53 Commits
Author SHA1 Message Date
Omar Sobh 55959b4920 ci: wire up CI, fix no_std build, fix stale package names in scripts
CI / test (push) Failing after 15s
- Add .gitea/workflows/ci.yml running scripts/ci-test.sh (fmt, clippy,
  test, no_std check) on push/PR to main.
- Fix stale rustyhdf5-py/rustyhdf5-format package names in
  ci-test.sh/check-nostd.sh, which had been silently no-op'ing those
  checks (cargo warns but doesn't fail on an unknown --exclude/-p
  target).
- With those checks actually running, fix the real issues they surface:
  - clippy: useless_conversion in chunked_write.rs, byte_char_slices in
    global_heap.rs/object_header.rs.
  - cargo fmt: apply formatting across the workspace (whitespace only).
  - no_std (thumbv7em-none-eabihf) build errors in clawhdf5-format:
    core::sync::atomic::AtomicU64 doesn't exist on that target (no
    native 64-bit atomics) — switch profiling.rs's counters to
    portable-atomic, which falls back to a CAS-based emulation there
    and is a no-op wrapper elsewhere. Add missing alloc imports for
    Box (filters.rs), Vec (filters_szip.rs), and format! (dict_encoding.rs)
    on no_std paths. Replace f64::powi (std/libm-only) with a small
    local exponentiation-by-squaring helper in the scale-offset filter.
2026-08-05 10:50:13 -07:00
Omar SobhandClaude Sonnet 5 b70d594c4f perf: O(1) chunk cache lookup with shared Arc buffers instead of O(n) scan+clone
The decompressed-chunk LRU cache was the hottest path in the read pipeline
(every chunked-dataset read goes through it) but did a linear scan through
up to 521 slots on every get/put, and a full buffer copy on every cache hit
(to_vec()/clone() of the whole decompressed chunk). chunked_read.rs then
cloned the buffer a second time just to insert it into the cache after
already having it in hand.

- Added a HashMap<ChunkCoord, usize> index alongside the LRU slots for O(1)
  lookup. Eviction uses swap_remove, so the swapped-in slot's index entry is
  fixed up on every eviction (covered by a dedicated test).
- CachedChunk.data is now Arc<CacheAlignedBuffer> — a cache hit is a
  refcount bump, not a copy. CacheAlignedBuffer gained a Sync impl (same
  soundness argument as its existing Send impl: access is only ever through
  borrow-checked &/&mut, like Vec<u8>) so Arc<CacheAlignedBuffer> is itself
  Send/Sync.
- put_decompressed/put_decompressed_aligned now return the Arc they just
  inserted (or the existing cached copy), so callers can reuse that
  allocation instead of holding a separate clone — eliminates the second
  copy in chunked_read.rs's three call sites, which now consume the
  Arc<CacheAlignedBuffer> (Deref's to &[u8], so downstream indexing/copy
  code is unchanged).
- prefetch_hint's doc comment now leads with "bookkeeping only, does not
  prefetch" instead of describing behavior it doesn't have.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-05 07:46:05 -07:00
Omar SobhandClaude Sonnet 5 b9898c2a9c security: bound decompression output to prevent memory-exhaustion DoS
decompress_chunk() already threaded chunk_size (the pipeline's declared
decompressed size) into the scale-offset/nbit/szip decoders to bound their
output, but not into deflate/lz4/zstd/pcodec, all four of which allocated
based on attacker-controlled input with no cap:

- lz4: read a raw u32 "orig_size" straight from the compressed payload's
  first 4 bytes and passed it directly to lz4_flex::block::decompress with
  no upper bound — a 4-byte attacker-controlled field could request ~4 GiB.
- deflate (non-macOS path): unbounded flate2 read_to_end into a fresh Vec.
- zstd: zstd::decode_all with no output cap (classic decompression-bomb
  vector, ratios can exceed 1000:1).
- pcodec: simple_decompress with no cap.

All four now take the expected chunk size and reject output that exceeds it
(or a 256 MiB absolute ceiling when the size is unavailable), matching the
pattern the other three filters already used. Also fixes the same unbounded
read_to_end in clawhdf5-filters' fast_deflate streaming fallback (used when
no size hint is available).

Added tests for each codec plus one exercising the actually-exploited path
through the public decompress_chunk() entrypoint.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-05 07:38:57 -07:00
Omar SobhandClaude Sonnet 5 88195d1c33 docs: fix untraceable benchmark claims, add dual-audience framing, validate on second machine
- README's "HDF5 Core I/O" table claimed 19ns/2,080µs labeled 308× (real ratio
  ~109,000×) and a 313ns zero-copy mmap figure — neither traced to any dated
  benchmark in BENCHMARKS.md. Replaced the table wholesale with the existing
  "vs libhdf5 Summary" figures, relabeled from "h5py/C HDF5" to "libhdf5"
  (BENCHMARKS.md never benchmarks against h5py, only libhdf5 directly).
- Added two new Criterion benchmarks to close the coverage gaps that produced
  the untraceable numbers: metadata_open_from_disk (I/O-inclusive, fair
  clawhdf5-vs-libhdf5 file-open comparison) and metadata_parse_in_memory
  (clawhdf5-only, explicitly labeled as excluding I/O) in h5bench_meta.rs;
  read_zerocopy_mmap in h5bench_read.rs (forces real page-ins by summing
  elements rather than just returning a slice length — the mmap path turns
  out to be slower than a plain copy at these sizes, an honest, unflattering
  but real result now documented instead of a fabricated 313ns).
- Re-ran the full existing benchmark suite plus the two new ones on a second,
  independently administered machine (tank: Ryzen 7 7800X3D) to validate the
  numbers before publishing them. 5 of 6 rows landed within ~15% of the
  original i7-12650H figures; recorded both in BENCHMARKS.md's new
  "Independent Validation" section. README now cites the tank numbers.
- Added a short top-of-file README callout naming both halves of the project
  (general-purpose HDF5 library vs. agent memory layer) with links to
  BENCHMARKS.md and the Crate Map, so a data-infra reader isn't 60% through
  a memory-store pitch before finding the part relevant to them.
- Added one factual, no-names line noting benchmark numbers are being
  validated in collaboration with HDF5 Group engineers.
- Fixed the same untraceable "2-300x faster than h5py/C HDF5" / "313 ns"
  claims in docs/QUICKSTART.md, one click from the README's own "New here?"
  link.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-03 17:46:55 -07:00
Omar SobhandClaude Sonnet 5 6b1ea450f5 chore: cleanup pass — remove empty types stub, implement superblock v4, reconcile plan docs
- Remove clawhdf5-types (empty 1-line stub crate; type defs already live in
  clawhdf5-format). Update workspace Cargo.toml and CLAUDE.md accordingly.
- Implement HDF5 superblock v4 (page-buffer mode) read and write support in
  clawhdf5-format: Superblock::parse_v4, page_size field, v4 serialize
  branch, and FileWriter::with_page_size. This was the one task left
  unimplemented from docs/superpowers/plans/2026-06-29-format-write-extensions.md.
- Reconcile the three docs/superpowers/plans/*.md docs (filter codecs,
  format write extensions, MPI-IO VOL) against actual shipped code: they
  were pre-work plans for d6c4d4f (2026-06-30) committed to git late on
  2026-08-03 with all checkboxes still unchecked. Mark completed tasks done
  and add a status note so they read as historical records, not open work.
- Refresh ROADMAP.md's "What's Next" section against current repo state.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-03 08:11:31 -07:00
Omar Sobh b1fc23e975 docs: add superpowers implementation plans (MPI-IO VOL backend, format write extensions, filter codecs) 2026-08-03 02:46:23 +00:00
Omar SobhandClaude Sonnet 4.6 1347746973 docs: consolidate benchmark.md into BENCHMARKS.md
- Merge libhdf5 1.14.6 head-to-head comparison from benchmark.md into
  BENCHMARKS.md h5bench section (sequential read/write, chunked write,
  metadata — attribute write, group create)
- Recalculate speedup ratios using current clawhdf5 numbers (post auto-shuffle):
  chunked write 512×512 now 38.4× faster than libhdf5 (was 16×)
- Fix groups_create/traverse column header mismatch: data was k=4/16/32/64
  but labeled k=4/16/64/128; corrected with separate Groups table
- Clarify Pcodec codec comparison benchmarked without auto-shuffle (shuffle
  degrades Pcodec which handles byte organization internally)
- Add "vs libhdf5 Summary" and "Why the Gaps" interpretation sections
- Delete benchmark.md (content fully absorbed)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-07-01 22:01:16 +00:00
Omar SobhandClaude Sonnet 4.6 c30ed0cda5 docs: update benchmarks and README with post-improvement numbers
- Write Path: WAL single save 134 µs → 18 µs (group-commit append, HDF5 batched at flush)
- Write Path: no-WAL save 91 µs → 61 µs (owned-Vec IO path)
- Summary table: memory write <135 µs → <20 µs
- Chunked write table: reflect auto-shuffle numbers (Zstd 748 MiB/s, deflate 719 MiB/s at 512×512)
- Add Pcodec to chunked write comparison and clawhdf5-format feature flags table
- Bump BENCHMARKS.md date to 2026-07-01

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-07-01 02:55:50 +00:00
Omar SobhandClaude Sonnet 4.6 d8ef8785e2 perf: lower parallel compress threshold from 4 to 2 chunks
Enables Rayon parallel compression for typical 4-chunk workloads (e.g.,
128×128 matrix with 32-row chunks). Rayon's dispatch overhead is ~2 µs,
worthwhile at ≥3 chunks with real compression work per chunk.

Previously the threshold was "> 4" which excluded 4-chunk datasets entirely
from parallel compression. Now "> 2" covers 3+ chunks.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-07-01 01:53:41 +00:00
Omar SobhandClaude Sonnet 4.6 e23e0358e0 docs: update BENCHMARKS.md with 2026-07-01 h5bench results
Post all write-path improvements (chunk-cache, SIMD shuffle, Zstd codec,
auto-shuffle pre-filter, owned-Vec IO, WAL group commit, Pcodec codec):

Chunked write (deflate+shuffle) 512×512: baseline 3.33 ms → 1.35 ms (-59%)
Chunked write (Zstd-3+shuffle) 512×512: 1.34 ms / 748 MiB/s

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-07-01 01:44:25 +00:00
Omar SobhandClaude Sonnet 4.6 2f9f73bf24 perf: switch embedding compression to Zstd-3 + remove redundant shuffle call
- Use Zstd level 3 instead of deflate(1) for embedding dataset compression.
  Auto-shuffle (already the default since the TDT pre-filter commit) is now
  the only shuffle needed — the explicit .with_shuffle() call was redundant.
- Benchmark: save_without_wal_single improves 67 → 61 µs (-9%).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-07-01 01:37:58 +00:00
Omar SobhandClaude Sonnet 4.6 aa3e12f3ae perf: WAL group commit — batch serialize + deferred header updates
Implements WAL group commit optimizations (arXiv:2507.13062):

1. Serialize each WAL entry to a local Vec<u8> before writing, reducing
   write() syscalls per entry from ~8 to 1.

2. Defer header entry_count updates to every GROUP_COMMIT_SIZE (8) entries
   instead of per-entry, eliminating 3 lseek() + 1 write() per entry.

3. Fix read_entries() to read until EOF instead of looping entry_count
   times — the header count is now a pre-allocation hint only. This is
   strictly more robust: tolerates stale counts from deferred updates AND
   truncated files from crashes mid-write.

Benchmark results:
- wal_flush_100_entries: -7.8% latency improvement (469 µs)
- save_with_wal_single: -1.7% (18.2 µs)
- save_without_wal_single: -2.3% (67 µs, full HDF5 write)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-07-01 01:33:35 +00:00
Omar SobhandClaude Sonnet 4.6 d41e5ecfdd feat: auto-apply shuffle before compression codecs (TDT byte-grouping)
Following arXiv:2506.18062 (TDT pre-filter) and matching h5py default
behavior: the shuffle filter is now automatically applied before any
compression codec (deflate, Zstd, LZ4, Pcodec) unless explicitly
disabled with .without_shuffle().

Benchmark results (f32 matrices, shuffle+codec vs unshuffled baseline):
- Zstd-3 at 512×512: 610 → 764 MiB/s (+25%)
- Deflate-6 at 128×128: 132 → 401 MiB/s (+204%)
- Deflate-6 at 512×512: 280 → 745 MiB/s (+166%)

Both codecs now reach parity at ~750 MiB/s for large matrices.

Changes:
- Add no_shuffle field to ChunkOptions (opt-out via .without_shuffle())
- Auto-add FILTER_SHUFFLE in build_pipeline() when compression is active
- Add DatasetBuilder.without_shuffle() method
- Update pipeline tests to reflect new 2-filter default
- Add chunk_options_pipeline_deflate_no_shuffle test
- Update BENCHMARKS.md with measured throughput improvements

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-07-01 01:21:06 +00:00
Omar SobhandClaude Sonnet 4.6 5701e8045d feat: add Pcodec lossless numerical compression filter (arXiv:2502.06112)
Implements Pcodec (filter ID 32023) via the `pco` 1.0.x crate as a new
optional compression codec. Pcodec achieves 30–94% better compression
ratio than Zstd for f32/f64 columnar data at 1–5 GiB/s decompression
speed, making it ideal for write-once/read-many embedding archives.

Write throughput at 512×512: 591 MiB/s (parity with Zstd-3 at 610 MiB/s).
For smaller chunks Zstd-3 remains faster due to Pcodec's fixed per-chunk
distributional analysis overhead.

- Add FILTER_PCODEC = 32023 constant to filter_pipeline.rs
- Add pcodec_compress/pcodec_decompress using pco::standalone API
- Wire into compress_chunk/decompress_chunk dispatch
- Add ChunkOptions.pcodec field and DatasetBuilder.with_pcodec() method
- Enable pcodec as highest-priority codec in build_pipeline()
- Add pco dep (optional, feature = "pcodec") to clawhdf5-format/clawhdf5
- Add write_2d_chunked_pcodec benchmark comparing pcodec vs zstd-3
- Document results in BENCHMARKS.md

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-07-01 01:16:08 +00:00
Omar SobhandClaude Sonnet 4.6 e82b8f56bd bench: enable zstd in bench crate, update codec comparison results
Add features = ["zstd"] to clawhdf5-bench dev-dependency so the
write_2d_chunked_zstd benchmark no longer panics with UnsupportedFilter(32015).

Update BENCHMARKS.md and README.md with measured results from the full
h5bench write suite (2026-06-30, post write-performance improvements):
- Zstd-3 hits 593 MiB/s at 512×512 vs deflate-6's 280 MiB/s (2.12×)
- Zstd-3 hits 330 MiB/s at 128×128 vs deflate-6's 132 MiB/s (2.51×)
- Sequential f64 batch write improved ~8-11% from owned-Vec IO path

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-30 23:49:13 +00:00
Omar SobhandClaude Sonnet 4.6 2ddb22897c perf: eliminate double compression and improve shuffle filter throughput
Four independent write-path improvements:

1. Cache compressed chunks between Pass 1 and Pass 2 (chunked_write.rs,
   file_writer.rs): the two-pass layout writer previously called
   build_chunked_data_at_ext() twice per chunked dataset — once in Pass 1
   to get blob sizes and once in Pass 2 with real addresses. Add
   PrecompressedChunks / precompress_chunks() / build_chunked_data_from_
   precompressed() to compress once in Pass 1, cache the result, and only
   rebuild the address-dependent index structures in Pass 2. Expected
   ~2× speedup for chunked+deflate writes (512×512 deflate: 3.33ms → ~1.7ms).

2. SIMD-vectorisable shuffle filter (filters.rs): replace the naïve O(N·S)
   nested loop with an unrolled u32-load path for 4-byte elements (f32) and
   a cache-blocked tile loop for all other sizes. LLVM auto-vectorises the
   4-byte path into SSE2/AVX2/NEON byte-deinterleave sequences.

3. Zstd benchmark variant (h5bench_write.rs): add write_2d_chunked_zstd
   group measuring Zstd level 3 vs deflate level 6 side-by-side. Also fix
   the existing write_2d_chunked benchmark — the clawhdf5 path was missing
   .with_deflate(6), making the comparison apples-to-oranges. Add arXiv-
   backed doc recommendation on DatasetBuilder::with_zstd().

4. Zero-copy HNSW save (hnsw.rs, clawhdf5-io/lib.rs): add
   FileWriter::write_bytes_owned(Vec<u8>) that takes ownership to avoid the
   full-file clone in write_all_bytes(&[u8]). HNSW::save_to_hdf5 uses it.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-30 22:36:40 +00:00
Omar SobhandClaude Sonnet 4.6 3a1fcc5cb3 docs: add standalone benchmark.md with clawhdf5 vs libhdf5 comparison
Full head-to-head results from Criterion suite (100 samples each):
sequential read/write, chunked write + deflate, metadata ops, group
traversal. Includes interpretation section explaining the structural
reasons for each gap.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-30 19:26:16 +00:00
Omar SobhandClaude Sonnet 4.6 bf197b70e3 docs+fix: add h5bench benchmark results and repair libhdf5-compare feature
Add h5bench-equivalent Criterion benchmark results to BENCHMARKS.md
(sequential read/write, chunked read/write, metadata throughput).

Fix libhdf5-compare feature for HDF5 1.14.x:
- Switch to hdf5-metno 0.12 (aliased as 'hdf5') in clawhdf5-bench
- Fix h5bench_meta.rs: AttributeBuilderEmpty::create takes &str not &String;
  shape=[1] dataset uses write(&[val]) not write_scalar
- Fix h5bench_read.rs: libhdf5-compare variant now writes its own reference
  file via hdf5-metno instead of dumping clawhdf5 bytes (avoids float
  datatype message incompatibility)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-30 16:39:52 +00:00
Omar SobhandClaude Sonnet 4.6 cb0b0e9df2 fix: correct libaec constants, HDF5→libaec option mapping, and VDS serialization
Critical fixes from whole-branch code review:
- libaec-sys: fix flag constants to match <libaec.h> exactly
  (PREPROCESS=8, MSB=4, RESTRICTED=16; drop non-existent AEC_ALLOW_K13)
  and add aec_buffer_encode FFI declaration
- filters_szip: fix cd index for bits_per_sample (cd[2] per H5Z_SZIP_PARM_BPP,
  not cd[4]); fix option-mask mapping (NN=0x20, MSB unconditional); add two
  real encode→decode roundtrip tests (no-NN and NN) that exercise libaec end-to-end
- file_writer: fix serialize_vds_mappings to delegate to data_layout_write
  (eliminates the buggy duplicate that always emitted version=1 even for
  external-file mappings); retains trailing Jenkins checksum

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-30 11:41:41 +00:00
Omar SobhandClaude Sonnet 4.6 e91f7fc539 fix: correct libaec FFI to use aec_stream struct (fixes SIGSEGV)
The previous aec_buffer_decode declaration used flat parameters which
don't match the actual libaec C API; this caused a SIGSEGV at runtime.
Replace with the correct aec_stream struct (mirroring <libaec.h>) and
update filters_szip.rs to populate and pass &mut AecStream.
Also add empty-input guard in szip_decode_impl and fallback library
path search in build.rs for distros that omit the .pc file.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-30 11:41:41 +00:00
Omar SobhandClaude Sonnet 4.6 d6c4d4f111 feat: implement filter codecs, format write extensions, and MPI-IO VOL
All three SDD plans fully wired and committed to main:

Filter Codecs (FC):
- FC-1: Implement float E-scale in scaleoffset_decompress (value = minval +
  code * 2^E, negative exponents via cast to i32); add two round-trip tests.
- FC-2: filters_szip.rs — feature-gated SZIP decode via libaec FFI; SZIP
  dispatch arm added to decompress_chunk.
- FC-3: libaec-sys workspace crate with pkg-config probe and aec_buffer_decode
  FFI binding; added to workspace members.

Format Write Extensions (FWE):
- FWE-1: GroupBuilder::add_external_link() API; wired through FinishedGroup
  → GrpFlat → file_writer pass 1/2/3 (OH size, layout cursor, final write);
  external_link_write_roundtrip test.
- FWE-2: data_layout_write.rs — serialize_vds_mappings with length_size param
  and version 0/1 (external vs same-file) selection; declared as pub mod.
- FWE-3: with_virtual_sources empty-mapping guard (Important #9) — empty vec
  is silently ignored; vds_empty_mapping_list test updated to assert non-VDS
  layout results.

MPI-IO VOL Backend (MPI):
- MPI-1/2/3: mpi_vol.rs — MpiVol implementing VirtualObjectLayer; root-read
  + broadcast collective read; gather + root-write collective write; feature-
  gated mpi-io feature; wired into clawhdf5-io lib.rs.
- MPI-4: mpi_io_bench binary (h5bench-equivalent MPI-IO throughput bench).

mpi_vol.rs reviewer fixes:
- Doc-comment updated to accurately describe root-read+broadcast pattern
  (not MPI_File_read_at); MpiVol::expected_capabilities() associated fn
  added so tests can verify capabilities without a live MPI universe;
  rank_and_size_stub_values renamed to no_feature_error_contains_feature_name.

Workspace check: zero warnings, 20 test suites pass.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-30 11:41:41 +00:00
Omar SobhandClaude Sonnet 4.6 90bdd7cd13 feat: add h5bench-equivalent Criterion benchmarks to clawhdf5-bench
Adds three Criterion benchmark suites mirroring the h5bench HPC I/O
benchmark workloads in pure Rust — no C libhdf5 required for the default
path, with an optional `libhdf5-compare` feature for side-by-side numbers.

  - benches/h5bench_write.rs: write_1d_contiguous, write_2d_chunked,
    write_f64_batch, write_multi_dataset, write_with_attrs
  - benches/h5bench_read.rs: read_sequential, read_f64_sequential,
    read_chunked_2d, read_from_disk, read_hyperslab
  - benches/h5bench_meta.rs: metadata_attrs_write, metadata_attrs_read,
    metadata_groups_create, metadata_groups_traverse, metadata_string_attrs

All benchmarks pass `cargo bench --bench <name> -- --test` and clippy
reports zero warnings.  Run with `cargo bench -p clawhdf5-bench`.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-30 11:41:41 +00:00
Omar SobhandClaude Sonnet 4.6 28a0dc3384 feat: add Virtual Dataset (VDS) write support and round-trip tests
Add `virtual_sources: Option<Vec<VdsMapping>>` field and `with_virtual_sources()` method to `DatasetBuilder`. In `FileWriter::finish()`, VDS datasets skip raw-data storage and instead serialize their source mappings into a global heap collection (version-1 same-file encoding) referenced by an HDF5 v4 layout-class-3 message. The two-pass address-computation loop handles VDS in both passes: pass 1 computes the fixed-size OH and pre-builds the heap blob; pass 2 places the blob at the correct file offset and rebuilds the OH with the real global heap address. Three new tests verify: (a) same-file two-source round-trip with mapping verification, (b) external-file source encoding, and (c) empty mapping list.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-30 11:41:41 +00:00
osobh bae80d030b Update README.md 2026-06-29 23:58:43 +00:00
osobhandClaude Opus 4.8 8534c7d204 feat: migrate-engine improvements (content validation, schema, streaming, incremental)
clawhdf5-migrate:
- Real content validation: the post-migration check reads the written HDF5
  back (new hdf5_reader) and compares actual content — chunk text, embeddings,
  and every session/entity/relation field — to the source, not just row counts.
  A representative sample of chunk rows is verified by default; --validate-full
  checks every row. A count-preserving corruption no longer passes.
- Configurable schema: SQL is built from a SchemaConfig (table + ordered column
  names, defaulting to the ZeroClaw layout) instead of hardcoded queries, with
  --chunks-table / --sessions-table / --entities-table / --relations-table.
- Streaming count pass: --dry-run does a COUNT(*)-only pass per table instead
  of loading every row.
- Incremental migration: --incremental reads the existing output, reads only
  source chunks with id greater than the last migrated id, and appends them
  (metadata groups refreshed from source) rather than re-migrating everything.

clawhdf5-format:
- read_as_f32 / read_as_f64 now decode IEEE-754 half-precision (2-byte) floats
  via a no_std-safe bit conversion — needed to read float16-stored embeddings
  back (e.g. for migrate's content validation), previously a TypeMismatch.

Tests: f16 read unit test; migrate tests for content-corruption detection,
custom table names, and incremental append; CLI smoke-tested end-to-end and the
dense/incremental output verified with h5py.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-04 02:19:31 +00:00
osobhandClaude Opus 4.8 0754afb7f2 feat: write multi-block fractal heaps (root indirect block)
Dense attribute and dense link storage capped at a single fractal-heap direct
block (~64 KiB of heap data — a few thousand objects); beyond that the writer
produced an invalid oversized block. Lift the cap with a root indirect block.

When the serialized objects don't fit in one direct block, build a root
indirect block (FHIB) over multiple direct blocks sized by the doubling table
(start 512, width 4, doubling per row up to 64 KiB). Objects are packed
row-major across blocks, each block carries its logical block offset, and heap
IDs encode each object's heap offset (block offset + position). The FRHP points
root -> FHIB with the row count; unused slots in the current rows are undefined.

The fractal-heap builder is unified: FractalHeapBlock now carries the full heap
blob, and a shared write_frhp helper serializes the header for both the
single-block and multi-block paths. The single-block path is unchanged
(byte-identical), so existing dense attrs/links stay valid.

Validated end-to-end: a 2,500-attribute object and a 2,500-link group round-trip
through our reader and are read correctly by h5py. Objects still may not span a
block (no huge-object path).

Tests: facade round-trips for multi-block dense attrs and dense links, plus an
h5py-gated interop test (verified against the real h5py environment).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-04 01:37:42 +00:00
osobhandClaude Opus 4.8 0aab49f2f0 fix: read multi-direct-block fractal heaps (root indirect block)
The fractal-heap reader split direct vs indirect block rows using the FRHP
"Starting # of Rows in Root Indirect Block" field (a constant, typically 1),
mislabeled as starting_row_of_indirect_blocks. For any heap whose data spans
more than one direct block — common in libhdf5 files with a large group or
many dense attributes — this treated direct blocks as indirect and walked into
garbage, failing with InvalidFractalHeapSignature.

Derive the split from the heap geometry instead: max_direct_rows =
log2(max_direct_block_size / starting_block_size) + 2. Rows below it hold
direct blocks; rows at/above hold child indirect blocks.

Validated against an h5py-written group with 400 dense attributes (root
indirect block, 4 rows, 13 direct blocks): all values now read correctly.
Regression fixture covers an 80-attribute multi-block heap.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-04 01:27:45 +00:00
osobhandClaude Opus 4.8 20ad16ab69 feat: write dense group link storage (fractal heap + v2 B-tree)
A group with more than 8 links (libhdf5's compact max_compact default) is now
written densely instead of as inline Link messages: the links live in a
single-direct-block fractal heap indexed by a v2 B-tree of type 5 (link-name
index), referenced from the group's LinkInfo message. This matches libhdf5's
compact->dense switchover and keeps large groups out of the object header.

- Extract the byte-identical fractal-heap builder shared by dense attributes
  and dense links, parameterized by heap_id_length / max_heap_size. Attributes
  keep 8 / 40; links use 7 / 32 to match libhdf5 (reverse-engineered: an
  h5py-written dense group uses heap_id_length 7, max_heap_size 32, type-5
  record = hash(4) + heap_id(7) = 11 bytes). This was the cause of an initial
  "object overruns end of direct block" error from h5py.
- build_group_oh gained an optional dense LinkInfo (omitting inline Link
  messages); the two-pass file assembly allocates each group's link blob after
  its object header and rebuilds it with real target addresses in the final
  pass (link-message size is address-independent, so layout is stable).

Validated end-to-end: our reader round-trips dense groups, h5py reads the
groups we write, and dense attributes remain byte-identical (still h5py-valid).
The agent's 9-dataset memory group now writes densely and round-trips. Single
direct block only (~a couple thousand links); indirect blocks remain a TODO.

Tests: facade round-trip (20-link dense group + compact sibling) and an
h5py-gated interop test confirming libhdf5 reads our dense groups.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-04 01:12:23 +00:00
osobhandClaude Opus 4.8 e0189cd5c4 harden: make the new readers panic-free on malformed input
The readers added this cycle parse untrusted bytes, so malformed/hostile
input must produce errors — never a panic, OOM, or unbounded recursion.
Audited each new surface and fixed the concrete vectors, each covered by an
adversarial regression test:

- Paged Fixed Array: `1 << max_nelmts_bits` shift overflow (u8 up to 255);
  element-count bounded by file size; element/page offset multiplies checked.
- H5S selection decoder: ALL/NONE validate they have the 16 bytes they claim
  to consume; hyperslab rank capped at 32 (H5S_MAX_RANK); iter_linear
  coordinate/stride/product arithmetic uses checked ops.
- VDS mapping parser: drop pre-allocation from the untrusted `nused`;
  bounds-check all selection slicing.
- scale-offset / N-Bit filters: `1 << minbits` overflow at minbits==64; N-Bit
  `bit_offset + precision` overflow; N-Bit type-tree recursion depth capped to
  stop a crafted nested tree from overflowing the stack; element counts bounded
  by the chunk's expected decompressed size (threaded the previously-unused
  chunk_size into both decoders) so a bogus count can't over-allocate.
- VDS assembly: a virtual dataset whose source is itself virtual (a cycle) now
  errors instead of recursing into a stack overflow.

16 new adversarial tests; full format suite (482 lib) + agent + facade green;
clippy clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-04 00:28:33 +00:00
osobhandClaude Opus 4.8 4fa7e89a46 feat: compress fixed-length string datasets (+ fix shared chunk-cache bug)
clawhdf5-agent: fixed-length string datasets (memory text chunks, session
summaries, ids, tags, entity/relation names) were stored uncompressed behind
a stale "chunked compound not yet supported" comment. Chunked writes work for
fixed-size string/compound datatypes like any other, so write_string_dataset
now chunks + deflates once a dataset's payload reaches 4 KiB — large,
redundant NullPad content compresses well while tiny metadata stays
contiguous (no chunk-overhead bloat). The dead `compress` parameter is
removed in favor of this size heuristic.

clawhdf5-format: enabling string compression exposed a latent bug — the
per-file ChunkCache built its chunk index once and reused it for every
chunked dataset in the file, keyed only by chunk coordinate with no dataset
discrimination. With one chunked dataset per file this never surfaced; with
two of different rank (a 1-D compressed string array and the 2-D embeddings
matrix) the first dataset's rank-1 index was reused for the second, panicking
with an out-of-bounds chunk coordinate. The cache now binds to a dataset by
its chunk-index address and rebinds — dropping the index, chunk-index map,
layout, and decompressed slots — whenever the dataset being read changes,
while still caching repeated/sequential access to the same dataset.

Tests: facade regression reading a 1-D compressed string dataset and a 2-D
compressed f32 dataset through one shared File cache (verified to panic
without the fix); existing agent e2e tests (large text chunks, migration
round-trip) now pass with compression on.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-03 22:48:39 +00:00
osobhandClaude Opus 4.8 57adc88320 test: regression for scale-offset float E-scale (raw + masked filter)
The HDF5 library does not implement the scale-offset filter's floating-point
E-scale mode. When asked for it (cd_values[0] = 1) it stores the chunk raw
(no minbits/minval header) and sets the chunk filter mask to skip the filter,
so such datasets read back verbatim purely by honoring the per-chunk filter
mask — no E-scale decoder is required.

Add a fixture written via the HDF5 low-level API (exact-representable values)
and a test asserting it reads back verbatim, locking in the filter-mask path.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-03 22:14:01 +00:00
osobhandClaude Opus 4.8 e6f0d8f161 feat: read external-file Virtual Datasets via a source resolver
VDS sources living in other files were previously unsupported because the
pure-byte read API has no filesystem. Add a resolver seam and wire a default.

clawhdf5-format:
- Add VdsSourceResolver (Fn(&str) -> Option<Vec<u8>>) and
  read_raw_data_full_with_resolver. read_virtual_data uses the resolver to
  fetch an external source file's bytes by its stored name, then reads the
  named source dataset from those bytes and scatters as usual. A resolver
  returning None leaves the region at fill (HDF5's missing-source behavior);
  an external source with no resolver at all is a clean error. read_raw_data_full
  is unchanged (delegates with no resolver).

clawhdf5:
- File now records the directory it was opened from and, for virtual layouts,
  reads through a default resolver that loads sibling source files relative to
  that directory. So File::open(virt).dataset(d).read_*() transparently
  assembles cross-file VDS. In-memory files (from_bytes) have no directory, so
  only same-file VDS resolves there.

Tests: format-layer external read with an injected resolver (and the
no-resolver error path), plus facade tests that drop both files in a temp dir
and read through File::open — covering successful resolution and the
missing-source-is-fill case.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-03 21:19:36 +00:00
osobhandClaude Opus 4.8 98ccc69411 feat: extend same-file VDS assembly to N dimensions
Generalize Selection iteration from 1-D to arbitrary rank: iter_linear(dims)
enumerates a selection's row-major linear indices over a dataspace of the
given shape (ALL, NONE, regular hyperslabs, points), which is the order HDF5
uses to pair virtual and source selections.

read_virtual_data now passes the full virtual/source dimensions instead of a
single extent, so multi-dimensional block mappings scatter to the correct
non-contiguous linear positions. read_named_dataset_raw returns the source
dataset's dimensions. The rank-1 restriction is removed; only external-file
sources remain unsupported.

Tests: 2-D integration fixture (vds_2d_same_file.h5: two 2x2 sources placed
as non-contiguous blocks in a 4x4 virtual) plus N-D iter_linear unit tests
(block, strided, ALL, rank-mismatch). The 1-D path is unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-03 20:57:12 +00:00
osobhandClaude Opus 4.8 908af40282 feat: assemble 1-D same-file Virtual Datasets (VDS)
A virtual layout previously returned UnsupportedVersion. Implement reading
for the common 1-D, same-file case, reverse-engineered and validated against
HDF5 2.0.

- Rewrite parse_vds_mappings to the real global-heap block format
  (version(1) · nused(length_size) · entries · checksum(4)), where each
  entry is source-file(null) · source-dataset(null) · source-selection ·
  virtual-selection. Block version 1 encodes a same-file source as a single
  0x04 marker in place of the file name; version 0 stores an explicit file
  name. The selections are H5S-serialized and self-describing in length, so
  they are decoded to find entry boundaries. The previous parser used a
  guessed layout that did not match real files.

- Extend Selection with decode_serialized() (H5S_select_serialize: ALL,
  NONE, and version-3 regular hyperslabs) and iter_linear_1d().

- Add read_virtual_data: resolve the mapping block from the global heap,
  read each same-file source dataset, and scatter its selected elements into
  the virtual buffer; unmapped regions stay at the zero fill value.
  External-file sources and N-D selections return a clean unsupported error.

Tests: real-file integration test (vds_same_file.h5: partial source slice +
fill gap), selection decoder unit tests built from the fixture bytes, and
same-file/external mapping-parser unit tests.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-03 20:01:51 +00:00
osobhandClaude Opus 4.8 a24fcb8be4 feat: read paged Fixed Array chunk indexes
A filtered, fixed-dimension dataset with more than one Fixed Array
data-block page (>1024 chunks by default) previously failed with
"paged Fixed Array data blocks not yet supported".

Implement the paged data-block layout, reverse-engineered and validated
against an HDF5 2.0 file:
- after the FADB prefix: a page-init bitmap (one bit per page, MSB-first
  within each byte), a 4-byte checksum, then the pages;
- each page is a fixed full-size slot of page_nelmts elements plus a
  4-byte checksum, with only the final page shorter;
- uninitialized pages still occupy their slot (zero-filled), so the
  bitmap — not a 0xFF sentinel — marks a whole page unallocated.

Element parsing is factored into parse_fa_element, shared by the
non-paged and paged paths.

Tests: real-file integration test against a minimal 2-page gzip fixture
(v4_fixed_array_paged.h5) plus a synthetic unit test covering a
multi-byte/MSB-first bitmap, a skipped uninitialized page, and a short
final page.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-03 17:41:31 +00:00
osobhandClaude Opus 4.8 4b1f4e369a docs: changelog for array-typed datatype reads
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 20:12:33 +00:00
osobhandClaude Opus 4.8 c99fb39ffd feat: read array-typed datatypes (incl. array compound members)
The typed read paths (read_as_i32/i64/u64/f32/f64) rejected Array datatypes
with a TypeMismatch, so an array-typed compound member (common with N-Bit /
reduced-precision data) could not be read. They now unwrap an Array to its base
type and read the flat sequence of base elements, recursing for nested arrays.
Base-type precision rules (e.g. reduced-precision sign extension) apply to the
elements.

Validated end-to-end against an HDF5 2.0 compound with an array member: the
array field reads [-1, 100, 1000, -32768] with correct 16-bit sign extension.
Adds a regression test for flat and nested array reads.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 20:12:21 +00:00
osobhandClaude Opus 4.8 dbd683dcaf docs: changelog for compound/array N-Bit support
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 13:04:22 +00:00
osobhandClaude Opus 4.8 06a1ef5285 feat: decode compound and array N-Bit layouts
Generalizes the N-Bit filter (id 5) decoder from atomic-only to the full
recursive type tree carried in the filter client data: atomic
([1, size, order, precision, offset]), array ([2, total_size, <base>]) and
compound ([3, total_size, nmembers, (offset, <node>)*]), nestable to any depth.

The decoder parses the tree once, then walks it per element with an MSB-first
bit reader, placing each leaf field's significant bits at its byte/bit offset in
a zero-filled element — HDF5's canonical layout. Float members are encoded as
full-precision atomics and handled transparently. Validated end-to-end against
HDF5 2.0 / h5py: compound int+int, compound with an array member, and compound
with a float member all decode to the exact canonical bytes. Adds h5py-free unit
tests from real captured chunks. (Reading array-typed compound *fields* into a
flat buffer is a separate datatype-reader concern.)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 13:04:08 +00:00
osobhandClaude Opus 4.8 8c68b5de33 docs: changelog for reduced-precision integer sign-extension
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:48:23 +00:00
osobhandClaude Opus 4.8 249841e232 fix: sign-extend reduced-precision fixed-point integers on read
HDF5 stores a fixed-point value whose datatype precision is smaller than its
storage size zero-filled above the precision; the sign of a reduced-precision
signed integer lives in the precision field, not the storage word, and is
applied during datatype conversion. clawhdf5 previously read the full storage
word, so e.g. a 16-bit-precision -1 (stored 0x0000ffff) read as 65535.

The integer read paths (read_as_i32/i64/u64/f32/f64) now extract the
[bit_offset, bit_offset+bit_precision) field and sign-extend (signed) or mask
(unsigned). Full-width types are unchanged — the bulk-copy fast paths are gated
to full width, so the common case keeps its memcpy and behaviour.

This completes signed N-Bit reads (now exact end-to-end) and also fixes
un-filtered reduced-precision signed/unsigned integer datasets. Validated
against HDF5 2.0 / h5py; adds h5py-free regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:48:08 +00:00
osobhandClaude Opus 4.8 bff039fa29 docs: changelog for float D-scale and N-Bit filter support
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:36:50 +00:00
osobhandClaude Opus 4.8 4a5ab1c584 feat: decode the HDF5 N-Bit filter (atomic variant)
Implements decompression for the N-Bit filter (id 5), atomic integer/float
variant — previously returned UnsupportedFilter(5). N-Bit packs each element's
significant `precision` bits MSB-first with no header; decode reads those bits
per element and places them at the datatype's bit offset in a zero-filled
`size`-byte element, reproducing HDF5's canonical reduced-precision layout
(verified byte-for-byte against the equivalent un-filtered dataset).

Reverse-engineered and validated against HDF5 2.0 / h5py: unsigned
reduced-precision datasets now read end-to-end with exact values. Signed
reduced-precision values are restored to their canonical (zero-filled) bytes;
sign-extending them to the application width is the datatype reader's job — a
pre-existing concern shared with un-filtered reduced-precision data. Recursive
compound/array N-Bit layouts remain unsupported. Adds h5py-free unit tests from
real captured chunks.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:36:13 +00:00
osobhandClaude Opus 4.8 9062b3fb53 feat: decode the float D-scale scale-offset variant
Extends the scale-offset filter (id 6) decoder to the floating-point D-scale
variant (H5Z_SO_FLOAT_DSCALE) alongside the integer variant. Shares the header
parsing and MSB-first code unpacking; reconstruction is
`value = minval + code / 10^scale_factor`, where `minval` is the minimum float
stored in the header and the all-ones code is the (defined) fill value.

Reverse-engineered and validated against HDF5 2.0 / h5py across f32 and f64,
negatives, decimal scale factors D=1..5 and multi-chunk datasets (decoded values
match h5py to full precision). The float E-scale variant remains unsupported.
Adds h5py-free unit tests from real captured chunks.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:28:20 +00:00
osobhandClaude Opus 4.8 ec7357de45 docs: changelog entry for scale-offset filter support
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:18:55 +00:00
osobhandClaude Opus 4.8 6ab42c2f07 feat: decode the HDF5 scale-offset filter (integer variant)
Implements decompression for the scale-offset filter (id 6), integer mode
(H5Z_SO_INT) — previously returned UnsupportedFilter(6). The on-disk format was
reverse-engineered against HDF5 2.0 / h5py and verified across signed/unsigned
element sizes, negative minima, multi-chunk datasets and fill-value handling:

  minbits (u32 LE) | 0x08 | minval (8 bytes LE) | 8 reserved bytes |
  MSB-first packed codes (nelmts * minbits bits)

Each code is `value - minval`; the all-ones code is reserved for the (defined)
fill value. The floating-point variants (D-scale/E-scale) use a different
algorithm and remain reported as unsupported.

Validated end-to-end (a 200-element chunked scale-offset dataset, plus negative
and unsigned datasets, now decode to the exact h5py values). Adds h5py-free unit
tests using real captured compressed chunks.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:18:42 +00:00
osobhandClaude Opus 4.8 19ca662975 docs: changelog entry for HDF5 2.0 (version-5) read fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:01:58 +00:00
osobhandClaude Opus 4.8 bc3a3a977a fix: read array datatypes and chunked layouts from HDF5 2.0 (version 5)
Follow-up to the v5 compound fix, found by an interop sweep over diverse
h5py/HDF5 2.0 (libver=latest) datasets:

- Array datatype (class 10) version 5 was rejected. v3/v4/v5 share the same
  array encoding, so the parser now accepts 3-5.
- Data Layout message version 5 was rejected, which broke EVERY chunked/
  compressed dataset written by modern HDF5. v5 reuses the v4 message
  structure, so it now routes through parse_v4.

Validated end-to-end: a gzip-compressed, Fixed-Array-indexed v5 chunked dataset
now decodes to the correct values. Adds h5py-free regression tests using the
real v5 array-datatype and chunked-layout bytes, and updates the layout
invalid-version test to use v6.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:00:38 +00:00
osobhandClaude Opus 4.8 a13ff51918 fix: read compound datatypes from HDF5 1.14+/2.0 (datatype version 5)
clawhdf5 rejected datatype message version 5 for the compound class with
"invalid datatype version 5 for class 6", so it could not read compound
datasets written by modern HDF5 / h5py with libver=latest. v5 reuses the same
compact member encoding as v3/v4 (name, variable-width offset, member type), so
the parser now accepts versions 3-5 for compound.

Found by running the previously-ignored h5py interop tests against h5py 3.16 /
HDF5 2.0. Adds an h5py-free regression test using the real v5 datatype bytes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 11:55:15 +00:00
osobhandClaude Opus 4.8 3ff501c8ef docs: add Unreleased changelog section for post-2.1.0 changes
Records the parallel chunk-compression perf change and the doc sweep that
landed after the v2.1.0 tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 11:42:43 +00:00
osobhandClaude Opus 4.8 49a99a9a40 docs: document hnsw/format feature flags and missing agent modules
- Add the `hnsw` flag (default-on) to the agent feature table and the
  fast-deflate/system-zlib/fast-checksum/lz4/zstd/blake3 flags to the format
  table.
- Add entity_extract and async_memory to the agent module overview.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 11:40:41 +00:00
osobhandClaude Opus 4.8 b9fac46ea5 perf: wire parallel chunk compression into the write path
build_chunked_data_at_ext now compresses all chunks via compress_all_chunks
(previously dead code) before laying them out, so compression runs across
rayon threads under the `parallel` feature when there are >4 filtered chunks.
Layout is unchanged — compression preserves chunk order, so on-disk bytes are
identical to the sequential path. The agent crate enables `parallel`, so this
speeds up compressed embedding writes.

Removes the #[allow(dead_code)] on compress_all_chunks and gates
PARALLEL_COMPRESS_THRESHOLD behind the `parallel` feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 11:40:40 +00:00
osobhandClaude Opus 4.8 f1762f82a7 docs: fix stale package names, counts, and CLI subcommands
Sweep of the docs after the v2.0.0 rename and recent changes:
- Per-crate READMEs (13 files): rename leftover rustyhdf5-*/edgehdf5-*
  package names and badges to clawhdf5-*, bump usage versions to 2.1.0.
- README: update stale test badge (417 -> 1500+), workspace stats
  (15 crates/72K -> 17 crates/84K), agent crate stats (20.7K/32 modules),
  and add the missing clawhdf5-napi and clawhdf5-bench crates to the tree.
- CLAUDE.md: correct the CLI subcommand list (inspect/dump/index/search ->
  the actual create/save/search/recall/stats/flush-wal/agents-md/export/snapshot).

No code changes. Verified there are zero todo!()/unimplemented!() macros and
no TODO/FIXME comments in the tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 11:17:04 +00:00
89 changed files with 10796 additions and 1044 deletions
+26
View File
@@ -0,0 +1,26 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
container: rust:latest
steps:
- uses: actions/checkout@v4
- name: Cache cargo registry/target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Install rustfmt & clippy components
run: rustup component add rustfmt clippy
- name: Install thumbv7em-none-eabihf target
run: rustup target add thumbv7em-none-eabihf
- name: Run CI script
run: bash scripts/ci-test.sh
+259 -4
View File
@@ -4,7 +4,7 @@
**System:** Intel i7-12650H (10C/16T, 4.7 GHz boost) · 32 GB DDR5 · Linux 6.8.0
**Rust:** 1.96.0-nightly (2026-03-14) · `--release` profile
**Date:** 2026-03-20
**Date:** 2026-07-01
---
@@ -114,8 +114,8 @@ HDF5 persistence with optional Write-Ahead Log.
| Operation | Latency | Notes |
|-----------|---------|-------|
| Single save (no WAL) | 91 µs | Direct HDF5 write |
| Single save (with WAL) | 134 µs | +47% for crash safety |
| Single save (no WAL) | 61 µs | Direct HDF5 write (owned-Vec IO path) |
| Single save (with WAL) | 18 µs | WAL group-commit append; HDF5 write batched at flush |
| Batch 100 | 723 µs | 7.2 µs per record |
| Batch 1,000 | 6.17 ms | 6.2 µs per record |
| WAL save (1K existing) | 539 µs | Incremental append |
@@ -160,7 +160,7 @@ End-to-end strategy evaluation including embedding operations.
| **Hybrid vector+keyword** | <200 µs | 1K records |
| **Knowledge graph query** | <25 µs | 1K entities |
| **Temporal range query** | <1 µs | 10K timestamps |
| **Memory write** | <135 µs | Per record |
| **Memory write** | <20 µs | Per record (WAL group-commit append) |
| **Consolidation cycle** | <165 µs | 1K records |
| **Importance gate** | <1 µs | Per record |
@@ -394,3 +394,258 @@ cargo run --release --bin footprint_bench
cargo run --release --bin consolidation_efficiency
cargo run --release --bin ephemeral_perf
```
---
## h5bench-Equivalent I/O Benchmarks
Criterion harness mirroring h5bench serial workloads. clawhdf5 benchmarks dated 2026-07-01;
libhdf5 1.14.6 head-to-head comparison dated 2026-06-30 (same hardware, same Criterion harness).
```bash
cargo bench -p clawhdf5-bench # clawhdf5-only
cargo bench -p clawhdf5-bench --features libhdf5-compare # head-to-head
```
### Sequential Read Throughput
Both read a 1-D contiguous f32 dataset. clawhdf5 parses from `Vec<u8>` (zero-copy);
libhdf5 reads from a temp file including `open` + `read` + `close` overhead.
| Workload | n=1K | n=10K | n=100K |
|----------|------|-------|--------|
| **clawhdf5** f32 | 634 ns / **5.9 GiB/s** | 2.44 µs / **15.3 GiB/s** | 24.5 µs / **15.2 GiB/s** |
| libhdf5 f32 | 45.2 µs / 85 MiB/s | 47.8 µs / 799 MiB/s | 73.9 µs / 5.0 GiB/s |
| **Speedup** | **71×** | **20×** | **3.0×** |
| clawhdf5 f64 | 743 ns / **10.0 GiB/s** | 4.17 µs / **17.8 GiB/s** | 43.3 µs / **17.2 GiB/s** |
| clawhdf5 from_disk (f64, OS I/O) | — | 10.1 µs / **7.4 GiB/s** | 77.6 µs / **9.6 GiB/s** |
| clawhdf5 hyperslab (f64, 10% slice) | — | 4.09 µs / **1.8 GiB/s** | 50.1 µs / **1.5 GiB/s** |
libhdf5 f64 comparison excluded — clawhdf5's datatype encoding differs from libhdf5's (known
gap), making cross-format reads unreliable for comparison.
### Chunked Read Throughput
| Matrix size | Latency | Throughput |
|-------------|---------|-----------|
| 64×64 f32 | 6.39 µs | **2.4 GiB/s** |
| 256×256 f32 | 41.7 µs | **5.9 GiB/s** |
| 512×512 f32 | 176 µs | **5.5 GiB/s** |
### Sequential Write Throughput
Both write to disk. At 100K elements both converge on the OS `write()` syscall ceiling.
| Workload | n=1K | n=10K | n=100K |
|----------|------|-------|--------|
| **clawhdf5** f32 | 9.44 µs / **404 MiB/s** | 25 µs / **1.49 GiB/s** | 228 µs / **1.63 GiB/s** |
| libhdf5 f32 | 77.9 µs / 49 MiB/s | 87.8 µs / 435 MiB/s | 214 µs / 1.74 GiB/s |
| **Speedup** | **8.2×** | **3.5×** | **≈ tie** |
| clawhdf5 f64 embeddings | 6.50 µs (n=128) | 8.67 µs (n=512) / **450 MiB/s** | 10.27 µs (n=1K) / **761 MiB/s** |
### Chunked Write: Codec Comparison (with auto-shuffle)
Auto-shuffle is applied before all compression codecs by default — AoS→SoA byte transpose,
implements byte-grouping pre-filter per arXiv:2506.18062. Shuffle dramatically improves
throughput for float/int data by creating long runs of similar bytes.
| Matrix size | Zstd-3 + shuffle | Deflate-6 + shuffle | Speedup |
|-------------|-----------------|---------------------|---------|
| 32×32 f32 | 48 µs / **81 MiB/s** | 39 µs / **100 MiB/s** | Deflate 1.23× faster (small chunk) |
| 128×128 f32 | **148 µs / 422 MiB/s** | 153 µs / **407 MiB/s** | Parity |
| 512×512 f32 | **1.34 ms / 748 MiB/s** | 1.39 ms / **719 MiB/s** | Zstd 1.04× faster |
Impact of auto-shuffle vs no-shuffle baseline:
| Matrix size | Zstd-3 speedup | Deflate-6 speedup |
|-------------|----------------|-------------------|
| 32×32 | +19% | +38% |
| 128×128 | +25% | **+204%** |
| 512×512 | +25% | **+157%** |
Both codecs perform at parity at large sizes (~720–750 MiB/s). Use `.with_zstd(3)` or
`.with_deflate(6)` for write-heavy workloads. Use `.without_shuffle()` only for byte arrays
or data that doesn't benefit from AoS→SoA transposition.
### Chunked Write vs libhdf5 (deflate-6)
clawhdf5 compresses all chunks in memory and issues a single `write()`. libhdf5 flushes each
chunk individually via its Virtual File Layer (one `pwrite()` per chunk).
| Matrix | clawhdf5 deflate-6 + shuffle | libhdf5 deflate-6 | Speedup |
|--------|------------------------------|-------------------|---------|
| 32×32 f32 | 39 µs / 100 MiB/s | 172 µs / 23 MiB/s | **4.4×** |
| 128×128 f32 | 153 µs / 407 MiB/s | 3,150 µs / 20 MiB/s | **20.6×** |
| 512×512 f32 | 1,390 µs / 719 MiB/s | 53,300 µs / 19 MiB/s | **38.4×** |
The 32×32 speedup (4.4×) is lower than the 512×512 speedup (38.4×) because shuffle adds
overhead that dominates at 4 KB chunks. libhdf5 was benchmarked without shuffle. The speedup
compounds with matrix size because libhdf5's per-chunk VFL overhead is proportional to chunk
count while clawhdf5's single-pass cost is constant.
### Codec Comparison: Pcodec vs Zstd-3
Pcodec (arXiv:2502.06112) is a pure-Rust lossless numerical codec with 30–94% better compression
ratio than Zstd for f32/f64 columns. Both sides benchmarked **without** auto-shuffle here (shuffle
degrades Pcodec which handles byte organization internally; Zstd-3 without shuffle numbers shown
for an apples-to-apples comparison).
| Matrix size | Pcodec | Zstd-3 (no shuffle) | Winner |
|-------------|--------|---------------------|--------|
| 32×32 f32 | 95 µs / **41 MiB/s** | 57 µs / **68 MiB/s** | Zstd-3 (1.66×) |
| 128×128 f32 | 528 µs / **118 MiB/s** | 179 µs / **349 MiB/s** | Zstd-3 (2.95×) |
| 512×512 f32 | 1.69 ms / **591 MiB/s** | 1.64 ms / **610 MiB/s** | Parity (3% diff) |
Pcodec's fixed per-chunk distributional analysis overhead (~400 µs) dominates at 32×32 (4 KB).
At 512×512 (1 MB) the speeds converge. **Pcodec's advantage is compression ratio, not encode
speed** — less data on disk means faster reads and lower storage cost. Enable with
`.with_pcodec()` for write-once/read-many workloads (embedding archives, scientific datasets).
### Metadata Throughput
clawhdf5 accumulates all metadata in memory and serializes in one pass. libhdf5 acquires a
global file mutex and flushes to disk on every attribute write or group creation.
**Attributes and datasets** (k = attribute or dataset count):
| Workload | k=4 | k=16 | k=64 | k=128 |
|----------|-----|------|------|-------|
| **clawhdf5** attrs_write (i64) | 8.05 µs / 494 Kop/s | 17.2 µs / 932 Kop/s | 49.2 µs / 1.30 Mop/s | 87.3 µs / 1.47 Mop/s |
| libhdf5 attrs_write | 100 µs / 40 Kop/s | 170 µs / 94 Kop/s | 472 µs / 136 Kop/s | 929 µs / 138 Kop/s |
| **Speedup** | **12.4×** | **9.9×** | **9.6×** | **10.6×** |
| clawhdf5 attrs_read | 1.06 µs / 3.78 Mop/s | 3.64 µs / 4.39 Mop/s | 15.7 µs / 4.08 Mop/s | 31.3 µs / 4.09 Mop/s |
| clawhdf5 string_attrs (write+read) | 5.17 µs / 774 Kop/s | 16.5 µs / 967 Kop/s | 33.6 µs / 951 Kop/s | — |
| clawhdf5 multi_dataset_write | 10.1 µs / 397 Kop/s | 31.5 µs / 508 Kop/s | 104 µs / 614 Kop/s | — |
**Groups** (k = group count):
| Workload | k=4 | k=16 | k=32 | k=64 |
|----------|-----|------|------|------|
| **clawhdf5** groups_create | 12.1 µs / 330 Kop/s | 33.7 µs / 475 Kop/s | 66.7 µs / 480 Kop/s | 121 µs / 529 Kop/s |
| libhdf5 groups_create | 140 µs / 28 Kop/s | 433 µs / 37 Kop/s | 690 µs / 46 Kop/s | 1,340 µs / 48 Kop/s |
| **Speedup** | **11.6×** | **12.8×** | **9.5×** | **11.1×** |
| clawhdf5 groups_traverse | 664 ns / 6.0 Mop/s | 3.55 µs / 4.5 Mop/s | 4.87 µs / 6.6 Mop/s | 10.6 µs / 6.0 Mop/s |
---
## vs libhdf5 Summary
| Workload | clawhdf5 | libhdf5 | Speedup |
|----------|----------|---------|---------|
| Sequential read, 1K f32 | 634 ns | 45.2 µs | **71×** |
| Sequential read, 100K f32 | 24.5 µs · 15.2 GiB/s | 73.9 µs · 5.0 GiB/s | **3.0×** |
| Sequential write, 100K f32 | 228 µs · 1.63 GiB/s | 214 µs · 1.74 GiB/s | **≈ tie** |
| Chunked write deflate-6, 512×512 | 1,390 µs · 719 MiB/s | 53,300 µs · 19 MiB/s | **38.4×** |
| Attribute write, 128 attrs | 87.3 µs · 1.47 Mop/s | 929 µs · 138 Kop/s | **10.6×** |
| Group create, 64 groups | 121 µs · 529 Kop/s | 1,340 µs · 48 Kop/s | **11.1×** |
### Why the Gaps
**Metadata (10–13×):** libhdf5 was designed for MPI parallel filesystems where every metadata
write must be immediately visible to other processes. It acquires a global file mutex and
flushes to disk per operation. clawhdf5 builds the entire file in memory and writes it in one
shot — no locking, no flushing, no C heap allocation per message.
**Chunked compressed write (4–38×):** libhdf5 writes each chunk individually through its VFL
(Virtual File Layer), one `pwrite()` per chunk. clawhdf5 compresses all chunks in memory (Rayon
parallel when > 2 chunks), lays them out contiguously, and issues a single `write()`. The
speedup compounds with matrix size: libhdf5's per-chunk overhead is proportional to chunk count
while clawhdf5's architectural cost is constant.
**Small reads (20–71×):** libhdf5's per-open overhead (chunk cache init, SWMR lock, metadata
read) dominates at sub-millisecond payloads. clawhdf5 has no global state — `File::from_bytes()`
starts parsing immediately.
**Large contiguous writes (≈ tie at 100K):** Both are bottlenecked by the OS `write()` syscall
to the page cache. There is no algorithmic headroom above ~1.7 GiB/s on this hardware.
### Caveats
- libhdf5 f64 read comparison excluded — clawhdf5's f32 datatype encoding differs from libhdf5's (known compatibility gap). f64 results are clawhdf5-only.
- Serial benchmarks. clawhdf5 uses Rayon for chunk compression when > 2 chunks; that parallelism is already reflected in the chunked write numbers.
- clawhdf5 reads from `Vec<u8>` (zero-copy from mmap in production); libhdf5 reads from a temp file. This gives clawhdf5 a structural read advantage that reflects realistic API usage.
---
## Independent Validation: tank (Ryzen 7 7800X3D), 2026-08-03
The `vs libhdf5 Summary` numbers above were re-run on a second, independently
administered machine (`tank`: AMD Ryzen 7 7800X3D, 8C/16T, Ubuntu 26.04, libhdf5
1.14.6 via `apt`) to confirm they reproduce off the original i7-12650H box, and to
add benchmark coverage for two claims that a documentation review found were not
traceable to any dated benchmark run (see git history around 2026-08-03 for context).
This section documents both.
### Reproduction of the vs-libhdf5 Summary table
| Workload | clawhdf5 (tank) | libhdf5 (tank) | Speedup (tank) | Speedup (i7-12650H, above) |
|----------|-----------------|-----------------|----------------|------------------------------|
| Sequential read, 1K f32 | 553 ns | 44.2 µs | **79.9×** | 71× |
| Sequential read, 100K f32 | 23.3 µs | 63.6 µs | **2.7×** | 3.0× |
| Sequential write, 100K f32 | 210 µs | 189 µs | **≈ tie** (clawhdf5 ~11% behind) | ≈ tie (clawhdf5 ~7% behind) |
| Chunked write deflate-6, 512×512 | 1.44 ms | 65.0 ms | **45.3×** | 38.4× |
| Attribute write, 128 attrs | 85.2 µs | 877 µs | **10.3×** | 10.6× |
| Group create, 64 groups | 130 µs | 1.37 ms | **10.6×** | 11.1× |
Five of six rows land within ~15% of the original i7-12650H figures — consistent
with normal cross-machine variance, not a methodology artifact. The chunked-write
row moved further (38.4× → 45.3×, +18%): tank's libhdf5 per-chunk write cost scales
worse relative to its own sequential-write throughput than on the i7, likely IPC/
memory-subsystem dependent. Both figures are real and dated; we report both rather
than picking one.
### New coverage: replacing the retracted "metadata parse / 308×" and "zero-copy mmap / 313 ns" claims
An earlier README revision cited `19 ns` vs `2,080 µs` (labeled, incorrectly, `308×`)
for "metadata parse," and `313 ns` for "zero-copy mmap" — neither figure traced to
any benchmark in this file. Both have been retracted from the README. In their
place, two new Criterion benchmarks were added
(`crates/clawhdf5-bench/benches/h5bench_meta.rs`,
`crates/clawhdf5-bench/benches/h5bench_read.rs`) and run on tank:
**`metadata_open_from_disk`** — opens a small file from disk (`std::fs::read` /
`hdf5::File::open`) and resolves one attribute. Both sides pay real OS I/O, unlike
the retracted claim.
| Operation | clawhdf5 | libhdf5 | Speedup |
|-----------|----------|---------|---------|
| Open file + read 1 attribute | 4.01 µs | 39.3 µs | **9.8×** |
**`metadata_parse_in_memory`** (clawhdf5-only) — times `File::from_bytes()` alone,
given bytes already resident in memory, i.e. header-parse cost with disk I/O
excluded. There is no fair libhdf5-side equivalent (its API has no "parse from an
in-memory buffer, skip the OS open" path), so this is reported standalone rather
than as a speedup multiple — this is the honest version of what the old `19 ns`
number was trying to claim.
| Operation | clawhdf5 (in-memory, no I/O) |
|-----------|------------------------------|
| Parse superblock + resolve 1 attribute | 549 ns |
**`read_zerocopy_mmap`** — opens via `MmapFile` and reads an f64 dataset through
`read_f64_zerocopy()`, summing every element to force the mapped pages to actually
fault in (returning only a slice length, as an earlier draft of this benchmark did,
would repeat the exact "measures nothing" mistake being fixed here).
| n (f64 elements) | clawhdf5 mmap (zerocopy, page-fault-forced) | clawhdf5 (`Vec<u8>` copy) | libhdf5 (disk open + copy) |
|-------------------|----------------------------------------------|----------------------------|------------------------------|
| 1,000 | 7.86 µs | 4.50 µs | 44.2 µs |
| 10,000 | 19.0 µs | 9.53 µs | 47.1 µs |
| 100,000 | 112 µs | 72.0 µs | 81.2 µs |
Honest result: at these sizes, forcing full materialization through the mmap path
is **not** faster than the plain `Vec<u8>` copy path — `mmap()`/page-fault overhead
per call outweighs the copy it avoids. This contradicts the retracted `313 ns`
claim outright and is a genuinely useful finding: `MmapFile`'s real advantage is
avoiding the allocation/copy for large files or sparse access patterns (lower peak
RSS, share pages across processes), not raw single-shot read latency at these
sizes. No README claim is made from this row; it's recorded here for the record
and to keep future readers from reintroducing the old number.
**Reproduce:**
```bash
cargo bench -p clawhdf5-bench --features libhdf5-compare --bench h5bench_meta -- metadata_open_from_disk
cargo bench -p clawhdf5-bench --features libhdf5-compare --bench h5bench_meta -- metadata_parse_in_memory
cargo bench -p clawhdf5-bench --features libhdf5-compare --bench h5bench_read -- read_zerocopy_mmap
```
+176
View File
@@ -1,5 +1,181 @@
# Changelog
## Unreleased
### New Features
- `clawhdf5-migrate`: substantial engine improvements:
- **Real content validation** — the post-migration check now reads the written
HDF5 back and compares actual content (chunk text, embeddings, and every
session/entity/relation field) against the source, not just row counts. A
representative sample of chunk rows is verified by default; `--validate-full`
checks every row. A corrupt migration that preserves counts no longer passes.
- **Configurable schema** — table names are no longer hardcoded; queries are
built from a `SchemaConfig` (table + ordered column names, defaulting to the
ZeroClaw layout) with `--chunks-table` / `--sessions-table` /
`--entities-table` / `--relations-table` overrides.
- **Streaming count pass** — `--dry-run` now does a `COUNT(*)`-only pass per
table instead of loading every row into memory.
- **Incremental migration** — `--incremental` reads the existing output, reads
only source chunks newer than the last migrated id, and appends them
(refreshing the metadata groups), instead of re-migrating everything.
- `clawhdf5-format`: read **IEEE-754 half-precision (f16)** floats. `read_as_f32`
/ `read_as_f64` previously only handled 4- and 8-byte floats; 2-byte floats
(e.g. float16-stored embeddings) now decode via a no_std-safe bit conversion.
- `clawhdf5-format`: **write multi-block fractal heaps** (root indirect block).
Dense attribute and dense link storage previously capped at a single direct
block (~64 KiB of heap data — a few thousand attributes/links). When the
objects exceed one direct block, the heap now lays out a root indirect block
(FHIB) over multiple direct blocks sized by the doubling table, distributing
objects across blocks with correct per-block heap offsets. Validated
end-to-end: a 2,500-attribute object and a 2,500-link group round-trip
through our reader and are read correctly by h5py. (Objects still may not
span a block — no huge-object path.)
- `clawhdf5-format`: **write dense group link storage** (fractal heap + v2
B-tree). A group with more than 8 links (libhdf5's compact `max_compact`
default) is now written densely — its links live in a fractal heap indexed by
a v2 B-tree of type 5 (link-name index) referenced from the group's LinkInfo
message — instead of as inline Link messages. This matches libhdf5's
compact→dense switchover and keeps large groups out of the object header.
Reverse-engineered against libhdf5: link heaps use `heap_id_length` 7 /
`max_heap_size` 32 (vs 8 / 40 for attributes). The shared single-direct-block
fractal-heap builder is now parameterized and used by both dense attributes
and dense links. Validated end-to-end: our reader round-trips, and h5py reads
the dense groups we write. (Single direct block — up to ~a couple thousand
links per group; beyond that needs indirect blocks, still unsupported.)
### Robustness
- `clawhdf5-format`: harden the readers added this cycle against malformed /
hostile input — they parse untrusted bytes and must return errors, never
panic, OOM, or recurse without bound. Fixed concrete vectors found by audit
and locked in with adversarial tests:
- **Paged Fixed Array**: `1 << max_nelmts_bits` shift overflow (a `u8` ≥ 64);
element/page offset multiplications now checked; element count bounded by
file size.
- **H5S selection decoder**: `ALL`/`NONE` no longer claim 16 bytes they don't
have; hyperslab `rank` capped at 32 (`H5S_MAX_RANK`) to stop a giant
allocation; `iter_linear` coordinate/stride/product arithmetic is checked.
- **VDS mapping parser**: no pre-allocation from the untrusted `nused`; all
selection slicing is bounds-checked.
- **scale-offset / N-Bit filters**: `1 << minbits` overflow at `minbits == 64`;
N-Bit `bit_offset + precision` overflow; N-Bit type-tree recursion depth
capped (no stack overflow from a crafted nested tree); element counts
bounded by the chunk's expected decompressed size so a bogus count can't
drive a huge allocation.
- **Virtual Dataset assembly**: a virtual dataset whose source is itself
virtual (a cycle) now errors instead of recursing into a stack overflow.
### New Features
- `clawhdf5-agent`: **compress fixed-length string datasets** (memory text
chunks, session summaries, ids, tags, entity/relation names, …). These were
always stored uncompressed with a "chunked compound not yet supported" note
that was simply stale — chunked writes work for fixed-size string/compound
datatypes like any other. `write_string_dataset` now chunks + deflates a
string dataset once its payload reaches 4 KiB, so large, highly-redundant
NullPad content shrinks substantially while tiny metadata stays contiguous
(no chunk-overhead bloat).
- `clawhdf5-format`: decode the **scale-offset filter** (id 6) — both the
integer variant (`H5Z_SO_INT`) and the floating-point **D-scale** variant
(`H5Z_SO_FLOAT_DSCALE`). Handles signed/unsigned int sizes, f32/f64, negative
minima, decimal scale factors and fill values; reverse-engineered against
HDF5 2.0 and validated end-to-end. The float E-scale variant remains
unsupported.
- `clawhdf5-format`: decode the **N-Bit filter** (id 5) — atomic, **compound**
and **array** layouts (the full recursive type tree, nestable to any depth),
previously unsupported. Signed and unsigned reduced-precision integers and
float members all read end-to-end, validated against HDF5 2.0.
### New Features
- `clawhdf5` / `clawhdf5-format`: read **external-file Virtual Datasets (VDS)**.
The format layer gains `read_raw_data_full_with_resolver` and a
`VdsSourceResolver` callback (`Fn(&str) -> Option<Vec<u8>>`) that maps a
stored source file name to its bytes, so the pure-byte reader can pull in
external sources without a filesystem of its own. The `clawhdf5` `File` API
wires a default resolver that reads sibling source files relative to the
opened file's directory, so `File::open(...).dataset(...).read_*()` now
transparently assembles cross-file VDS. A source file the resolver cannot
supply leaves its region at the fill value (matching HDF5); an external
source with no resolver at all is a clean error. In-memory files
(`File::from_bytes`) have no directory, so only same-file VDS resolves there.
- `clawhdf5-format`: assemble **same-file Virtual Datasets (VDS)** of any rank.
Previously a virtual layout returned `UnsupportedVersion`. The reader now
decodes the global-heap mapping block (reverse-engineered against HDF5 2.0:
`version · nused · [source-file · source-dataset · source-selection ·
virtual-selection]* · checksum`, including the block-version-1 same-file
marker), decodes the `H5S` source/virtual dataspace **selections** (ALL,
NONE, and version-3 regular hyperslabs), reads each same-file source dataset,
and scatters its selected elements into the virtual buffer in row-major order
(so multi-dimensional block mappings land correctly); unmapped regions are
left at the zero fill value. External-file sources return a clean unsupported
error. The previous `parse_vds_mappings` used a guessed layout that did not
match real files and is replaced.
### Tests
- `clawhdf5-format`: regression test for **scale-offset float E-scale**
datasets. The HDF5 library does not implement E-scale encoding — when asked
for it (`cd_values[0] = 1`) it stores the chunk raw and sets the chunk filter
mask to skip the filter — so these files read back verbatim purely by
honoring the per-chunk filter mask. The test locks in that behavior against a
fixture produced via the HDF5 low-level API; no E-scale decoder is needed.
### Bug Fixes
- `clawhdf5-format`: **read multi-direct-block fractal heaps**. The reader split
direct vs indirect block rows using the FRHP "Starting # of Rows in Root
Indirect Block" field (a constant, typically 1), so any heap whose data spans
more than one direct block — common in libhdf5 files with a large group or
many dense attributes — was misread as having indirect blocks and failed with
`InvalidFractalHeapSignature`. The split is now derived from the heap geometry
(`max_direct_rows = log2(max_direct / start) + 2`). Validated against an
h5py-written 400-dense-attribute group (root indirect block, 4 rows, 13 direct
blocks).
- `clawhdf5-format`: scope the per-file **chunk cache by dataset**. The shared
`ChunkCache` built its chunk index once and reused it for every chunked
dataset in the file, keyed only by chunk coordinate with no dataset
discrimination. With a single chunked dataset per file this was latent; once a
file holds two chunked datasets of different rank (e.g. a 1-D compressed
string array and the 2-D embeddings matrix), the first dataset's index was
reused for the second, panicking with an out-of-bounds chunk coordinate. The
cache now rebinds (dropping its index, chunk-index map, layout, and
decompressed slots) whenever the dataset being read changes, while still
caching repeated/sequential access to the same dataset.
- `clawhdf5-format`: read **paged Fixed Array** chunk indexes. A filtered,
fixed-dimension dataset with more than one data-block page (>1024 chunks by
default) previously failed with "paged Fixed Array data blocks not yet
supported". The reader now walks the page-init bitmap (MSB-first), skips
uninitialized pages, and resolves each page's fixed full-size slot (including
the short final page). Reverse-engineered and validated end-to-end against an
HDF5 2.0 file.
- `clawhdf5-format`: read **array-typed datatypes** (e.g. an array-typed
compound member) via `read_as_i32/i64/u64/f32/f64` — previously a
`TypeMismatch`. The array is read as a flat sequence of its base elements
(recursing for nested arrays), applying base-type precision rules.
- `clawhdf5-format`: **sign-extend reduced-precision fixed-point integers** on
read. A signed integer whose datatype precision is smaller than its storage
size is stored zero-filled, so e.g. a 16-bit-precision `-1` previously read as
`65535`. The integer read paths now extract the precision field and
sign-extend (full-width types are unchanged). Completes signed N-Bit reads and
also fixes un-filtered reduced-precision integer datasets.
- `clawhdf5-format`: read datasets written by modern HDF5 (1.14+/2.0, i.e.
`libver=latest`). Compound (class 6) and array (class 10) datatype **version 5**
messages and data layout **version 5** messages were rejected as invalid; they
reuse the v3/v4 binary structure, so they are now accepted. This unblocks
reading compound types and — critically — every chunked/compressed dataset
written by HDF5 2.0. Found by running the h5py interop tests against
h5py 3.16 / HDF5 2.0.
### Performance
- `clawhdf5-format`: chunked writes now compress all chunks up front via
`compress_all_chunks`, running across rayon threads under the `parallel`
feature when there are more than 4 filtered chunks. On-disk layout is
unchanged. Speeds up compressed embedding writes in `clawhdf5-agent` (which
enables `parallel`).
### Documentation
- Fix stale package names across all 13 per-crate READMEs (`rustyhdf5-*` /
`edgehdf5-*` → `clawhdf5-*`, usage versions → 2.1.0).
- Correct README workspace/test/crate stats and the CLAUDE.md CLI subcommand
list; document the `hnsw` and format compression/checksum feature flags and
the `entity_extract` / `async_memory` modules.
## v2.1.0 (2026-06-03)
### New Features
+3 -4
View File
@@ -5,12 +5,11 @@ Pure-Rust HDF5 format implementation with HNSW vector search, WAL-backed persist
## Architecture
Cargo workspace with 17 crates under `crates/`:
Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal FFI bindings crate for the optional `szip` feature):
| Crate | Role |
|-------|------|
| `clawhdf5-types` | Shared type definitions and physical constants |
| `clawhdf5-format` | HDF5 binary spec parser (superblock, B-tree, heap) |
| `clawhdf5-format` | HDF5 binary spec parser (superblock, B-tree, heap) — also holds shared type definitions and physical constants |
| `clawhdf5-io` | Read/write implementation |
| `clawhdf5-filters` | Compression filters (gzip, LZ4, Zstd, Blosc) |
| `clawhdf5-derive` | Proc-macro derive for HDF5-serializable structs |
@@ -54,7 +53,7 @@ cargo test --workspace
### CLI
```bash
cargo run -p clawhdf5-cli -- --help
# inspect, dump, index, search subcommands
# create, save, search, recall, stats, flush-wal, agents-md, export, snapshot subcommands
```
### Python bindings
+1 -1
View File
@@ -1,7 +1,6 @@
[workspace]
members = [
"crates/clawhdf5-format",
"crates/clawhdf5-types",
"crates/clawhdf5-io",
"crates/clawhdf5-filters",
"crates/clawhdf5-derive",
@@ -17,6 +16,7 @@ members = [
"crates/clawhdf5-cli",
"crates/clawhdf5-napi",
"crates/clawhdf5-bench",
"crates/libaec-sys",
]
resolver = "2"
+58 -19
View File
@@ -4,14 +4,19 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-1.75%2B-orange.svg)](https://www.rust-lang.org)
[![Tests](https://img.shields.io/badge/tests-417%20passing-brightgreen.svg)](#benchmarks)
[![Tests](https://img.shields.io/badge/tests-1500%2B%20passing-brightgreen.svg)](#benchmarks)
[![LongMemEval](https://img.shields.io/badge/LongMemEval-Hit@5%2046%25%20BM25--only-blue.svg)](BENCHMARKS.md#longmemeval-results)
[![Footprint](https://img.shields.io/badge/footprint-6.5%20KB%2Frecord-lightgrey.svg)](BENCHMARKS.md#memory-footprint)
ClawhDF5 is a pure-Rust HDF5 implementation combined with a research-grade agent memory engine. It gives AI agents persistent, searchable, cryptographically verifiable memory — all stored in a single portable file.
ClawHDF5 is a pure-Rust HDF5 implementation combined with a research-grade agent memory engine. It gives AI agents persistent, searchable, cryptographically verifiable memory — all stored in a single portable file.
> **Two things live here:**
> - **A general-purpose, pure-Rust HDF5 library** — zero C dependencies, NetCDF-4 support, SIMD/GPU acceleration. See the **[Crate Map](#crate-map)** and **[BENCHMARKS.md](BENCHMARKS.md)** for the libhdf5 head-to-head numbers.
> - **An agent memory layer built on top of it** — vector search, knowledge graph, hippocampal-style consolidation, in `clawhdf5-agent`.
```
cargo add clawhdf5-agent --features agent
cargo add clawhdf5 # core HDF5 read/write, no agent layer
cargo add clawhdf5-agent --features agent # + agent memory layer
```
> **New here?** Start with the **[Quickstart Guide](docs/QUICKSTART.md)** · See **[Use Cases](docs/USE_CASES.md)** · Read **[Benchmarks](BENCHMARKS.md)**
@@ -37,7 +42,21 @@ Every AI agent needs memory. Today that means scattered Markdown files, SQLite d
## Performance
Benchmarked on Intel i7-12650H (10C/16T), 384-dim embeddings, Criterion.rs.
Vector search and agent-memory operations below are benchmarked on Intel i7-12650H (10C/16T), 384-dim embeddings, Criterion.rs. The HDF5 Core I/O table immediately below is from a separate, independently reproduced run (see its own hardware note).
### HDF5 Core I/O (vs libhdf5 1.14.6)
*Benchmark numbers are being validated in collaboration with engineers from the HDF5 Group to confirm methodology and reproducibility.*
Figures below are from an independent reproduction run on a second machine (AMD Ryzen 7 7800X3D, 2026-08-03). Full methodology, the original i7-12650H run, and two additional benchmarks added to close prior coverage gaps (an I/O-inclusive metadata-open comparison and an honest zero-copy-mmap measurement) are in [BENCHMARKS.md § Independent Validation](BENCHMARKS.md#independent-validation-tank-ryzen-7-7800x3d-2026-08-03).
| Operation | ClawhDF5 | libhdf5 | Speedup |
|-----------|----------|---------|---------|
| Attribute write (128 attrs) | 85.2 µs | 877 µs | **10.3×** |
| Group create (64 groups) | 130 µs | 1.37 ms | **10.6×** |
| Chunked write, deflate-6 (512×512 f32) | 1.44 ms | 65.0 ms | **45.3×** |
| Sequential read (100K f32) | 23.3 µs | 63.6 µs | **2.7×** |
| Sequential write (100K f32) | 210 µs | 189 µs | **≈ tie** |
### Vector Search
@@ -57,17 +76,21 @@ Benchmarked on Intel i7-12650H (10C/16T), 384-dim embeddings, Criterion.rs.
| Spreading activation | **17 µs** | 100 entities |
| Temporal range query | **716 ns** | 10K timestamps |
| Consolidation cycle | **164 µs** | 1K records |
| Memory write (WAL) | **134 µs** | per record |
| Memory write (WAL) | **18 µs** | per record (group-commit append; HDF5 batched at flush) |
| Importance gate | **61 ns** | per record |
### HDF5 Core I/O (vs h5py/C HDF5)
### Chunked Write Throughput (codec comparison)
| Operation | ClawhDF5 | h5py (C) | Speedup |
|-----------|----------|----------|---------|
| Metadata parse | 19 ns | 2,080 µs | **308×** |
| Write 1M f64 | 0.82 ms | 1.60 ms | **2×** |
| Read 1M f64 | 0.28 ms | 0.65 ms | **2.3×** |
| Zero-copy mmap | 313 ns | N/A | — |
Measured with Criterion on f32 matrices. Auto-shuffle is applied before all compression codecs
by default (AoS→SoA byte transpose, +157–204% throughput for float data):
| Codec | 128×128 f32 | 512×512 f32 | Notes |
|-------|-------------|-------------|-------|
| Zstd level 3 | **148 µs / 422 MiB/s** | **1.34 ms / 748 MiB/s** | With auto-shuffle |
| Deflate level 6 | 153 µs / 407 MiB/s | 1.39 ms / 719 MiB/s | With auto-shuffle |
| Pcodec | 528 µs / 118 MiB/s | 1.69 ms / 591 MiB/s | Best compression ratio |
Use `.with_zstd(3)` or `.with_deflate(6)` for write-heavy workloads — both now perform at ~720–750 MiB/s on large matrices. Use `.with_pcodec()` for write-once/read-many workloads where compression ratio matters more than encode speed. Disable auto-shuffle with `.without_shuffle()` for byte arrays that don't benefit from AoS→SoA transposition.
> ¹ MemX ([arxiv:2603.16171](https://arxiv.org/abs/2603.16171), March 2026): Rust + libSQL, claims <90ms at 100K records.
@@ -170,9 +193,11 @@ ClawhDF5's agent memory engine implements research from 15+ recent papers on age
| **`vector_search`** | Flat cosine, pre-normed, SIMD, BLAS, GPU, parallel search paths |
| **`ivf` / `pq`** | IVF-PQ approximate nearest neighbor for billion-scale search |
| **`bm25`** | BM25 keyword index with TF-IDF scoring |
| **`entity_extract`** | Rule-based entity extraction from text chunks into the knowledge graph |
| **`wal`** | Write-ahead log for crash-safe persistence |
| **`memory_strategy`** | Pluggable strategies: save-every, semantic-shift, user-correction detection |
| **`decision_gate`** | Sub-microsecond trivial/substantive classification |
| **`async_memory`** | Tokio-based async wrapper over the memory store (`async` feature) |
---
@@ -313,7 +338,7 @@ let exported = backend.export_markdown("MEMORY.md")?;
## Crate Map
```
clawhdf5 workspace (15 crates, 72K lines of Rust)
clawhdf5 workspace (17 crates, 84K lines of Rust)
│
├── Core HDF5
│ ├── clawhdf5-types — Type system definitions
@@ -327,14 +352,18 @@ clawhdf5 workspace (15 crates, 72K lines of Rust)
│ └── clawhdf5-gpu — GPU compute (wgpu)
│
├── Agent Memory
│ ├── clawhdf5-agent — Memory engine (16.8K lines, 29 modules)
│ ├── clawhdf5-ann — HNSW approximate nearest neighbor
│ ├── clawhdf5-agent — Memory engine (20.7K lines, 32 modules)
│ ├── clawhdf5-ann — HNSW approximate nearest neighbor (default backend)
│ ├── clawhdf5-migrate — SQLite → HDF5 migration
│ ├── clawhdf5-android — Android JNI bridge
│ └── clawhdf5-cli — CLI tool
│
└── Bindings
└── clawhdf5-py — Python (PyO3)
├── Bindings
│ ├── clawhdf5-py — Python (PyO3)
│ └── clawhdf5-napi — Node.js (napi-rs)
│
└── Tooling
└── clawhdf5-bench — Benchmark suite
```
---
@@ -365,6 +394,7 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
|------|---------|-------------|
| `agent` | no | Full agent memory layer |
| `float16` | **yes** | Half-precision embedding storage (2× compression) |
| `hnsw` | **yes** | HNSW approximate vector index for `hybrid_search` (via `clawhdf5-ann`); disable for an exact linear scan |
| `parallel` | no | Rayon parallel search |
| `fast-math` | no | BLAS matrix-vector multiply |
| `accelerate` | no | Apple Accelerate / AMX (macOS) |
@@ -380,7 +410,15 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
| `deflate` | yes | Deflate compression |
| `checksum` | yes | Jenkins lookup3 verification |
| `provenance` | yes | SHA-256 provenance attributes |
| `parallel` | no | Parallel chunk encoding (rayon) |
| `fast-deflate` | **yes** | zlib-ng backend for faster deflate |
| `system-zlib-decompress` | **yes** | Use the system zlib for decompression where available |
| `parallel` | no | Parallel chunk encoding + compression (rayon) |
| `fast-checksum` | no | crc32fast-accelerated checksums |
| `lz4` | no | LZ4 block compression filter (id 32004) |
| `zstd` | no | Zstandard compression filter (id 32015) |
| `pcodec` | no | Pcodec lossless numerical codec (id 32023, via `pco` crate) |
| `system-zlib` / `zlib-rs` | no | Alternative zlib backends for deflate |
| `blake3_hash` | no | BLAKE3 content hashing for provenance |
---
@@ -401,7 +439,8 @@ cargo test --workspace # all 417+ tests
cargo test -p clawhdf5-agent # agent memory tests
# Benchmarks
cargo bench -p clawhdf5-agent # full benchmark suite
cargo bench -p clawhdf5-agent # agent memory suite
cargo bench -p clawhdf5-bench # h5bench-equivalent I/O suite
```
---
+13 -5
View File
@@ -151,12 +151,20 @@ All 8 tracks delivered. 1,546 tests passing, zero clippy warnings.
## What's Next
- [ ] CI/CD pipeline — GitHub Actions or Gitea Actions for automated testing
Verified against current repo state on 2026-08-03 (see also `docs/superpowers/plans/` for the filter-codec/format-write/MPI-IO work, now shipped):
- [ ] CI/CD pipeline — still no GitHub/Gitea Actions workflow in the repo; automated testing is manual only
- [ ] Academic benchmark cross-validation — reproduce MemX/LongMemEval under identical conditions
- [ ] TypeScript bridge — full npm package via `clawhdf5-napi` (scaffolding exists)
- [ ] Publish crates to crates.io
- [ ] Python wheel distribution via maturin for `clawhdf5-py`
- [ ] TypeScript bridge — `clawhdf5-napi` has no `package.json`; it's still Rust-only scaffolding, not a publishable npm package
- [ ] Publish crates to crates.io — no `publish` config anywhere in the workspace yet
- [ ] Python wheel distribution via maturin — `crates/clawhdf5-py/pyproject.toml` exists (maturin-buildable locally) but wheels aren't published anywhere
### Recently closed out (2026-08-03 cleanup pass)
- [x] Removed `clawhdf5-types` — it was an empty 1-line stub crate; shared type definitions already live in `clawhdf5-format`, so CLAUDE.md and the workspace manifest were corrected instead of filling it in
- [x] Superblock v4 (page-buffer mode) read/write — the only unimplemented task from `docs/superpowers/plans/2026-06-29-format-write-extensions.md`; now done (`Superblock::parse_v4`/`serialize`, `FileWriter::with_page_size`)
- [x] Reconciled the three `docs/superpowers/plans/*.md` docs against actual shipped code — they were pre-work plans for `d6c4d4f` (2026-06-30), committed to git late; checkboxes now reflect reality
---
_Last updated: 2026-04-12_
_Last updated: 2026-08-03_
+5 -5
View File
@@ -1,9 +1,9 @@
# rustyhdf5-accel
# clawhdf5-accel
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-accel.svg)](https://crates.io/crates/rustyhdf5-accel)
[![docs.rs](https://docs.rs/rustyhdf5-accel/badge.svg)](https://docs.rs/rustyhdf5-accel)
[![crates.io](https://img.shields.io/crates/v/clawhdf5-accel.svg)](https://crates.io/crates/clawhdf5-accel)
[![docs.rs](https://docs.rs/clawhdf5-accel/badge.svg)](https://docs.rs/clawhdf5-accel)
SIMD-accelerated operations for rustyhdf5.
SIMD-accelerated operations for clawhdf5.
## Features
@@ -15,7 +15,7 @@ SIMD-accelerated operations for rustyhdf5.
## Usage
```rust
use rustyhdf5_accel::checksum::crc32_simd;
use clawhdf5_accel::checksum::crc32_simd;
let crc = crc32_simd(&data);
```
+12 -6
View File
@@ -13,7 +13,8 @@ use std::arch::x86_64::*;
/// Caller must verify is_x86_feature_detected!("avx512f").
// SAFETY: Caller must have verified avx512f via is_x86_feature_detected!.
#[target_feature(enable = "avx512f")]
pub unsafe fn dot_product(a: &[f32], b: &[f32]) -> f32 { unsafe {
pub unsafe fn dot_product(a: &[f32], b: &[f32]) -> f32 {
unsafe {
assert_eq!(a.len(), b.len());
let len = a.len();
let mut i = 0;
@@ -48,7 +49,8 @@ pub unsafe fn dot_product(a: &[f32], b: &[f32]) -> f32 { unsafe {
}
sum
}}
}
}
/// AVX-512 cosine similarity — fused single pass.
///
@@ -56,7 +58,8 @@ pub unsafe fn dot_product(a: &[f32], b: &[f32]) -> f32 { unsafe {
/// Caller must verify is_x86_feature_detected!("avx512f").
// SAFETY: Caller must have verified avx512f via is_x86_feature_detected!.
#[target_feature(enable = "avx512f")]
pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { unsafe {
pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
unsafe {
assert_eq!(a.len(), b.len());
let len = a.len();
let mut i = 0;
@@ -87,7 +90,8 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { unsafe {
let denom = (norm_a * norm_b).sqrt();
if denom == 0.0 { 0.0 } else { dot / denom }
}}
}
}
/// AVX-512 L2 distance.
///
@@ -95,7 +99,8 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { unsafe {
/// Caller must verify is_x86_feature_detected!("avx512f").
// SAFETY: Caller must have verified avx512f via is_x86_feature_detected!.
#[target_feature(enable = "avx512f")]
pub unsafe fn l2_distance(a: &[f32], b: &[f32]) -> f32 { unsafe {
pub unsafe fn l2_distance(a: &[f32], b: &[f32]) -> f32 {
unsafe {
assert_eq!(a.len(), b.len());
let len = a.len();
let mut i = 0;
@@ -118,4 +123,5 @@ pub unsafe fn l2_distance(a: &[f32], b: &[f32]) -> f32 { unsafe {
}
sum.sqrt()
}}
}
}
+7 -7
View File
@@ -1,18 +1,18 @@
# edgehdf5-memory
# clawhdf5-agent
[![crates.io](https://img.shields.io/crates/v/edgehdf5-memory.svg)](https://crates.io/crates/edgehdf5-memory)
[![docs.rs](https://img.shields.io/docsrs/edgehdf5-memory)](https://docs.rs/edgehdf5-memory)
[![crates.io](https://img.shields.io/crates/v/clawhdf5-agent.svg)](https://crates.io/crates/clawhdf5-agent)
[![docs.rs](https://img.shields.io/docsrs/clawhdf5-agent)](https://docs.rs/clawhdf5-agent)
HDF5-backed persistent memory store for on-device AI agents.
Built on [rustyhdf5](https://crates.io/crates/rustyhdf5), edgehdf5-memory provides a vector-searchable memory backend optimized for edge AI workloads. Store embeddings, text chunks, and metadata in a single HDF5 file with SIMD-accelerated similarity search.
Built on [clawhdf5](https://crates.io/crates/clawhdf5), clawhdf5-agent provides a vector-searchable memory backend optimized for edge AI workloads. Store embeddings, text chunks, and metadata in a single HDF5 file with SIMD-accelerated similarity search.
## Features
- Persistent vector store in HDF5 format
- Cosine similarity and L2 distance search
- SIMD-accelerated via rustyhdf5-accel (AVX2, NEON)
- Optional GPU acceleration via rustyhdf5-gpu
- SIMD-accelerated via clawhdf5-accel (AVX2, NEON)
- Optional GPU acceleration via clawhdf5-gpu
- Memory-mapped access for large stores
- f16 storage support for compact embeddings
@@ -20,7 +20,7 @@ Built on [rustyhdf5](https://crates.io/crates/rustyhdf5), edgehdf5-memory provid
```toml
[dependencies]
edgehdf5-memory = "1.93"
clawhdf5-agent = "2.1.0"
```
## License
+2 -1
View File
@@ -116,7 +116,8 @@ impl GpuSearchBackend {
// If we don't have an accelerator but now above threshold, try init
if vectors.len() >= self.threshold
&& let Ok(mut accel) = clawhdf5_gpu::GpuAccelerator::new() {
&& let Ok(mut accel) = clawhdf5_gpu::GpuAccelerator::new()
{
let flat: Vec<f32> = vectors.iter().flat_map(|v| v.iter().copied()).collect();
if accel.upload_vectors(&flat, self.dim).is_ok()
&& accel.upload_norms(norms).is_ok()
+30 -21
View File
@@ -65,7 +65,7 @@ fn build_memory_group(
let mut group = builder.create_group("memory");
// chunks: fixed-length string array
write_string_dataset(&mut group, "chunks", &cache.chunks, false);
write_string_dataset(&mut group, "chunks", &cache.chunks);
// embeddings: f32 [N x D]
let n = cache.embeddings.len() as u64;
@@ -83,14 +83,15 @@ fn build_memory_group(
let rows_per_chunk = (target_chunk_bytes / (d * 4)).max(1).min(n);
ds.with_chunks(&[rows_per_chunk, d]);
// Compression: shuffle + deflate for embeddings when enabled
// Compression: Zstd for embeddings — faster than deflate at same ratio.
// Shuffle is applied automatically (auto-shuffle pre-filter).
if config.compression {
let level = if config.compression_level > 0 {
config.compression_level
config.compression_level.min(22)
} else {
1 // fast default for embeddings
3 // Zstd level 3: fast + good ratio for f32 embeddings
};
ds.with_shuffle().with_deflate(level);
ds.with_zstd(level);
}
}
@@ -101,7 +102,7 @@ fn build_memory_group(
}
// source_channel: fixed-length string array
write_string_dataset(&mut group, "source_channel", &cache.source_channels, false);
write_string_dataset(&mut group, "source_channel", &cache.source_channels);
// timestamps: f64 array
group
@@ -109,11 +110,11 @@ fn build_memory_group(
.with_f64_data(&cache.timestamps)
.fill_time(FillTime::Never);
// session_ids: fixed-length string array (no compression — chunked compound not yet supported)
write_string_dataset(&mut group, "session_ids", &cache.session_ids, false);
// session_ids: fixed-length string array (auto-compressed when large)
write_string_dataset(&mut group, "session_ids", &cache.session_ids);
// tags: fixed-length string array (no compression — chunked compound not yet supported)
write_string_dataset(&mut group, "tags", &cache.tags, false);
// tags: fixed-length string array (auto-compressed when large)
write_string_dataset(&mut group, "tags", &cache.tags);
// tombstones: u8 array — use compact if small
{
@@ -150,7 +151,7 @@ fn build_sessions_group(
let mut group = builder.create_group("sessions");
let ids: Vec<String> = sessions.entries.iter().map(|e| e.id.clone()).collect();
write_string_dataset(&mut group, "ids", &ids, false);
write_string_dataset(&mut group, "ids", &ids);
let start_idxs: Vec<i64> = sessions
.entries
@@ -165,14 +166,14 @@ fn build_sessions_group(
group.create_dataset("end_idxs").with_i64_data(&end_idxs);
let channels: Vec<String> = sessions.entries.iter().map(|e| e.channel.clone()).collect();
write_string_dataset(&mut group, "channels", &channels, false);
write_string_dataset(&mut group, "channels", &channels);
let timestamps: Vec<f64> = sessions.entries.iter().map(|e| e.ts).collect();
group
.create_dataset("timestamps")
.with_f64_data(&timestamps);
write_string_dataset(&mut group, "summaries", &sessions.summaries, false);
write_string_dataset(&mut group, "summaries", &sessions.summaries);
let finished = group.finish();
builder.add_group(finished);
@@ -192,14 +193,14 @@ fn build_knowledge_group(
.with_i64_data(&entity_ids);
let entity_names: Vec<String> = knowledge.entities.iter().map(|e| e.name.clone()).collect();
write_string_dataset(&mut group, "entity_names", &entity_names, false);
write_string_dataset(&mut group, "entity_names", &entity_names);
let entity_types: Vec<String> = knowledge
.entities
.iter()
.map(|e| e.entity_type.clone())
.collect();
write_string_dataset(&mut group, "entity_types", &entity_types, false);
write_string_dataset(&mut group, "entity_types", &entity_types);
let emb_idxs: Vec<i64> = knowledge.entities.iter().map(|e| e.embedding_idx).collect();
group
@@ -222,7 +223,7 @@ fn build_knowledge_group(
.iter()
.map(|r| r.relation.clone())
.collect();
write_string_dataset(&mut group, "relation_types", &rel_types, false);
write_string_dataset(&mut group, "relation_types", &rel_types);
let rel_weights: Vec<f32> = knowledge.relations.iter().map(|r| r.weight).collect();
group
@@ -234,7 +235,7 @@ fn build_knowledge_group(
// Aliases
if !knowledge.alias_strings.is_empty() {
write_string_dataset(&mut group, "alias_strings", &knowledge.alias_strings, false);
write_string_dataset(&mut group, "alias_strings", &knowledge.alias_strings);
group
.create_dataset("alias_entity_ids")
.with_i64_data(&knowledge.alias_entity_ids);
@@ -252,11 +253,15 @@ fn build_knowledge_group(
///
/// When `compress` is true, uses chunked storage with deflate(6) —
/// NullPad strings have high redundancy and compress very well.
/// Payload size (bytes) at or above which a fixed-length string dataset is
/// stored chunked + deflate-compressed. Below this, the chunk B-tree/heap
/// overhead outweighs the savings, so the data is left contiguous.
const STRING_COMPRESS_THRESHOLD: usize = 4096;
fn write_string_dataset(
group: &mut clawhdf5_format::type_builders::GroupBuilder,
name: &str,
strings: &[String],
compress: bool,
) {
if strings.is_empty() {
// Empty dataset: use 1-byte string type with no data
@@ -278,6 +283,7 @@ fn write_string_dataset(
bytes.resize(max_len, 0);
raw.extend_from_slice(&bytes);
}
let raw_len = raw.len();
let dtype = Datatype::String {
size: max_len as u32,
@@ -288,9 +294,12 @@ fn write_string_dataset(
.create_dataset(name)
.with_compound_data(dtype, raw, strings.len() as u64);
// Deflate compression for string datasets — NullPad has high redundancy
if compress && strings.len() > 1 {
// Chunk size: target ~64KB chunks for string data
// Fixed-length NullPad strings have high redundancy (padding + repeated
// content), so deflate pays off once the payload is large enough to absorb
// the chunking overhead. Fixed-length string datasets are chunkable like
// any other fixed-size datatype.
if strings.len() > 1 && raw_len >= STRING_COMPRESS_THRESHOLD {
// Target ~64KB chunks for string data.
let elem_size = max_len as u64;
let target_chunk = 64 * 1024;
let rows_per_chunk = (target_chunk / elem_size).max(1).min(strings.len() as u64);
+8 -4
View File
@@ -26,9 +26,7 @@ impl HDF5Memory {
) -> Vec<(usize, f32)> {
self.ensure_hnsw_fresh();
match self.hnsw.as_ref() {
Some(index)
if !index.is_empty() && index.dimension() == query_embedding.len() =>
{
Some(index) if !index.is_empty() && index.dimension() == query_embedding.len() => {
// Over-fetch so the merge sees a useful vector pool; cosine
// distance from the index converts back to similarity (1 - d).
let pool = (k * 8).max(64);
@@ -38,7 +36,13 @@ impl HDF5Memory {
.map(|(id, dist)| (id, 1.0 - dist))
.collect();
let kw_scores = bm25.search(query_text, self.cache.len());
hybrid::merge_vector_keyword(vec_scores, kw_scores, vector_weight, keyword_weight, k)
hybrid::merge_vector_keyword(
vec_scores,
kw_scores,
vector_weight,
keyword_weight,
k,
)
}
_ => hybrid::hybrid_search(
query_embedding,
+67 -35
View File
@@ -44,11 +44,20 @@ pub struct WalEntry {
pub tombstone_index: Option<usize>,
}
/// How many entries to accumulate before updating the header entry_count.
///
/// The header count is only needed for replay; `read_entries` already handles
/// stale counts by reading until EOF. Updating every N entries rather than
/// every entry eliminates 3 lseek() + 1 write() per entry — see arXiv:2507.13062.
const GROUP_COMMIT_SIZE: u32 = 8;
#[derive(Debug)]
pub struct WalFile {
path: PathBuf,
file: Option<File>,
entry_count: u32,
/// Entries written since the last header count update.
pending_header_sync: u32,
}
impl WalFile {
@@ -83,6 +92,7 @@ impl WalFile {
path: path.to_path_buf(),
file: Some(f),
entry_count,
pending_header_sync: 0,
})
} else {
// Create new WAL
@@ -95,62 +105,82 @@ impl WalFile {
path: path.to_path_buf(),
file: Some(f),
entry_count: 0,
pending_header_sync: 0,
})
}
}
/// Append a save entry to the WAL.
///
/// Serializes the entry into a single buffer before writing to minimize
/// syscall count (1 write() vs ~8 previously). The header entry_count is
/// updated every GROUP_COMMIT_SIZE entries rather than on every write,
/// eliminating 3 lseek() + 1 write() per entry (arXiv:2507.13062).
///
/// Crash safety: `read_entries` reads until EOF and handles stale header
/// counts, so deferred header updates do not compromise recovery.
pub fn append_save(&mut self, entry: &WalEntry) -> Result<(), MemoryError> {
let emb_len = entry.embedding.len();
let mut buf = Vec::with_capacity(
1 + 8 + // type + timestamp
4 + entry.chunk.len() +
4 + emb_len * 4 +
4 + entry.source_channel.len() +
4 + entry.session_id.len() +
4 + entry.tags.len(),
);
buf.push(WalEntryType::Save as u8);
buf.extend_from_slice(&entry.timestamp.to_le_bytes());
serialize_str(&mut buf, &entry.chunk);
buf.extend_from_slice(&(emb_len as u32).to_le_bytes());
for &val in &entry.embedding {
buf.extend_from_slice(&val.to_le_bytes());
}
serialize_str(&mut buf, &entry.source_channel);
serialize_str(&mut buf, &entry.session_id);
serialize_str(&mut buf, &entry.tags);
let f = self
.file
.as_mut()
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
// entry_type
f.write_all(&[WalEntryType::Save as u8])?;
// timestamp
f.write_all(&entry.timestamp.to_le_bytes())?;
// chunk
write_len_prefixed_str(f, &entry.chunk)?;
// embedding
let emb_len = entry.embedding.len() as u32;
f.write_all(&emb_len.to_le_bytes())?;
for &val in &entry.embedding {
f.write_all(&val.to_le_bytes())?;
}
// source_channel
write_len_prefixed_str(f, &entry.source_channel)?;
// session_id
write_len_prefixed_str(f, &entry.session_id)?;
// tags
write_len_prefixed_str(f, &entry.tags)?;
f.flush()?;
f.write_all(&buf)?;
self.entry_count += 1;
self.pending_header_sync += 1;
if self.pending_header_sync >= GROUP_COMMIT_SIZE {
self.write_entry_count()?;
}
Ok(())
}
/// Append a tombstone entry (deletion).
pub fn append_tombstone(&mut self, index: usize, timestamp: f64) -> Result<(), MemoryError> {
let mut buf = [0u8; 1 + 8 + 4]; // type + timestamp + index
buf[0] = WalEntryType::Tombstone as u8;
buf[1..9].copy_from_slice(&timestamp.to_le_bytes());
buf[9..13].copy_from_slice(&(index as u32).to_le_bytes());
let f = self
.file
.as_mut()
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
f.write_all(&[WalEntryType::Tombstone as u8])?;
f.write_all(&timestamp.to_le_bytes())?;
f.write_all(&(index as u32).to_le_bytes())?;
f.flush()?;
f.write_all(&buf)?;
self.entry_count += 1;
self.pending_header_sync += 1;
if self.pending_header_sync >= GROUP_COMMIT_SIZE {
self.write_entry_count()?;
}
Ok(())
}
/// Read all entries from the WAL (for replay on open).
///
/// Tolerates truncated WAL files: if the file is shorter than the header's
/// `entry_count` claims, the successfully-read entries are returned without
/// error. This handles crash-during-truncate and header-only WAL scenarios.
/// Reads until EOF — the header `entry_count` is used only for pre-allocation
/// (and may be stale if written with deferred group-commit updates). This
/// tolerates both truncated files (crash mid-write) and stale header counts
/// (crash before the next group-commit header sync).
pub fn read_entries(path: &Path) -> Result<Vec<WalEntry>, MemoryError> {
if !path.exists() {
return Ok(Vec::new());
@@ -168,11 +198,12 @@ impl WalFile {
header[4]
)));
}
let entry_count = u32::from_le_bytes([header[5], header[6], header[7], header[8]]);
let mut entries = Vec::with_capacity(entry_count as usize);
// entry_count is a pre-allocation hint only — we read until EOF.
let entry_count_hint = u32::from_le_bytes([header[5], header[6], header[7], header[8]]);
let mut entries = Vec::with_capacity(entry_count_hint as usize);
for _ in 0..entry_count {
// Read entry type — EOF here means truncated WAL, not an error
loop {
// Read entry type — EOF here is normal end-of-log, not an error
let mut type_buf = [0u8; 1];
if f.read_exact(&mut type_buf).is_err() {
break;
@@ -252,6 +283,7 @@ impl WalFile {
f.flush()?;
self.file = Some(f);
self.entry_count = 0;
self.pending_header_sync = 0;
Ok(())
}
@@ -274,8 +306,8 @@ impl WalFile {
let pos = f.stream_position()?;
f.seek(SeekFrom::Start(5))?;
f.write_all(&self.entry_count.to_le_bytes())?;
f.flush()?;
f.seek(SeekFrom::Start(pos))?;
self.pending_header_sync = 0;
Ok(())
}
}
@@ -306,11 +338,11 @@ pub fn replay_into_cache(entries: &[WalEntry], cache: &mut crate::cache::MemoryC
// --- Binary helpers ---
fn write_len_prefixed_str(f: &mut File, s: &str) -> Result<(), MemoryError> {
/// Serialize a length-prefixed string into an in-memory buffer (zero syscalls).
fn serialize_str(buf: &mut Vec<u8>, s: &str) {
let bytes = s.as_bytes();
f.write_all(&(bytes.len() as u32).to_le_bytes())?;
f.write_all(bytes)?;
Ok(())
buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
buf.extend_from_slice(bytes);
}
fn read_len_prefixed_str(f: &mut File) -> Result<String, MemoryError> {
@@ -80,8 +80,7 @@ fn hnsw_matches_bruteforce_oracle() {
oracle.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
let oracle_ids: std::collections::HashSet<usize> =
oracle.iter().take(k).map(|(i, _)| *i).collect();
let hnsw_ids: std::collections::HashSet<usize> =
results.iter().map(|r| r.index).collect();
let hnsw_ids: std::collections::HashSet<usize> = results.iter().map(|r| r.index).collect();
let overlap = oracle_ids.intersection(&hnsw_ids).count();
assert!(
@@ -127,17 +126,19 @@ fn incremental_inserts_after_search_are_found() {
// First batch, then a search to force the index to build.
for i in 0..40 {
let v = make_vector(&mut seed, dim);
mem.save(entry(&format!("a{i}"), v, &format!("a{i}"))).unwrap();
mem.save(entry(&format!("a{i}"), v, &format!("a{i}")))
.unwrap();
}
let _ = mem.hybrid_search(&make_vector(&mut seed, dim), "", 1.0, 0.0, 5);
// Now insert a distinctive vector incrementally and confirm we can find it.
let needle = vec![10.0f32; dim];
let idx = mem
.save(entry("needle", needle.clone(), "needle"))
.unwrap();
let idx = mem.save(entry("needle", needle.clone(), "needle")).unwrap();
let hits = mem.hybrid_search(&needle, "", 1.0, 0.0, 1);
assert_eq!(hits[0].index, idx, "incrementally inserted vector must be found");
assert_eq!(
hits[0].index, idx,
"incrementally inserted vector must be found"
);
}
#[test]
@@ -158,6 +159,9 @@ fn save_batch_then_search_is_consistent() {
// Exact-match queries should resolve to themselves after a batch insert.
for probe in [0usize, 17, 49] {
let hits = mem.hybrid_search(&vectors[probe], "", 1.0, 0.0, 1);
assert_eq!(hits[0].index, probe, "batch-inserted vector {probe} not found");
assert_eq!(
hits[0].index, probe,
"batch-inserted vector {probe} not found"
);
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
# rustyhdf5-ann
# clawhdf5-ann
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-ann.svg)](https://crates.io/crates/rustyhdf5-ann)
[![docs.rs](https://docs.rs/rustyhdf5-ann/badge.svg)](https://docs.rs/rustyhdf5-ann)
[![crates.io](https://img.shields.io/crates/v/clawhdf5-ann.svg)](https://crates.io/crates/clawhdf5-ann)
[![docs.rs](https://docs.rs/clawhdf5-ann/badge.svg)](https://docs.rs/clawhdf5-ann)
HNSW approximate nearest neighbor index stored as HDF5.
@@ -14,7 +14,7 @@ HNSW approximate nearest neighbor index stored as HDF5.
## Usage
```rust
use rustyhdf5_ann::HnswIndex;
use clawhdf5_ann::HnswIndex;
let index = HnswIndex::from_hdf5("vectors.h5").unwrap();
let neighbors = index.search(&query, 10);
+12 -4
View File
@@ -16,7 +16,6 @@ use clawhdf5_format::object_header::ObjectHeader;
use clawhdf5_format::signature::find_signature;
use clawhdf5_format::superblock::Superblock;
use clawhdf5_io::FileWriter as IoFileWriter;
use clawhdf5_io::HDF5ReadWrite;
/// Distance metric for the HNSW index.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -380,7 +379,13 @@ impl HnswIndex {
// Phase 1: greedy descent from the top down to node_level + 1.
for layer in (node_level + 1..=ep_level).rev() {
ep = greedy_closest(&self.vectors, &self.graph[layer], &self.vectors[id], ep, self.metric);
ep = greedy_closest(
&self.vectors,
&self.graph[layer],
&self.vectors[id],
ep,
self.metric,
);
}
// Phase 2: search and connect from min(node_level, ep_level) down to 0.
@@ -516,7 +521,7 @@ impl HnswIndex {
pub fn save_to_hdf5(&self, writer: &mut IoFileWriter) -> Result<(), FormatError> {
let bytes = self.to_hdf5_bytes()?;
writer
.write_all_bytes(&bytes)
.write_bytes_owned(bytes)
.map_err(|e| FormatError::SerializationError(e.to_string()))?;
Ok(())
}
@@ -1013,7 +1018,10 @@ fn get_attr_i64(attrs: &[(String, AttrValue)], name: &str) -> Result<i64, Format
/// Like [`get_attr_i64`] but returns `None` when the attribute is absent or not
/// an integer, instead of erroring. Used for optional/back-compat attributes.
fn get_attr_i64_opt(attrs: &[(String, AttrValue)], name: &str) -> Option<i64> {
attrs.iter().find(|(n, _)| n == name).and_then(|(_, v)| match v {
attrs
.iter()
.find(|(n, _)| n == name)
.and_then(|(_, v)| match v {
AttrValue::I64(val) => Some(*val),
AttrValue::U64(val) => Some(*val as i64),
_ => None,
+36
View File
@@ -25,8 +25,44 @@ path = "src/bin/consolidation_efficiency.rs"
name = "ephemeral_perf"
path = "src/bin/ephemeral_perf.rs"
[[bin]]
name = "mpi_io_bench"
path = "src/bin/mpi_io_bench.rs"
required-features = ["mpi-io"]
# ---------------------------------------------------------------------------
# h5bench-equivalent Criterion benchmarks
# ---------------------------------------------------------------------------
[[bench]]
name = "h5bench_write"
harness = false
[[bench]]
name = "h5bench_read"
harness = false
[[bench]]
name = "h5bench_meta"
harness = false
[dependencies]
clawhdf5-agent = { path = "../clawhdf5-agent" }
clawhdf5-io = { path = "../clawhdf5-io" }
mpi = { version = "0.8", optional = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tempfile = "3"
# Optional: libhdf5 C wrapper for side-by-side comparison (requires system libhdf5).
# Enable with: cargo bench -p clawhdf5-bench --features libhdf5-compare
# Uses hdf5-metno (fork of hdf5 crate) which supports HDF5 1.14.x.
hdf5 = { version = "0.12", optional = true, package = "hdf5-metno" }
[dev-dependencies]
clawhdf5 = { path = "../clawhdf5", features = ["zstd", "pcodec"] }
criterion = { version = "0.5", features = ["html_reports"] }
[features]
# When enabled, benchmarks add matching libhdf5 variants for side-by-side comparison.
libhdf5-compare = ["hdf5"]
mpi-io = ["clawhdf5-io/mpi-io", "mpi"]
@@ -0,0 +1,327 @@
//! h5bench-equivalent metadata workloads for clawhdf5.
//!
//! Measures attribute creation/read throughput and group traversal latency —
//! the workloads that h5bench's `metadata` mode targets against libhdf5.
use clawhdf5::{AttrValue, File, FileBuilder};
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Workload: metadata_attrs_write
// Create K attributes on a single dataset.
// Exercises attribute message allocation and compact → dense header transition.
// ---------------------------------------------------------------------------
fn bench_metadata_attrs_write(c: &mut Criterion) {
let mut group = c.benchmark_group("metadata_attrs_write");
for &k in &[4usize, 16, 64, 128] {
group.throughput(Throughput::Elements(k as u64));
group.bench_with_input(BenchmarkId::new("clawhdf5", k), &k, |b, &k| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("attrs_write.h5");
b.iter(|| {
let mut fb = FileBuilder::new();
let ds = fb
.create_dataset("data")
.with_f64_data(&[1.0, 2.0, 3.0])
.with_shape(&[3]);
for i in 0..k {
ds.set_attr(&format!("attr_{i:04}"), AttrValue::I64(i as i64));
}
fb.write(&path).unwrap();
});
});
#[cfg(feature = "libhdf5-compare")]
group.bench_with_input(BenchmarkId::new("libhdf5", k), &k, |b, &k| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("attrs_libhdf5.h5");
b.iter(|| {
let file = hdf5::File::create(&path).unwrap();
let ds = file.new_dataset::<f64>().shape([3]).create("data").unwrap();
ds.write(&[1.0f64, 2.0, 3.0]).unwrap();
for i in 0..k {
ds.new_attr::<i64>()
.create(format!("attr_{i:04}").as_str())
.unwrap()
.write_scalar(&(i as i64))
.unwrap();
}
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: metadata_attrs_read
// Open a pre-built file and read all K attributes back.
// ---------------------------------------------------------------------------
fn bench_metadata_attrs_read(c: &mut Criterion) {
let mut group = c.benchmark_group("metadata_attrs_read");
for &k in &[4usize, 16, 64, 128] {
// Build the reference file in memory.
let bytes = {
let mut fb = FileBuilder::new();
let ds = fb
.create_dataset("data")
.with_f64_data(&[1.0, 2.0, 3.0])
.with_shape(&[3]);
for i in 0..k {
ds.set_attr(&format!("attr_{i:04}"), AttrValue::I64(i as i64));
}
fb.finish().unwrap()
};
group.throughput(Throughput::Elements(k as u64));
group.bench_with_input(BenchmarkId::new("clawhdf5", k), &bytes, |b, raw| {
b.iter(|| {
let file = File::from_bytes(raw.clone()).unwrap();
let ds = file.dataset("data").unwrap();
ds.attrs().unwrap()
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: metadata_groups_create
// Create K top-level groups (no datasets inside).
// Measures link-storage allocation: compact → dense B-tree transition.
// ---------------------------------------------------------------------------
fn bench_metadata_groups_create(c: &mut Criterion) {
let mut group = c.benchmark_group("metadata_groups_create");
for &k in &[4usize, 16, 32, 64] {
group.throughput(Throughput::Elements(k as u64));
group.bench_with_input(BenchmarkId::new("clawhdf5", k), &k, |b, &k| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("groups_create.h5");
b.iter(|| {
let mut fb = FileBuilder::new();
for i in 0..k {
let mut g = fb.create_group(&format!("group_{i:04}"));
// Minimal dataset inside each group to make it non-trivial.
g.create_dataset("x").with_f64_data(&[0.0]);
let finished = g.finish();
fb.add_group(finished);
}
fb.write(&path).unwrap();
});
});
#[cfg(feature = "libhdf5-compare")]
group.bench_with_input(BenchmarkId::new("libhdf5", k), &k, |b, &k| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("groups_libhdf5.h5");
b.iter(|| {
let file = hdf5::File::create(&path).unwrap();
for i in 0..k {
let g = file.create_group(&format!("group_{i:04}")).unwrap();
g.new_dataset::<f64>()
.shape([1])
.create("x")
.unwrap()
.write(&[0.0f64])
.unwrap();
}
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: metadata_groups_traverse
// Open a pre-built file with K groups and traverse (list) the root group.
// ---------------------------------------------------------------------------
fn bench_metadata_groups_traverse(c: &mut Criterion) {
let mut group = c.benchmark_group("metadata_groups_traverse");
for &k in &[4usize, 16, 32, 64] {
// Pre-build.
let bytes = {
let mut fb = FileBuilder::new();
for i in 0..k {
let mut g = fb.create_group(&format!("group_{i:04}"));
g.create_dataset("x").with_f64_data(&[0.0]);
let finished = g.finish();
fb.add_group(finished);
}
fb.finish().unwrap()
};
group.throughput(Throughput::Elements(k as u64));
group.bench_with_input(BenchmarkId::new("clawhdf5", k), &bytes, |b, raw| {
b.iter(|| {
let file = File::from_bytes(raw.clone()).unwrap();
let root = file.root();
root.groups().unwrap()
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: metadata_roundtrip_string_attrs
// Write and read back K variable-length string attributes.
// String attrs require a dedicated VL heap entry — distinct from numeric ones.
// ---------------------------------------------------------------------------
fn bench_metadata_string_attrs(c: &mut Criterion) {
let mut group = c.benchmark_group("metadata_string_attrs");
for &k in &[4usize, 16, 32] {
group.throughput(Throughput::Elements(k as u64));
group.bench_with_input(BenchmarkId::new("clawhdf5", k), &k, |b, &k| {
b.iter(|| {
let mut fb = FileBuilder::new();
let ds = fb
.create_dataset("data")
.with_f64_data(&[1.0])
.with_shape(&[1]);
for i in 0..k {
ds.set_attr(
&format!("label_{i:04}"),
AttrValue::String(format!("value-{i}-some-longer-string-payload")),
);
}
let bytes = fb.finish().unwrap();
// Immediately read back to exercise both directions.
let file = File::from_bytes(bytes).unwrap();
let ds_r = file.dataset("data").unwrap();
ds_r.attrs().unwrap()
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: metadata_open_from_disk
// Open a small pre-built file from disk and resolve one attribute. Both
// sides pay the OS open()/read() cost plus header-parse cost, so this is a
// fair, I/O-inclusive "open a file and touch its metadata" comparison — the
// honest version of the "metadata parse" claim this benchmark replaces.
// ---------------------------------------------------------------------------
fn bench_metadata_open_from_disk(c: &mut Criterion) {
let mut group = c.benchmark_group("metadata_open_from_disk");
group.throughput(Throughput::Elements(1));
let tmp = TempDir::new().unwrap();
let clawhdf5_path = tmp.path().join("open_clawhdf5.h5");
{
let mut fb = FileBuilder::new();
let ds = fb
.create_dataset("data")
.with_f64_data(&[1.0, 2.0, 3.0])
.with_shape(&[3]);
ds.set_attr("label", AttrValue::I64(42));
fb.write(&clawhdf5_path).unwrap();
}
group.bench_function("clawhdf5", |b| {
b.iter(|| {
let raw = std::fs::read(&clawhdf5_path).unwrap();
let file = File::from_bytes(raw).unwrap();
let ds = file.dataset("data").unwrap();
ds.attrs().unwrap()
});
});
#[cfg(feature = "libhdf5-compare")]
{
let libhdf5_path = tmp.path().join("open_libhdf5.h5");
{
let file = hdf5::File::create(&libhdf5_path).unwrap();
let ds = file.new_dataset::<f64>().shape([3]).create("data").unwrap();
ds.write(&[1.0f64, 2.0, 3.0]).unwrap();
ds.new_attr::<i64>()
.create("label")
.unwrap()
.write_scalar(&42i64)
.unwrap();
}
group.bench_function("libhdf5", |b| {
b.iter(|| {
let file = hdf5::File::open(&libhdf5_path).unwrap();
let ds = file.dataset("data").unwrap();
let _: i64 = ds.attr("label").unwrap().read_scalar().unwrap();
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: metadata_parse_in_memory (clawhdf5-only)
// Times File::from_bytes() alone on bytes already resident in memory — i.e.
// the header-parse cost with disk I/O excluded. There is no fair libhdf5
// equivalent (its API has no "parse from an in-memory buffer" path that
// skips the OS open), so this is reported standalone, not as a speedup
// multiple against libhdf5. See metadata_open_from_disk above for the
// I/O-inclusive, directly comparable number.
// ---------------------------------------------------------------------------
fn bench_metadata_parse_in_memory(c: &mut Criterion) {
let mut group = c.benchmark_group("metadata_parse_in_memory");
group.throughput(Throughput::Elements(1));
let bytes = {
let mut fb = FileBuilder::new();
let ds = fb
.create_dataset("data")
.with_f64_data(&[1.0, 2.0, 3.0])
.with_shape(&[3]);
ds.set_attr("label", AttrValue::I64(42));
fb.finish().unwrap()
};
group.bench_with_input(
BenchmarkId::new("clawhdf5", "in_memory"),
&bytes,
|b, raw| {
b.iter(|| {
let file = File::from_bytes(raw.clone()).unwrap();
let ds = file.dataset("data").unwrap();
ds.attrs().unwrap()
});
},
);
group.finish();
}
criterion_group!(
meta_benches,
bench_metadata_attrs_write,
bench_metadata_attrs_read,
bench_metadata_groups_create,
bench_metadata_groups_traverse,
bench_metadata_string_attrs,
bench_metadata_open_from_disk,
bench_metadata_parse_in_memory,
);
criterion_main!(meta_benches);
@@ -0,0 +1,290 @@
//! h5bench-equivalent read workloads for clawhdf5.
//!
//! Covers sequential read, hyperslab / strided access, and round-trip
//! validation patterns mirroring the h5bench HPC read suite.
use clawhdf5::{File, FileBuilder};
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Helpers: build reference files once per bench group.
// ---------------------------------------------------------------------------
/// Write a contiguous 1-D f32 dataset and return raw bytes.
fn make_1d_contiguous_bytes(n: usize) -> Vec<u8> {
let data: Vec<f32> = (0..n).map(|i| i as f32 * 0.001).collect();
let mut fb = FileBuilder::new();
fb.create_dataset("data")
.with_f32_data(&data)
.with_shape(&[n as u64]);
fb.finish().unwrap()
}
/// Write a contiguous 1-D f64 dataset and return raw bytes.
fn make_1d_f64_bytes(n: usize) -> Vec<u8> {
let data: Vec<f64> = (0..n).map(|i| i as f64 * 0.001).collect();
let mut fb = FileBuilder::new();
fb.create_dataset("data")
.with_f64_data(&data)
.with_shape(&[n as u64]);
fb.finish().unwrap()
}
/// Write a 2-D chunked f32 matrix to a temp file, return path string.
///
/// The temp dir is returned to keep the directory alive.
fn make_2d_chunked_file(tmp: &TempDir, rows: usize, cols: usize) -> std::path::PathBuf {
let data: Vec<f32> = (0..rows * cols).map(|i| i as f32).collect();
let path = tmp.path().join("chunked.h5");
let mut fb = FileBuilder::new();
fb.create_dataset("matrix")
.with_f32_data(&data)
.with_shape(&[rows as u64, cols as u64])
.with_chunks(&[32, cols as u64]);
fb.write(&path).unwrap();
path
}
// ---------------------------------------------------------------------------
// Workload: read_sequential
// Read back the full 1-D contiguous f32 dataset.
// Measures parser + byte-copy throughput.
// ---------------------------------------------------------------------------
fn bench_read_sequential(c: &mut Criterion) {
let mut group = c.benchmark_group("read_sequential");
for &n in &[1_000usize, 10_000, 100_000] {
let bytes = make_1d_contiguous_bytes(n);
group.throughput(Throughput::Bytes((n * size_of::<f32>()) as u64));
group.bench_with_input(BenchmarkId::new("clawhdf5", n), &bytes, |b, raw| {
b.iter(|| {
let file = File::from_bytes(raw.clone()).unwrap();
let ds = file.dataset("data").unwrap();
ds.read_f32().unwrap()
});
});
#[cfg(feature = "libhdf5-compare")]
group.bench_with_input(BenchmarkId::new("libhdf5", n), &n, |b, &nn| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("seq_libhdf5.h5");
let data: Vec<f32> = (0..nn).map(|i| i as f32 * 0.001).collect();
{
let lf = hdf5::File::create(&path).unwrap();
let lds = lf.new_dataset::<f32>().shape([nn]).create("data").unwrap();
lds.write(data.as_slice()).unwrap();
}
b.iter(|| {
let file = hdf5::File::open(&path).unwrap();
let ds = file.dataset("data").unwrap();
ds.read_raw::<f32>().unwrap()
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: read_f64_sequential
// Same as above but for f64 — the dominant agent-embedding dtype.
// ---------------------------------------------------------------------------
fn bench_read_f64_sequential(c: &mut Criterion) {
let mut group = c.benchmark_group("read_f64_sequential");
for &n in &[1_000usize, 10_000, 100_000] {
let bytes = make_1d_f64_bytes(n);
group.throughput(Throughput::Bytes((n * size_of::<f64>()) as u64));
group.bench_with_input(BenchmarkId::new("clawhdf5", n), &bytes, |b, raw| {
b.iter(|| {
let file = File::from_bytes(raw.clone()).unwrap();
let ds = file.dataset("data").unwrap();
ds.read_f64().unwrap()
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: read_chunked_2d
// Read back a 2-D chunked f32 matrix from disk (exercises chunk reassembly).
// ---------------------------------------------------------------------------
fn bench_read_chunked_2d(c: &mut Criterion) {
let mut group = c.benchmark_group("read_chunked_2d");
for &(rows, cols) in &[(64usize, 64usize), (256, 256), (512, 512)] {
let tmp = TempDir::new().unwrap();
let path = make_2d_chunked_file(&tmp, rows, cols);
let n = rows * cols;
group.throughput(Throughput::Bytes((n * size_of::<f32>()) as u64));
let label = format!("{rows}x{cols}");
group.bench_with_input(BenchmarkId::new("clawhdf5", &label), &path, |b, p| {
b.iter(|| {
let raw = std::fs::read(p).unwrap();
let file = File::from_bytes(raw).unwrap();
let ds = file.dataset("matrix").unwrap();
ds.read_f32().unwrap()
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: read_from_disk
// Open file from disk (FileBuilder::write → File::open) measuring OS I/O +
// HDF5 parse together. Simulates cold-cache reads.
// ---------------------------------------------------------------------------
fn bench_read_from_disk(c: &mut Criterion) {
let mut group = c.benchmark_group("read_from_disk");
for &n in &[10_000usize, 100_000] {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("disk.h5");
let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
let mut fb = FileBuilder::new();
fb.create_dataset("data")
.with_f64_data(&data)
.with_shape(&[n as u64]);
fb.write(&path).unwrap();
group.throughput(Throughput::Bytes((n * size_of::<f64>()) as u64));
group.bench_with_input(BenchmarkId::new("clawhdf5", n), &path, |b, p| {
b.iter(|| {
let raw = std::fs::read(p).unwrap();
let file = File::from_bytes(raw).unwrap();
file.dataset("data").unwrap().read_f64().unwrap()
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: read_hyperslab
// Reads a subset of a 1-D dataset (simulating strided / hyperslab access).
// Uses every-other element to stress the selection logic.
// ---------------------------------------------------------------------------
fn bench_read_hyperslab(c: &mut Criterion) {
let mut group = c.benchmark_group("read_hyperslab");
for &n in &[10_000usize, 100_000] {
let bytes = make_1d_f64_bytes(n);
// Read first 10% of the dataset as a proxy for hyperslab access.
let slice_len = n / 10;
group.throughput(Throughput::Bytes((slice_len * size_of::<f64>()) as u64));
group.bench_with_input(BenchmarkId::new("clawhdf5", n), &bytes, |b, raw| {
b.iter(|| {
let file = File::from_bytes(raw.clone()).unwrap();
let ds = file.dataset("data").unwrap();
// Full read then take a slice — clawhdf5 does not yet expose
// selection API at the high-level facade, so we read all and
// trim (this is what the format-level selection exercises).
let all = ds.read_f64().unwrap();
all[..slice_len].to_vec()
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: read_zerocopy_mmap
// Opens a file from disk via `MmapFile` and reads an f64 dataset through
// `read_f64_zerocopy()`, which returns a slice directly into the mapped
// pages (no allocation, no copy). Compared against the regular
// std::fs::read + File::from_bytes path (which does copy), and — with
// libhdf5-compare — against libhdf5's own disk-backed open+read.
// ---------------------------------------------------------------------------
fn bench_read_zerocopy_mmap(c: &mut Criterion) {
use clawhdf5::MmapFile;
let mut group = c.benchmark_group("read_zerocopy_mmap");
for &n in &[1_000usize, 10_000, 100_000] {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("mmap.h5");
let data: Vec<f64> = (0..n).map(|i| i as f64 * 0.001).collect();
let mut fb = FileBuilder::new();
fb.create_dataset("data")
.with_f64_data(&data)
.with_shape(&[n as u64]);
fb.write(&path).unwrap();
group.throughput(Throughput::Bytes((n * size_of::<f64>()) as u64));
group.bench_with_input(
BenchmarkId::new("clawhdf5_mmap_zerocopy", n),
&path,
|b, p| {
b.iter(|| {
let file = MmapFile::open(p).unwrap();
let ds = file.dataset("data").unwrap();
let slice = ds.read_f64_zerocopy().unwrap();
// Sum every element to force the mapped pages to actually be
// faulted in — returning just `.len()` would measure nothing
// but the mmap() syscall, repeating the exact "too-fast-to-
// be-real" mistake this benchmark exists to fix.
let sum: f64 = slice.map(|s| s.iter().sum()).unwrap_or(0.0);
criterion::black_box(sum)
});
},
);
group.bench_with_input(BenchmarkId::new("clawhdf5_copy", n), &path, |b, p| {
b.iter(|| {
let raw = std::fs::read(p).unwrap();
let file = File::from_bytes(raw).unwrap();
file.dataset("data").unwrap().read_f64().unwrap()
});
});
#[cfg(feature = "libhdf5-compare")]
group.bench_with_input(BenchmarkId::new("libhdf5", n), &n, |b, &nn| {
let tmp2 = TempDir::new().unwrap();
let path2 = tmp2.path().join("mmap_libhdf5.h5");
let data2: Vec<f64> = (0..nn).map(|i| i as f64 * 0.001).collect();
{
let lf = hdf5::File::create(&path2).unwrap();
let lds = lf.new_dataset::<f64>().shape([nn]).create("data").unwrap();
lds.write(data2.as_slice()).unwrap();
}
b.iter(|| {
let file = hdf5::File::open(&path2).unwrap();
let ds = file.dataset("data").unwrap();
ds.read_raw::<f64>().unwrap()
});
});
}
group.finish();
}
criterion_group!(
read_benches,
bench_read_sequential,
bench_read_f64_sequential,
bench_read_chunked_2d,
bench_read_from_disk,
bench_read_hyperslab,
bench_read_zerocopy_mmap,
);
criterion_main!(read_benches);
@@ -0,0 +1,330 @@
//! h5bench-equivalent write workloads for clawhdf5.
//!
//! Mirrors the sequential and chunked write patterns from the h5bench HPC
//! benchmark suite but implemented in pure Rust using Criterion for statistical
//! rigor. The `libhdf5-compare` feature adds matching benchmarks via the `hdf5`
//! crate (requires a system libhdf5 install).
use clawhdf5::{AttrValue, FileBuilder};
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Workload: write_1d_contiguous
// Write N × f32 as a single contiguous 1-D dataset.
// Measures raw serialization + HDF5 superblock / object-header overhead.
// ---------------------------------------------------------------------------
fn bench_write_1d_contiguous(c: &mut Criterion) {
let mut group = c.benchmark_group("write_1d_contiguous");
for &n in &[1_000usize, 10_000, 100_000] {
let data: Vec<f32> = (0..n).map(|i| i as f32 * 0.001).collect();
group.throughput(Throughput::Bytes((n * size_of::<f32>()) as u64));
group.bench_with_input(BenchmarkId::new("clawhdf5", n), &data, |b, d| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("write_1d_contiguous.h5");
b.iter(|| {
let mut fb = FileBuilder::new();
fb.create_dataset("data")
.with_f32_data(d)
.with_shape(&[n as u64]);
fb.write(&path).unwrap();
});
});
#[cfg(feature = "libhdf5-compare")]
group.bench_with_input(BenchmarkId::new("libhdf5", n), &data, |b, d| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("write_1d_libhdf5.h5");
b.iter(|| {
let file = hdf5::File::create(&path).unwrap();
let ds = file
.new_dataset::<f32>()
.shape([d.len()])
.create("data")
.unwrap();
ds.write(d.as_slice()).unwrap();
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: write_2d_chunked
// Write an M × N f32 matrix as a chunked 2-D dataset with deflate (level 6).
// Measures chunked layout creation + compression pipeline throughput.
// ---------------------------------------------------------------------------
fn bench_write_2d_chunked(c: &mut Criterion) {
let mut group = c.benchmark_group("write_2d_chunked");
// (rows, cols, chunk_rows, chunk_cols)
let configs: &[(usize, usize, u64, u64)] =
&[(32, 32, 8, 32), (128, 128, 32, 128), (512, 512, 64, 512)];
for &(rows, cols, cr, cc) in configs {
let n = rows * cols;
let data: Vec<f32> = (0..n).map(|i| i as f32).collect();
let label = format!("{rows}x{cols}");
group.throughput(Throughput::Bytes((n * size_of::<f32>()) as u64));
group.bench_with_input(BenchmarkId::new("clawhdf5", &label), &data, |b, d| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("write_2d_chunked.h5");
b.iter(|| {
let mut fb = FileBuilder::new();
fb.create_dataset("matrix")
.with_f32_data(d)
.with_shape(&[rows as u64, cols as u64])
.with_chunks(&[cr, cc])
.with_deflate(6);
fb.write(&path).unwrap();
});
});
#[cfg(feature = "libhdf5-compare")]
group.bench_with_input(BenchmarkId::new("libhdf5", &label), &data, |b, d| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("write_2d_libhdf5.h5");
b.iter(|| {
let file = hdf5::File::create(&path).unwrap();
let ds = file
.new_dataset::<f32>()
.shape([rows, cols])
.chunk([cr as usize, cc as usize])
.deflate(6)
.create("matrix")
.unwrap();
ds.write_raw(d.as_slice()).unwrap();
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: write_2d_chunked_zstd
// Same matrix sizes as write_2d_chunked but uses Zstd level 3.
// Zstd level 3 typically encodes 500+ MiB/s vs deflate's ~300 MiB/s at the
// same or better compression ratio (arXiv 2604.06221, ROOT I/O 2019).
// ---------------------------------------------------------------------------
fn bench_write_2d_chunked_zstd(c: &mut Criterion) {
let mut group = c.benchmark_group("write_2d_chunked_zstd");
let configs: &[(usize, usize, u64, u64)] =
&[(32, 32, 8, 32), (128, 128, 32, 128), (512, 512, 64, 512)];
for &(rows, cols, cr, cc) in configs {
let n = rows * cols;
let data: Vec<f32> = (0..n).map(|i| i as f32).collect();
let label = format!("{rows}x{cols}");
group.throughput(Throughput::Bytes((n * size_of::<f32>()) as u64));
group.bench_with_input(
BenchmarkId::new("clawhdf5/zstd-3", &label),
&data,
|b, d| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("write_2d_chunked_zstd.h5");
b.iter(|| {
let mut fb = FileBuilder::new();
fb.create_dataset("matrix")
.with_f32_data(d)
.with_shape(&[rows as u64, cols as u64])
.with_chunks(&[cr, cc])
.with_zstd(3);
fb.write(&path).unwrap();
});
},
);
group.bench_with_input(
BenchmarkId::new("clawhdf5/deflate-6", &label),
&data,
|b, d| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("write_2d_chunked_deflate.h5");
b.iter(|| {
let mut fb = FileBuilder::new();
fb.create_dataset("matrix")
.with_f32_data(d)
.with_shape(&[rows as u64, cols as u64])
.with_chunks(&[cr, cc])
.with_deflate(6);
fb.write(&path).unwrap();
});
},
);
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: write_2d_chunked_pcodec
// Same matrix sizes as write_2d_chunked but uses Pcodec (arXiv:2502.06112).
// Pcodec achieves 30–94% better compression ratio than Zstd for f32/f64 at
// 1–5 GiB/s decompression speed via a quantile-based numerical codec.
// ---------------------------------------------------------------------------
fn bench_write_2d_chunked_pcodec(c: &mut Criterion) {
let mut group = c.benchmark_group("write_2d_chunked_pcodec");
let configs: &[(usize, usize, u64, u64)] =
&[(32, 32, 8, 32), (128, 128, 32, 128), (512, 512, 64, 512)];
for &(rows, cols, cr, cc) in configs {
let n = rows * cols;
let data: Vec<f32> = (0..n).map(|i| i as f32).collect();
let label = format!("{rows}x{cols}");
group.throughput(Throughput::Bytes((n * size_of::<f32>()) as u64));
group.bench_with_input(
BenchmarkId::new("clawhdf5/pcodec", &label),
&data,
|b, d| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("write_2d_chunked_pcodec.h5");
b.iter(|| {
let mut fb = FileBuilder::new();
fb.create_dataset("matrix")
.with_f32_data(d)
.with_shape(&[rows as u64, cols as u64])
.with_chunks(&[cr, cc])
.with_pcodec();
fb.write(&path).unwrap();
});
},
);
group.bench_with_input(
BenchmarkId::new("clawhdf5/zstd-3", &label),
&data,
|b, d| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("write_2d_chunked_zstd.h5");
b.iter(|| {
let mut fb = FileBuilder::new();
fb.create_dataset("matrix")
.with_f32_data(d)
.with_shape(&[rows as u64, cols as u64])
.with_chunks(&[cr, cc])
.with_zstd(3);
fb.write(&path).unwrap();
});
},
);
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: write_f64_batch
// Write batches of f64 elements — simulates the clawhdf5-agent embedding
// write path (one f64 vector per memory entry).
// ---------------------------------------------------------------------------
fn bench_write_f64_batch(c: &mut Criterion) {
let mut group = c.benchmark_group("write_f64_batch");
for &n in &[128usize, 512, 1_024] {
let data: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
group.throughput(Throughput::Bytes((n * size_of::<f64>()) as u64));
group.bench_with_input(BenchmarkId::new("clawhdf5", n), &data, |b, d| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("write_f64_batch.h5");
b.iter(|| {
let mut fb = FileBuilder::new();
fb.create_dataset("embedding")
.with_f64_data(d)
.with_shape(&[n as u64]);
fb.write(&path).unwrap();
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: write_multi_dataset
// Write K independent f32 datasets into one file — stresses the object-header
// + link-storage path (compact → dense transition at >8 datasets).
// ---------------------------------------------------------------------------
fn bench_write_multi_dataset(c: &mut Criterion) {
let mut group = c.benchmark_group("write_multi_dataset");
for &k in &[4usize, 16, 64] {
let rows = 100usize;
let data: Vec<f32> = (0..rows).map(|i| i as f32).collect();
group.throughput(Throughput::Elements(k as u64));
group.bench_with_input(BenchmarkId::new("clawhdf5", k), &data, |b, d| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("write_multi.h5");
b.iter(|| {
let mut fb = FileBuilder::new();
for i in 0..k {
fb.create_dataset(&format!("ds_{i:04}"))
.with_f32_data(d)
.with_shape(&[rows as u64]);
}
fb.write(&path).unwrap();
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Workload: write_with_attrs
// Write a dataset with K attributes — exercises attribute message allocation.
// ---------------------------------------------------------------------------
fn bench_write_with_attrs(c: &mut Criterion) {
let mut group = c.benchmark_group("write_with_attrs");
for &k in &[4usize, 16, 64] {
group.throughput(Throughput::Elements(k as u64));
group.bench_with_input(BenchmarkId::new("clawhdf5", k), &k, |b, &k| {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("write_attrs.h5");
b.iter(|| {
let mut fb = FileBuilder::new();
let ds = fb
.create_dataset("data")
.with_f64_data(&[1.0, 2.0, 3.0])
.with_shape(&[3]);
for i in 0..k {
ds.set_attr(&format!("attr_{i}"), AttrValue::I64(i as i64));
}
fb.write(&path).unwrap();
});
});
}
group.finish();
}
criterion_group!(
write_benches,
bench_write_1d_contiguous,
bench_write_2d_chunked,
bench_write_2d_chunked_zstd,
bench_write_2d_chunked_pcodec,
bench_write_f64_batch,
bench_write_multi_dataset,
bench_write_with_attrs,
);
criterion_main!(write_benches);
@@ -0,0 +1,67 @@
//! h5bench-equivalent MPI-IO performance benchmark.
//!
//! Usage: mpirun -np N cargo run -p clawhdf5-bench --features mpi-io --bin mpi_io_bench -- --size <N>
//!
//! Measures collective write and read throughput in MB/s for f64 arrays.
#[cfg(feature = "mpi-io")]
fn main() {
use clawhdf5_io::mpi_vol::MpiVol;
use clawhdf5_io::vol::VirtualObjectLayer;
use mpi::traits::*;
use std::time::Instant;
let args: Vec<String> = std::env::args().collect();
let n_elements: usize = args
.iter()
.position(|a| a == "--size")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse().ok())
.unwrap_or(100_000);
let mut vol = MpiVol::new_world().expect("MPI init failed");
let world = vol.universe.world();
let rank = world.rank() as usize;
let size = world.size() as usize;
let path = format!("/tmp/clawhdf5_mpiio_bench_{n_elements}.h5");
vol.open(&path).unwrap();
// Each rank contributes n_elements/size f64 values
let per_rank = n_elements / size;
let shard: Vec<f64> = (0..per_rank)
.map(|i| (rank * per_rank + i) as f64)
.collect();
let shard_bytes: Vec<u8> = shard.iter().flat_map(|v| v.to_le_bytes()).collect();
// Collective write
world.barrier();
let t0 = Instant::now();
vol.write_dataset("data", &shard_bytes, &[n_elements as u64], "f64")
.unwrap();
world.barrier();
let write_elapsed = t0.elapsed().as_secs_f64();
// Collective read
let t1 = Instant::now();
let _data = vol.read_dataset("data").unwrap();
world.barrier();
let read_elapsed = t1.elapsed().as_secs_f64();
if rank == 0 {
let total_mb = (n_elements * 8) as f64 / 1e6;
println!("=== clawhdf5 MPI-IO Benchmark ===");
println!("Elements : {n_elements}");
println!("Ranks : {size}");
println!("Total : {total_mb:.1} MB");
println!("Write : {:.1} MB/s", total_mb / write_elapsed);
println!("Read : {:.1} MB/s", total_mb / read_elapsed);
}
}
#[cfg(not(feature = "mpi-io"))]
fn main() {
eprintln!("mpi_io_bench requires the `mpi-io` feature.");
eprintln!("Run: mpirun -np N cargo run -p clawhdf5-bench --features mpi-io --bin mpi_io_bench");
std::process::exit(1);
}
+5 -5
View File
@@ -1,9 +1,9 @@
# rustyhdf5-derive
# clawhdf5-derive
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-derive.svg)](https://crates.io/crates/rustyhdf5-derive)
[![docs.rs](https://docs.rs/rustyhdf5-derive/badge.svg)](https://docs.rs/rustyhdf5-derive)
[![crates.io](https://img.shields.io/crates/v/clawhdf5-derive.svg)](https://crates.io/crates/clawhdf5-derive)
[![docs.rs](https://docs.rs/clawhdf5-derive/badge.svg)](https://docs.rs/clawhdf5-derive)
Derive macros for rustyhdf5 HDF5 traits.
Derive macros for clawhdf5 HDF5 traits.
## Features
@@ -13,7 +13,7 @@ Derive macros for rustyhdf5 HDF5 traits.
## Usage
```rust
use rustyhdf5_derive::HDF5Type;
use clawhdf5_derive::HDF5Type;
#[derive(HDF5Type)]
struct Point {
+5 -5
View File
@@ -1,9 +1,9 @@
# rustyhdf5-filters
# clawhdf5-filters
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-filters.svg)](https://crates.io/crates/rustyhdf5-filters)
[![docs.rs](https://docs.rs/rustyhdf5-filters/badge.svg)](https://docs.rs/rustyhdf5-filters)
[![crates.io](https://img.shields.io/crates/v/clawhdf5-filters.svg)](https://crates.io/crates/clawhdf5-filters)
[![docs.rs](https://docs.rs/clawhdf5-filters/badge.svg)](https://docs.rs/clawhdf5-filters)
Filter and compression pipeline for rustyhdf5.
Filter and compression pipeline for clawhdf5.
## Features
@@ -14,7 +14,7 @@ Filter and compression pipeline for rustyhdf5.
## Usage
```rust
use rustyhdf5_filters::{deflate_decode, deflate_encode};
use clawhdf5_filters::{deflate_decode, deflate_encode};
let compressed = deflate_encode(&data, 6).unwrap();
let decompressed = deflate_decode(&compressed).unwrap();
+16 -1
View File
@@ -270,14 +270,29 @@ pub(crate) fn flate2_decompress_preallocated(
Ok(output)
}
/// Absolute ceiling on decompressed output when the caller has no size hint,
/// preventing unbounded allocation from a hostile/corrupted zlib stream.
const MAX_DECOMPRESS_SIZE: usize = 256 * 1024 * 1024;
/// Streaming decompress with dynamic sizing (when output size is unknown).
///
/// Bounded by [`MAX_DECOMPRESS_SIZE`] since there is no chunk-size hint to
/// validate against here — an unbounded `read_to_end` would let a hostile
/// zlib stream force arbitrarily large allocation (a "zlib bomb").
pub(crate) fn flate2_decompress_streaming(data: &[u8]) -> Result<Vec<u8>, String> {
use std::io::Read;
let mut decoder = flate2::read::ZlibDecoder::new(data);
let decoder = flate2::read::ZlibDecoder::new(data);
let mut result = Vec::new();
decoder
.take(MAX_DECOMPRESS_SIZE as u64 + 1)
.read_to_end(&mut result)
.map_err(|e| e.to_string())?;
if result.len() > MAX_DECOMPRESS_SIZE {
return Err(format!(
"decompressed output exceeds {} MiB limit",
MAX_DECOMPRESS_SIZE / 1024 / 1024
));
}
Ok(result)
}
+5
View File
@@ -11,6 +11,7 @@ categories = ["parser-implementations", "science", "encoding", "no-std"]
[dependencies]
byteorder = { version = "1", default-features = false }
portable-atomic = { version = "1" }
flate2 = { version = "1", default-features = false, features = ["rust_backend"], optional = true }
sha2 = { version = "0.10", default-features = false, optional = true }
rayon = { version = "1", optional = true }
@@ -18,6 +19,8 @@ crc32fast = { version = "1", optional = true }
lz4_flex = { version = "0.11", optional = true }
zstd = { version = "0.13", optional = true }
blake3 = { version = "1", optional = true }
libaec-sys = { path = "../libaec-sys", version = "0.1", optional = true }
pco = { version = "1.0", optional = true }
[dev-dependencies]
serde_json = "1"
@@ -43,6 +46,8 @@ zlib-rs = ["flate2/zlib-rs"]
lz4 = ["lz4_flex"]
zstd = ["dep:zstd"]
blake3_hash = ["blake3"]
szip = ["libaec-sys"]
pcodec = ["dep:pco"]
[[bench]]
name = "parallel_decompress_bench"
+4 -4
View File
@@ -1,7 +1,7 @@
# rustyhdf5-format
# clawhdf5-format
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-format.svg)](https://crates.io/crates/rustyhdf5-format)
[![docs.rs](https://docs.rs/rustyhdf5-format/badge.svg)](https://docs.rs/rustyhdf5-format)
[![crates.io](https://img.shields.io/crates/v/clawhdf5-format.svg)](https://crates.io/crates/clawhdf5-format)
[![docs.rs](https://docs.rs/clawhdf5-format/badge.svg)](https://docs.rs/clawhdf5-format)
Pure-Rust HDF5 binary format parsing and writing — no C dependencies.
@@ -16,7 +16,7 @@ Pure-Rust HDF5 binary format parsing and writing — no C dependencies.
## Usage
```rust
use rustyhdf5_format::Superblock;
use clawhdf5_format::Superblock;
let data = std::fs::read("data.h5").unwrap();
let sb = Superblock::from_bytes(&data).unwrap();
+152 -48
View File
@@ -16,6 +16,8 @@ use core::ops::{Deref, DerefMut};
use alloc::collections::BTreeMap;
#[cfg(feature = "std")]
use std::collections::HashMap;
#[cfg(feature = "std")]
use std::sync::Arc;
use crate::chunk_index::{ChunkIndex, ChunkLayout};
use crate::chunked_read::ChunkInfo;
@@ -64,6 +66,11 @@ pub struct CacheAlignedBuffer {
// SAFETY: The raw pointer is exclusively owned — no aliasing.
unsafe impl Send for CacheAlignedBuffer {}
// SAFETY: `CacheAlignedBuffer` exposes its contents only via `&[u8]`/`&mut
// [u8]` through the ordinary borrow-checked `Deref`/`DerefMut` impls below —
// the same access pattern as `Vec<u8>`, which is `Sync`. Needed so
// `Arc<CacheAlignedBuffer>` (used by the chunk cache) is itself `Send`.
unsafe impl Sync for CacheAlignedBuffer {}
impl CacheAlignedBuffer {
/// Allocate a new cache-line-aligned buffer of exactly `len` bytes,
@@ -223,7 +230,9 @@ pub const DEFAULT_MAX_SLOTS: usize = 521;
#[cfg(feature = "std")]
struct CachedChunk {
coord: ChunkCoord,
data: CacheAlignedBuffer,
/// Shared so a cache hit is a refcount bump, not a copy of the whole
/// (potentially large) decompressed chunk.
data: Arc<CacheAlignedBuffer>,
/// Monotonically increasing access counter for LRU ordering.
last_access: u64,
}
@@ -256,9 +265,23 @@ struct CacheInner {
/// Populated once per dataset on first access.
index: Option<HashMap<ChunkCoord, ChunkInfo>>,
/// Address of the dataset (its chunk-index base address) that the cached
/// index, chunk index, layout, and decompressed slots currently belong to.
/// The cache is shared per file across datasets, so every cached-read entry
/// checks this and resets the per-dataset state when the dataset changes —
/// otherwise one dataset's chunk index (with its own rank) would be reused
/// for another, corrupting reads.
index_addr: Option<u64>,
/// LRU cache of decompressed chunk data.
slots: Vec<CachedChunk>,
/// Coordinate -> index into `slots`, for O(1) lookup instead of a linear
/// scan. Kept in sync with `slots` on every insert/evict/clear — in
/// particular, `slots.swap_remove(i)` moves the last element into slot
/// `i`, so the moved element's index entry must be updated too.
slot_index: HashMap<ChunkCoord, usize>,
/// Current total bytes of cached decompressed data.
current_bytes: usize,
@@ -334,7 +357,9 @@ impl ChunkCache {
Self {
inner: std::sync::Mutex::new(CacheInner {
index: None,
index_addr: None,
slots: Vec::with_capacity(max_slots.min(64)),
slot_index: HashMap::with_capacity(max_slots.min(64)),
current_bytes: 0,
max_bytes,
max_slots,
@@ -349,6 +374,30 @@ impl ChunkCache {
// ----- Index operations -----
/// Bind the cache to the dataset at chunk-index address `addr`.
///
/// The cache is shared per file across all of its datasets. If the cache
/// currently holds state for a different dataset, all per-dataset state
/// (chunk index, chunk-index map, layout, and decompressed slots) is
/// dropped so the next access rebuilds it for this dataset. Reading the
/// same dataset again is a no-op, preserving the cache's benefit for
/// repeated/sequential access. Returns `true` if a reset occurred.
pub fn ensure_dataset(&self, addr: u64) -> bool {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
if inner.index_addr == Some(addr) {
return false;
}
inner.index = None;
inner.chunk_index = None;
inner.chunk_layout = None;
inner.slots.clear();
inner.slot_index.clear();
inner.current_bytes = 0;
inner.last_coord = None;
inner.index_addr = Some(addr);
true
}
/// Returns `true` if the chunk index has been built.
pub fn has_index(&self) -> bool {
self.inner
@@ -445,8 +494,20 @@ impl ChunkCache {
/// Try to get cached decompressed data for a chunk coordinate.
///
/// Returns a clone of the cache-line-aligned buffer.
/// O(1) lookup. Returns an owned copy for API compatibility with callers
/// that need a `Vec<u8>`; prefer [`Self::get_decompressed_aligned`] when
/// an `Arc`-shared buffer works for the caller, since that avoids the
/// copy entirely.
pub fn get_decompressed(&self, coord: &[u64]) -> Option<Vec<u8>> {
self.get_decompressed_aligned(coord)
.map(|arc| arc.as_slice().to_vec())
}
/// Try to get a reference-counted clone of the aligned buffer for a chunk.
///
/// O(1) index lookup; the clone is an `Arc` refcount bump, not a copy of
/// the underlying decompressed data.
pub fn get_decompressed_aligned(&self, coord: &[u64]) -> Option<Arc<CacheAlignedBuffer>> {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.tick += 1;
let tick = inner.tick;
@@ -468,36 +529,12 @@ impl ChunkCache {
}
inner.last_coord = Some(coord.to_vec());
let mut found = None;
for slot in inner.slots.iter_mut() {
if slot.coord.as_slice() == coord {
slot.last_access = tick;
found = Some(slot.data.to_vec());
break;
}
}
if let Some(ref data) = found {
inner.stats.hits += 1;
inner.stats.bytes_read += data.len() as u64;
let found = if let Some(&idx) = inner.slot_index.get(coord) {
inner.slots[idx].last_access = tick;
Some(Arc::clone(&inner.slots[idx].data))
} else {
inner.stats.misses += 1;
}
found
}
/// Try to get a reference-counted clone of the aligned buffer for a chunk.
pub fn get_decompressed_aligned(&self, coord: &[u64]) -> Option<CacheAlignedBuffer> {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.tick += 1;
let tick = inner.tick;
let mut found = None;
for slot in inner.slots.iter_mut() {
if slot.coord.as_slice() == coord {
slot.last_access = tick;
found = Some(slot.data.clone());
break;
}
}
None
};
if let Some(ref data) = found {
inner.stats.hits += 1;
inner.stats.bytes_read += data.len() as u64;
@@ -510,30 +547,39 @@ impl ChunkCache {
/// Insert decompressed chunk data into the LRU cache.
///
/// The data is stored in a [`CacheAlignedBuffer`] so subsequent reads
/// return cache-line-aligned memory.
pub fn put_decompressed(&self, coord: ChunkCoord, data: Vec<u8>) {
let aligned = CacheAlignedBuffer::from_slice(&data);
self.put_decompressed_aligned(coord, aligned);
/// return cache-line-aligned memory. Returns the `Arc`-shared buffer that
/// is now cached (or already was), so the caller can reuse it directly
/// instead of holding a separate copy of the same data.
pub fn put_decompressed(&self, coord: ChunkCoord, data: Vec<u8>) -> Arc<CacheAlignedBuffer> {
let aligned = CacheAlignedBuffer::from_vec(data);
self.put_decompressed_aligned(coord, aligned)
}
/// Insert an already-aligned buffer into the LRU cache.
pub fn put_decompressed_aligned(&self, coord: ChunkCoord, data: CacheAlignedBuffer) {
///
/// Returns the `Arc`-shared buffer now held by the cache (the one just
/// inserted, or the existing cached copy if `coord` was already present).
pub fn put_decompressed_aligned(
&self,
coord: ChunkCoord,
data: CacheAlignedBuffer,
) -> Arc<CacheAlignedBuffer> {
let data = Arc::new(data);
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let data_len = data.len();
// Don't cache if single chunk exceeds budget
// Don't cache if single chunk exceeds budget — still return the data
// to the caller, just don't retain it.
if data_len > inner.max_bytes {
return;
return data;
}
// Check if already present
inner.tick += 1;
let tick = inner.tick;
for slot in inner.slots.iter_mut() {
if slot.coord == coord {
slot.last_access = tick;
return; // already cached
}
if let Some(&idx) = inner.slot_index.get(&coord) {
inner.slots[idx].last_access = tick;
return Arc::clone(&inner.slots[idx].data); // already cached
}
// Evict until we have room
@@ -549,23 +595,35 @@ impl ChunkCache {
.map(|(i, _)| i)
.unwrap();
let removed = inner.slots.swap_remove(lru_idx);
inner.slot_index.remove(&removed.coord);
// swap_remove moved the former last element into `lru_idx` (unless
// it *was* the last element) — fix up that element's index entry.
if lru_idx < inner.slots.len() {
let moved_coord = inner.slots[lru_idx].coord.clone();
inner.slot_index.insert(moved_coord, lru_idx);
}
inner.current_bytes -= removed.data.len();
inner.stats.evictions += 1;
}
inner.current_bytes += data_len;
let new_idx = inner.slots.len();
inner.slot_index.insert(coord.clone(), new_idx);
inner.slots.push(CachedChunk {
coord,
data,
data: Arc::clone(&data),
last_access: tick,
});
data
}
/// Clear the entire cache (index + decompressed data).
pub fn clear(&self) {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.index = None;
inner.index_addr = None;
inner.slots.clear();
inner.slot_index.clear();
inner.current_bytes = 0;
inner.tick = 0;
inner.last_coord = None;
@@ -574,11 +632,13 @@ impl ChunkCache {
inner.chunk_layout = None;
}
/// Hint that the given chunk coordinates will be accessed soon.
/// Record that the given chunk coordinates are predicted to be accessed
/// soon (bookkeeping only).
///
/// Pre-populates the chunk index for these coordinates so that
/// subsequent lookups are O(1). This does NOT pre-decompress the
/// chunks — it only ensures the index entries exist.
/// This does **not** prefetch or pre-decompress anything — it only
/// checks whether each coordinate is already in the chunk index and
/// updates access-pattern stats accordingly. Real prefetching (e.g.
/// background pre-decompression) is not implemented.
pub fn prefetch_hint(&self, next_coords: &[ChunkCoord]) {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
if inner.index.is_none() {
@@ -752,6 +812,50 @@ mod tests {
assert_eq!(cache.cached_bytes(), 3);
}
#[test]
fn slot_index_consistent_after_many_evictions() {
// Force repeated swap_remove evictions (small slot budget, many
// inserts) and confirm the coord -> slot index stays correct: every
// remaining coord must still resolve to its own data, not another
// slot's (which would happen if swap_remove's index fixup were wrong).
let cache = ChunkCache::with_capacity(1024 * 1024, 4); // max 4 slots
for i in 0..50u64 {
cache.put_decompressed(vec![i], vec![(i % 256) as u8; 8]);
// Interleave reads of a couple of earlier coords to churn LRU
// order (and thus which slot gets swap_remove'd) beyond simple
// FIFO eviction.
if i >= 2 {
let _ = cache.get_decompressed(&[i - 2]);
}
}
// Whatever remains in the cache (at most 4 slots) must return its
// own correct data.
for i in 0..50u64 {
if let Some(data) = cache.get_decompressed(&[i]) {
assert_eq!(
data,
vec![(i % 256) as u8; 8],
"coord {i} returned wrong data after eviction churn"
);
}
}
assert!(cache.cached_chunk_count() <= 4);
}
#[test]
fn get_decompressed_aligned_shares_arc_on_hit() {
let cache = ChunkCache::new();
cache.put_decompressed(vec![0, 0], vec![9, 9, 9, 9]);
let a = cache.get_decompressed_aligned(&[0, 0]).unwrap();
let b = cache.get_decompressed_aligned(&[0, 0]).unwrap();
// A cache hit clones the Arc (refcount bump), not the underlying
// buffer — both handles point at the same allocation.
assert!(Arc::ptr_eq(&a, &b));
assert_eq!(a.as_slice(), &[9, 9, 9, 9]);
}
// --- CacheAlignedBuffer tests ---
#[test]
+21 -9
View File
@@ -17,6 +17,8 @@ use crate::extensible_array::{ExtensibleArrayHeader, read_extensible_array_chunk
use crate::filter_pipeline::FilterPipeline;
use crate::filters::decompress_chunk;
use crate::fixed_array::{FixedArrayHeader, read_fixed_array_chunks};
#[cfg(feature = "std")]
use std::sync::Arc;
#[cfg(feature = "parallel")]
use crate::parallel_read;
@@ -593,6 +595,10 @@ pub fn read_chunked_data_cached(
)));
}
// The per-file cache is shared across datasets; bind it to this one so a
// different dataset's chunk index is never reused for this read.
cache.ensure_dataset(addr);
// Populate chunk index on first access
if !cache.has_index() {
let chunks = match (version, chunk_index_type) {
@@ -685,7 +691,7 @@ pub fn read_chunked_data_cached(
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
// Try decompressed cache first
let decompressed = if let Some(cached) = cache.get_decompressed(&coord) {
let decompressed = if let Some(cached) = cache.get_decompressed_aligned(&coord) {
cached
} else {
// Decompress from file
@@ -707,8 +713,7 @@ pub fn read_chunked_data_cached(
} else {
raw_chunk.to_vec()
};
cache.put_decompressed(coord, dec.clone());
dec
cache.put_decompressed(coord, dec)
};
let chunk_offsets: Vec<usize> = chunk_info
@@ -946,6 +951,10 @@ pub fn read_chunked_data_sweep(
)));
}
// The per-file cache is shared across datasets; bind it to this one so a
// different dataset's chunk index is never reused for this read.
cache.ensure_dataset(addr);
// Populate chunk index on first access
if !cache.has_index() {
let chunks = match (version, chunk_index_type) {
@@ -1047,7 +1056,7 @@ pub fn read_chunked_data_sweep(
}
// Try decompressed cache first
let decompressed = if let Some(cached) = cache.get_decompressed(&coord) {
let decompressed = if let Some(cached) = cache.get_decompressed_aligned(&coord) {
cached
} else {
// Decompress from file
@@ -1069,8 +1078,7 @@ pub fn read_chunked_data_sweep(
} else {
raw_chunk.to_vec()
};
cache.put_decompressed(coord, dec.clone());
dec
cache.put_decompressed(coord, dec)
};
let chunk_offsets: Vec<usize> = chunk_info
@@ -1169,6 +1177,10 @@ pub fn read_chunked_data_indexed(
)));
}
// The per-file cache is shared across datasets; bind it to this one so a
// different dataset's chunk index is never reused for this read.
cache.ensure_dataset(addr);
// Build chunk index on first access
if !cache.has_chunk_index() {
let chunks = match (version, chunk_index_type) {
@@ -1259,7 +1271,7 @@ pub fn read_chunked_data_indexed(
.ok_or_else(|| FormatError::ChunkedReadError("chunk layout not available".into()))?;
// Decompress chunks (using LRU cache where possible)
let mut chunk_buffers: Vec<CacheAlignedBuffer> = Vec::with_capacity(mappings_info.len());
let mut chunk_buffers: Vec<Arc<CacheAlignedBuffer>> = Vec::with_capacity(mappings_info.len());
for (coord, file_offset, file_size, filter_mask) in &mappings_info {
if let Some(cached) = cache.get_decompressed_aligned(coord) {
chunk_buffers.push(cached);
@@ -1283,8 +1295,8 @@ pub fn read_chunked_data_indexed(
raw_chunk.to_vec()
};
let aligned = CacheAlignedBuffer::from_vec(decompressed);
cache.put_decompressed_aligned(coord.clone(), aligned.clone());
chunk_buffers.push(aligned);
let arc = cache.put_decompressed_aligned(coord.clone(), aligned);
chunk_buffers.push(arc);
}
}
+222 -128
View File
@@ -11,8 +11,8 @@ use crate::chunk_cache::{CACHE_LINE_SIZE, align_to_cache_line};
use crate::ea_writer;
use crate::error::FormatError;
use crate::filter_pipeline::{
FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_SHUFFLE, FILTER_ZSTD, FilterDescription,
FilterPipeline,
FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_PCODEC, FILTER_SHUFFLE, FILTER_ZSTD,
FilterDescription, FilterPipeline,
};
use crate::filters::compress_chunk;
@@ -34,13 +34,19 @@ pub struct ChunkOptions {
/// Deflate compression level (0-9), None = no deflate.
pub deflate_level: Option<u32>,
/// Whether to apply shuffle filter before compression.
/// If `false` AND compression is enabled AND `no_shuffle` is `false`,
/// shuffle is auto-applied (matches h5py default behavior).
pub shuffle: bool,
/// Disable the automatic shuffle pre-filter. Set via `without_shuffle()`.
pub no_shuffle: bool,
/// Whether to apply fletcher32 checksum.
pub fletcher32: bool,
/// Whether to use LZ4 compression (filter ID 32004).
pub lz4: bool,
/// Zstandard compression level (1-22), None = no zstd. Filter ID 32015.
pub zstd_level: Option<u32>,
/// Pcodec lossless numerical compression. Filter ID 32023.
pub pcodec: bool,
}
impl ChunkOptions {
@@ -52,13 +58,20 @@ impl ChunkOptions {
|| self.fletcher32
|| self.lz4
|| self.zstd_level.is_some()
|| self.pcodec
}
/// Build a FilterPipeline from the options.
pub fn build_pipeline(&self, element_size: u32) -> Option<FilterPipeline> {
let mut filters = Vec::new();
if self.shuffle {
let has_compression =
self.deflate_level.is_some() || self.zstd_level.is_some() || self.lz4 || self.pcodec;
// Shuffle before compression. Applied if explicitly requested OR if compression
// is active and the caller hasn't disabled it — matches h5py default behavior
// and implements TDT byte-grouping (arXiv:2506.18062) for free.
if self.shuffle || (has_compression && !self.no_shuffle) {
filters.push(FilterDescription {
filter_id: FILTER_SHUFFLE,
name: None,
@@ -67,8 +80,15 @@ impl ChunkOptions {
});
}
// Compression filters (mutually exclusive, priority: zstd > lz4 > deflate)
if let Some(level) = self.zstd_level {
// Compression filters (mutually exclusive, priority: pcodec > zstd > lz4 > deflate)
if self.pcodec {
filters.push(FilterDescription {
filter_id: FILTER_PCODEC,
name: Some("pcodec".into()),
flags: 0,
client_data: vec![element_size],
});
} else if let Some(level) = self.zstd_level {
filters.push(FilterDescription {
filter_id: FILTER_ZSTD,
name: Some("zstd".into()),
@@ -238,11 +258,19 @@ pub fn split_into_chunks(
}
/// Parallel compression threshold: use rayon when chunk count exceeds this.
#[allow(dead_code)]
const PARALLEL_COMPRESS_THRESHOLD: usize = 4;
///
/// Lowered to 2 to enable parallel compression for typical 4-chunk workloads
/// (e.g., 128×128 matrix with 32-row chunks = 4 chunks). Rayon's overhead is
/// ~2 µs, worthwhile at ≥2 chunks with any real compression (arXiv:2206.14761).
#[cfg(feature = "parallel")]
const PARALLEL_COMPRESS_THRESHOLD: usize = 2;
/// Compress all chunks, using parallel compression when beneficial.
#[allow(dead_code)]
///
/// With the `parallel` feature and more than [`PARALLEL_COMPRESS_THRESHOLD`]
/// filtered chunks, compression runs across rayon threads; otherwise it is
/// sequential. Output order matches input order, so per-chunk bytes are
/// identical to the sequential path.
fn compress_all_chunks(
chunks: &[(Vec<u64>, Vec<u8>)],
pipeline: &Option<FilterPipeline>,
@@ -541,6 +569,158 @@ pub fn build_fixed_array_at(
combined
}
/// Compressed chunks ready to be laid out at any file address.
///
/// Created by [`precompress_chunks`] and consumed by
/// [`build_chunked_data_from_precompressed`]. Caching this between the two
/// writer passes eliminates the double-compression that the two-pass layout
/// algorithm previously performed.
pub struct PrecompressedChunks {
/// Per-chunk: (raw_size_bytes, compressed_bytes).
pub chunks: Vec<(u64, Vec<u8>)>,
pub has_filters: bool,
pub element_size: usize,
pub shape: Vec<u64>,
pub chunk_dims: Vec<u64>,
pub pipeline_message: Option<Vec<u8>>,
}
/// Compress all chunks of a dataset without laying them out at a file address.
///
/// Call this once per dataset in Pass 1, cache the result, then call
/// [`build_chunked_data_from_precompressed`] in both Pass 1 (dummy address
/// for sizing) and Pass 2 (real address) to avoid re-compressing.
pub fn precompress_chunks(
raw_data: &[u8],
shape: &[u64],
chunk_dims: &[u64],
element_size: usize,
options: &ChunkOptions,
) -> Result<PrecompressedChunks, FormatError> {
let pipeline = options.build_pipeline(element_size as u32);
let has_filters = pipeline.is_some();
let pipeline_message = pipeline.as_ref().map(|pl| pl.serialize());
let raw_chunks = split_into_chunks(raw_data, shape, chunk_dims, element_size);
let compressed = compress_all_chunks(&raw_chunks, &pipeline, element_size as u32)?;
let chunks = raw_chunks
.into_iter()
.zip(compressed)
.map(|((_offsets, raw_bytes), c)| (raw_bytes.len() as u64, c))
.collect();
Ok(PrecompressedChunks {
chunks,
has_filters,
element_size,
shape: shape.to_vec(),
chunk_dims: chunk_dims.to_vec(),
pipeline_message,
})
}
/// Lay out precompressed chunks at `base_address` and build index structures.
///
/// This is the address-dependent half of chunk writing. Call it in Pass 1
/// with a dummy address (to get the blob size), and again in Pass 2 with the
/// real address — both times reusing the same [`PrecompressedChunks`] so
/// compression happens only once.
pub fn build_chunked_data_from_precompressed(
pre: &PrecompressedChunks,
base_address: u64,
maxshape: Option<&[u64]>,
) -> ChunkedDataResult {
let offset_size: u8 = 8;
let length_size: u8 = 8;
let num_chunks = pre.chunks.len();
let element_size = pre.element_size;
let mut data_buf = Vec::new();
let mut written_chunks = Vec::with_capacity(num_chunks);
for (raw_size, compressed) in &pre.chunks {
let aligned_offset = align_to_cache_line(data_buf.len());
if aligned_offset > data_buf.len() {
data_buf.resize(aligned_offset, 0u8);
}
let address = base_address + data_buf.len() as u64;
let compressed_size = compressed.len() as u64;
data_buf.extend_from_slice(compressed);
written_chunks.push(WrittenChunk {
address,
compressed_size,
raw_size: *raw_size,
filter_mask: 0,
});
}
let chunk_dims_u32: Vec<u32> = pre.chunk_dims.iter().map(|&d| d as u32).collect();
let use_extensible = maxshape.is_some_and(|ms| ms.contains(&u64::MAX));
let aligned_idx = align_to_cache_line(data_buf.len());
if aligned_idx > data_buf.len() {
data_buf.resize(aligned_idx, 0u8);
}
let layout_message = if use_extensible {
let ea_address = base_address + data_buf.len() as u64;
let ea_bytes = ea_writer::build_extensible_array_at(
&written_chunks,
offset_size,
length_size,
pre.has_filters,
ea_address,
);
data_buf.extend_from_slice(&ea_bytes);
ea_writer::serialize_v4_extensible_array(
&chunk_dims_u32,
ea_address,
offset_size,
element_size as u32,
)
} else if num_chunks == 1 {
let chunk_addr = written_chunks[0].address;
let filtered_size = if pre.has_filters {
Some(written_chunks[0].compressed_size)
} else {
None
};
let filter_mask = if pre.has_filters { Some(0u32) } else { None };
serialize_v4_single_chunk(
&chunk_dims_u32,
chunk_addr,
filtered_size,
filter_mask,
offset_size,
element_size as u32,
)
} else {
let fa_address = base_address + data_buf.len() as u64;
let fa_bytes = build_fixed_array_at(
&written_chunks,
offset_size,
length_size,
pre.has_filters,
fa_address,
);
data_buf.extend_from_slice(&fa_bytes);
serialize_v4_fixed_array(
&chunk_dims_u32,
fa_address,
offset_size,
element_size as u32,
10, // max_nelmts_bits — matches h5py convention
)
};
ChunkedDataResult {
data_bytes: data_buf,
layout_message,
pipeline_message: pre.pipeline_message.clone(),
}
}
/// Build chunked data with absolute addresses.
/// If `maxshape` has unlimited dims, uses Extensible Array index.
pub fn build_chunked_data_at(
@@ -572,119 +752,12 @@ pub fn build_chunked_data_at_ext(
base_address: u64,
maxshape: Option<&[u64]>,
) -> Result<ChunkedDataResult, FormatError> {
let pipeline = options.build_pipeline(element_size as u32);
let chunks = split_into_chunks(raw_data, shape, chunk_dims, element_size);
let num_chunks = chunks.len();
let has_filters = pipeline.is_some();
// Compress each chunk, padding to cache-line boundaries for aligned access
let mut data_buf = Vec::new();
let mut written_chunks = Vec::with_capacity(num_chunks);
for (_offsets, chunk_bytes) in &chunks {
let compressed = if let Some(pl) = pipeline.as_ref() {
compress_chunk(chunk_bytes, pl, element_size as u32)?
} else {
chunk_bytes.clone()
};
// Pad current position to cache-line boundary
let aligned_offset = align_to_cache_line(data_buf.len());
if aligned_offset > data_buf.len() {
data_buf.resize(aligned_offset, 0u8);
}
let address = base_address + data_buf.len() as u64;
let compressed_size = compressed.len() as u64;
let raw_size = chunk_bytes.len() as u64;
data_buf.extend_from_slice(&compressed);
written_chunks.push(WrittenChunk {
address,
compressed_size,
raw_size,
filter_mask: 0,
});
}
let chunk_dims_u32: Vec<u32> = chunk_dims.iter().map(|&d| d as u32).collect();
let offset_size: u8 = 8;
let length_size: u8 = 8;
// Determine if we should use Extensible Array (resizable datasets)
let use_extensible = maxshape.is_some_and(|ms| ms.contains(&u64::MAX));
// Pad before index structures so they are also cache-line aligned
let aligned_idx = align_to_cache_line(data_buf.len());
if aligned_idx > data_buf.len() {
data_buf.resize(aligned_idx, 0u8);
}
let layout_message = if use_extensible {
let ea_address = base_address + data_buf.len() as u64;
let ea_bytes = ea_writer::build_extensible_array_at(
&written_chunks,
offset_size,
length_size,
has_filters,
ea_address,
);
data_buf.extend_from_slice(&ea_bytes);
ea_writer::serialize_v4_extensible_array(
&chunk_dims_u32,
ea_address,
offset_size,
element_size as u32,
)
} else if num_chunks == 1 {
let chunk_addr = written_chunks[0].address;
let filtered_size = if has_filters {
Some(written_chunks[0].compressed_size)
} else {
None
};
let filter_mask = if has_filters { Some(0u32) } else { None };
serialize_v4_single_chunk(
&chunk_dims_u32,
chunk_addr,
filtered_size,
filter_mask,
offset_size,
element_size as u32,
)
} else {
let fa_address = base_address + data_buf.len() as u64;
let max_bits: u8 = 10;
let fa_bytes = build_fixed_array_at(
&written_chunks,
offset_size,
length_size,
has_filters,
fa_address,
);
data_buf.extend_from_slice(&fa_bytes);
serialize_v4_fixed_array(
&chunk_dims_u32,
fa_address,
offset_size,
element_size as u32,
max_bits,
)
};
let pipeline_message = pipeline.as_ref().map(|pl| pl.serialize());
Ok(ChunkedDataResult {
data_bytes: data_buf,
layout_message,
pipeline_message,
})
let pre = precompress_chunks(raw_data, shape, chunk_dims, element_size, options)?;
Ok(build_chunked_data_from_precompressed(
&pre,
base_address,
maxshape,
))
}
/// Write selected elements into an existing in-memory dataset buffer.
@@ -1072,36 +1145,55 @@ mod tests {
#[test]
fn chunk_options_pipeline_deflate() {
// Auto-shuffle is applied before compression by default (matches h5py).
let options = ChunkOptions {
deflate_level: Some(6),
..Default::default()
};
let pl = options.build_pipeline(8).unwrap();
assert_eq!(pl.filters.len(), 2);
assert_eq!(pl.filters[0].filter_id, FILTER_SHUFFLE);
assert_eq!(pl.filters[1].filter_id, FILTER_DEFLATE);
}
#[test]
fn chunk_options_pipeline_deflate_no_shuffle() {
// Users can opt out of auto-shuffle with no_shuffle = true.
let options = ChunkOptions {
deflate_level: Some(6),
no_shuffle: true,
..Default::default()
};
let pl = options.build_pipeline(8).unwrap();
assert_eq!(pl.filters.len(), 1);
assert_eq!(pl.filters[0].filter_id, FILTER_DEFLATE);
}
#[test]
fn chunk_options_pipeline_lz4() {
// Auto-shuffle before LZ4.
let options = ChunkOptions {
lz4: true,
..Default::default()
};
let pl = options.build_pipeline(8).unwrap();
assert_eq!(pl.filters.len(), 1);
assert_eq!(pl.filters[0].filter_id, FILTER_LZ4);
assert_eq!(pl.filters.len(), 2);
assert_eq!(pl.filters[0].filter_id, FILTER_SHUFFLE);
assert_eq!(pl.filters[1].filter_id, FILTER_LZ4);
}
#[test]
fn chunk_options_pipeline_zstd() {
// Auto-shuffle before Zstd.
let options = ChunkOptions {
zstd_level: Some(3),
..Default::default()
};
let pl = options.build_pipeline(8).unwrap();
assert_eq!(pl.filters.len(), 1);
assert_eq!(pl.filters[0].filter_id, FILTER_ZSTD);
assert_eq!(pl.filters[0].client_data, vec![3]);
assert_eq!(pl.filters.len(), 2);
assert_eq!(pl.filters[0].filter_id, FILTER_SHUFFLE);
assert_eq!(pl.filters[1].filter_id, FILTER_ZSTD);
assert_eq!(pl.filters[1].client_data, vec![3]);
}
#[test]
@@ -1112,8 +1204,10 @@ mod tests {
..Default::default()
};
let pl = options.build_pipeline(8).unwrap();
assert_eq!(pl.filters.len(), 1);
assert_eq!(pl.filters[0].filter_id, FILTER_ZSTD);
// shuffle + zstd (deflate is ignored when zstd wins priority)
assert_eq!(pl.filters.len(), 2);
assert_eq!(pl.filters[0].filter_id, FILTER_SHUFFLE);
assert_eq!(pl.filters[1].filter_id, FILTER_ZSTD);
}
#[test]
+173 -86
View File
@@ -67,74 +67,81 @@ pub enum DataLayout {
},
}
/// Parse VDS mappings from global heap object data.
/// Parse VDS mappings from global-heap object data.
///
/// The global heap object for a VDS layout contains a serialized list of
/// source mappings. Each mapping has:
/// - Virtual selection (serialized dataspace selection, variable length)
/// - Source file name (null-terminated string)
/// - Source dataset name (null-terminated string)
/// - Source selection (serialized dataspace selection, variable length)
/// The global-heap block holding a VDS mapping list is laid out as
/// (reverse-engineered and validated against HDF5 2.0):
///
/// The overall format starts with:
/// - version (4 bytes LE) — currently 0
/// - entry count (not explicitly stored; parse until data exhausted)
/// ```text
/// version(1) · nused(length_size, LE) · entry[nused] · checksum(4)
/// ```
///
/// This is a best-effort parser that handles common VDS files. The exact
/// binary format is not fully specified publicly and may vary by HDF5 version.
pub fn parse_vds_mappings(heap_data: &[u8]) -> Result<Vec<VdsMapping>, FormatError> {
if heap_data.len() < 4 {
/// Each entry is:
/// - source file name — a null-terminated string in **block version 0**; in
/// **block version 1** a same-file reference is encoded as a single `0x04`
/// marker byte (the source file is the virtual file itself) in place of the
/// name;
/// - source dataset name (null-terminated string);
/// - source selection (serialized `H5S` dataspace selection — self-describing
/// in length);
/// - virtual selection (serialized `H5S` dataspace selection).
///
/// The selections are decoded with [`crate::selection::Selection`] purely to
/// learn their byte length so the entry list can be walked; the raw selection
/// bytes are retained on each [`VdsMapping`] for the reader to interpret.
pub fn parse_vds_mappings(
heap_data: &[u8],
length_size: u8,
) -> Result<Vec<VdsMapping>, FormatError> {
use crate::selection::Selection;
let ls = length_size as usize;
if heap_data.len() < 1 + ls {
return Ok(Vec::new());
}
// VDS global heap object starts with version(4)
let _version = u32::from_le_bytes([heap_data[0], heap_data[1], heap_data[2], heap_data[3]]);
let mut pos = 4;
let version = heap_data[0];
let mut pos = 1;
let nused = read_length(heap_data, pos, length_size)?;
pos += ls;
// `nused` is untrusted; don't pre-allocate from it. Each entry consumes at
// least a few bytes, so the loop is naturally bounded by the heap data and
// a bogus `nused` simply errors out on the first short read.
let mut mappings = Vec::new();
// Reads one self-describing selection at `pos`, returning its raw bytes and
// advancing past it — bounds-checked so a corrupt selection can't overrun.
let read_selection = |heap_data: &[u8], pos: &mut usize| -> Result<Vec<u8>, FormatError> {
let rest = heap_data.get(*pos..).ok_or(FormatError::UnexpectedEof {
expected: *pos,
available: heap_data.len(),
})?;
let (_, len) = Selection::decode_serialized(rest)?;
let bytes = rest
.get(..len)
.ok_or(FormatError::UnexpectedEof {
expected: pos.saturating_add(len),
available: heap_data.len(),
})?
.to_vec();
*pos += len;
Ok(bytes)
};
while pos < heap_data.len() {
// Each entry: virtual_selection_size(4) + virtual_selection(N) +
// source_file_name(null-term) + source_dataset_name(null-term) +
// source_selection_size(4) + source_selection(N)
if pos + 4 > heap_data.len() {
break;
}
for _ in 0..nused {
// Source file name (with the version-1 same-file marker handled).
let source_file = if version >= 1 && heap_data.get(pos) == Some(&0x04) {
pos += 1;
String::from(".")
} else {
read_null_terminated_string(heap_data, &mut pos)?
};
// Virtual selection
let vsel_size = u32::from_le_bytes([
heap_data[pos],
heap_data[pos + 1],
heap_data[pos + 2],
heap_data[pos + 3],
]) as usize;
pos += 4;
if pos + vsel_size > heap_data.len() {
break;
}
let virtual_selection = heap_data[pos..pos + vsel_size].to_vec();
pos += vsel_size;
// Source file name (null-terminated)
let source_file = read_null_terminated_string(heap_data, &mut pos)?;
// Source dataset name (null-terminated)
// Source dataset name.
let source_dataset = read_null_terminated_string(heap_data, &mut pos)?;
// Source selection
if pos + 4 > heap_data.len() {
break;
}
let ssel_size = u32::from_le_bytes([
heap_data[pos],
heap_data[pos + 1],
heap_data[pos + 2],
heap_data[pos + 3],
]) as usize;
pos += 4;
if pos + ssel_size > heap_data.len() {
break;
}
let source_selection = heap_data[pos..pos + ssel_size].to_vec();
pos += ssel_size;
// Source selection, then virtual selection (both self-describing length).
let source_selection = read_selection(heap_data, &mut pos)?;
let virtual_selection = read_selection(heap_data, &mut pos)?;
mappings.push(VdsMapping {
source_file,
@@ -235,7 +242,7 @@ impl DataLayout {
index: *global_heap_index as u16,
},
)?;
*mappings = parse_vds_mappings(&obj.data)?;
*mappings = parse_vds_mappings(&obj.data, length_size)?;
}
Ok(())
}
@@ -250,7 +257,9 @@ impl DataLayout {
match version {
3 => Self::parse_v3(data, layout_class, offset_size, length_size),
4 => Self::parse_v4(data, layout_class, offset_size, length_size),
// v5 (emitted by HDF5 1.14+/2.0 with `libver=latest`) uses the same
// message structure as v4 — only the version number was bumped.
4 | 5 => Self::parse_v4(data, layout_class, offset_size, length_size),
_ => Err(FormatError::InvalidLayoutVersion(version)),
}
}
@@ -626,6 +635,30 @@ mod tests {
);
}
#[test]
fn v5_chunked_from_hdf5_2_0() {
// Real data layout message from h5py 3.16 / HDF5 2.0 (`libver=latest`)
// for a gzip-compressed 1-D chunked dataset. Version 5 uses the same
// structure as v4 (here: chunked, Fixed Array index). Regression guard
// for reading modern-format chunked datasets.
let bytes: [u8; 17] = [
0x05, 0x02, 0x00, 0x02, 0x01, 0x0a, 0x08, 0x03, 0x0a, 0xef, 0x05, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00,
];
let layout = DataLayout::parse(&bytes, 8, 8).unwrap();
match layout {
DataLayout::Chunked {
chunk_dimensions,
chunk_index_type,
..
} => {
assert_eq!(chunk_dimensions, vec![10, 8]);
assert_eq!(chunk_index_type, Some(3)); // Fixed Array
}
other => panic!("expected Chunked, got {other:?}"),
}
}
#[test]
fn v4_chunked_single_chunk_no_filters() {
let mut buf = vec![4u8, 2]; // version=4, class=2
@@ -678,9 +711,10 @@ mod tests {
#[test]
fn invalid_version() {
let buf = vec![5u8, 0, 0, 0];
// v3-v5 are supported; v6 is not a real layout message version.
let buf = vec![6u8, 0, 0, 0];
let err = DataLayout::parse(&buf, 8, 8).unwrap_err();
assert_eq!(err, FormatError::InvalidLayoutVersion(5));
assert_eq!(err, FormatError::InvalidLayoutVersion(6));
}
#[test]
@@ -747,32 +781,85 @@ mod tests {
}
#[test]
fn parse_vds_mappings_basic() {
// Build a simple VDS mapping blob
let mut blob = Vec::new();
blob.extend_from_slice(&0u32.to_le_bytes()); // version=0
fn parse_vds_mappings_same_file_v1() {
// The exact global-heap block written by HDF5 2.0 for a same-file VDS
// with two sources: src_a -> virtual[0:4], src_b -> virtual[4:8].
let blob = [
0x01u8, // block version 1
0x02, 0, 0, 0, 0, 0, 0, 0, // nused = 2 (length_size = 8)
// entry 0
0x04, // same-file marker (replaces file name)
0x73, 0x72, 0x63, 0x5f, 0x61, 0x00, // "src_a\0"
0x03, 0, 0, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // source sel = ALL
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x01, 0, 0, 0, // virtual sel: HYPER v3
0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x04, 0x00, // start0 stride1 count1 block4
// entry 1
0x04, 0x73, 0x72, 0x63, 0x5f, 0x62, 0x00, // "src_b\0"
0x03, 0, 0, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // source sel = ALL
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x01, 0, 0, 0, // virtual sel: HYPER v3
0x04, 0x00, 0x01, 0x00, 0x01, 0x00, 0x04, 0x00, // start4 stride1 count1 block4
0x68, 0xf0, 0x3e, 0xe4, // checksum (ignored)
];
let mappings = parse_vds_mappings(&blob, 8).unwrap();
assert_eq!(mappings.len(), 2);
assert_eq!(mappings[0].source_file, ".");
assert_eq!(mappings[0].source_dataset, "src_a");
assert_eq!(mappings[1].source_file, ".");
assert_eq!(mappings[1].source_dataset, "src_b");
// Virtual selection (8 bytes of dummy data)
let vsel = vec![1, 2, 3, 4, 5, 6, 7, 8];
blob.extend_from_slice(&(vsel.len() as u32).to_le_bytes());
blob.extend_from_slice(&vsel);
// Virtual selections decode to [0:4] and [4:8].
use crate::selection::Selection;
let (v0, _) = Selection::decode_serialized(&mappings[0].virtual_selection).unwrap();
let (v1, _) = Selection::decode_serialized(&mappings[1].virtual_selection).unwrap();
assert_eq!(v0.iter_linear_1d(8).unwrap(), vec![0, 1, 2, 3]);
assert_eq!(v1.iter_linear_1d(8).unwrap(), vec![4, 5, 6, 7]);
}
// Source file name
blob.extend_from_slice(b"source.h5\0");
// Source dataset name
blob.extend_from_slice(b"/data\0");
// Source selection (4 bytes)
let ssel = vec![10, 20, 30, 40];
blob.extend_from_slice(&(ssel.len() as u32).to_le_bytes());
blob.extend_from_slice(&ssel);
let mappings = parse_vds_mappings(&blob).unwrap();
#[test]
fn parse_vds_mappings_external_v0() {
// Block version 0 with an explicit (external) source file name.
let blob = [
0x00u8, // block version 0
0x01, 0, 0, 0, 0, 0, 0, 0, // nused = 1
0x73, 0x72, 0x63, 0x5f, 0x65, 0x78, 0x74, 0x2e, 0x68, 0x35,
0x00, // "src_ext.h5\0"
0x64, 0x61, 0x74, 0x61, 0x00, // "data\0"
0x03, 0, 0, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // source sel = ALL
0x03, 0, 0, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // virtual sel = ALL
];
let mappings = parse_vds_mappings(&blob, 8).unwrap();
assert_eq!(mappings.len(), 1);
assert_eq!(mappings[0].source_file, "source.h5");
assert_eq!(mappings[0].source_dataset, "/data");
assert_eq!(mappings[0].virtual_selection, vsel);
assert_eq!(mappings[0].source_selection, ssel);
assert_eq!(mappings[0].source_file, "src_ext.h5");
assert_eq!(mappings[0].source_dataset, "data");
}
#[test]
fn parse_vds_mappings_huge_nused_does_not_oom_or_panic() {
// nused = u64::MAX with no entry data: must error, not pre-allocate or
// overrun.
let mut blob = vec![0x01u8];
blob.extend_from_slice(&u64::MAX.to_le_bytes());
assert!(parse_vds_mappings(&blob, 8).is_err());
}
#[test]
fn parse_vds_mappings_truncated_selection_does_not_overrun() {
// One entry whose source selection (ALL) is truncated to 8 of 16 bytes.
let blob = [
0x01u8, // version 1
0x01, 0, 0, 0, 0, 0, 0, 0, // nused = 1
0x04, // same-file marker
0x78, 0x00, // "x\0"
0x03, 0, 0, 0, 0x01, 0, 0, 0, // ALL header, truncated (8 of 16 bytes)
];
assert!(parse_vds_mappings(&blob, 8).is_err());
}
#[test]
fn parse_vds_mappings_empty_is_ok_empty() {
assert!(parse_vds_mappings(&[], 8).unwrap().is_empty());
// Header present, nused = 0.
let blob = [0x01u8, 0, 0, 0, 0, 0, 0, 0, 0];
assert!(parse_vds_mappings(&blob, 8).unwrap().is_empty());
}
}
@@ -0,0 +1,200 @@
//! Write-side helpers for VDS (Virtual Dataset Source) mapping serialization.
//!
//! [`serialize_vds_mappings`] produces the byte blob stored in a global heap
//! object and referenced from a Data Layout v4 class=3 (Virtual) message.
//! Its output is byte-compatible with what [`crate::data_layout::parse_vds_mappings`]
//! can parse back.
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
use crate::data_layout::VdsMapping;
/// Serialize a slice of [`VdsMapping`]s into the global-heap object byte format.
///
/// # Layout
///
/// ```text
/// version(1) · nused(length_size, LE) · entry[nused]
/// ```
///
/// Each entry:
/// - **version 0** (at least one external source file): null-terminated source
/// file name, then null-terminated source dataset name, then source selection
/// bytes (self-describing), then virtual selection bytes (self-describing).
/// - **version 1** (all same-file): a single `0x04` marker byte in place of the
/// file name, then null-terminated source dataset name, then the two
/// self-describing selection blobs.
///
/// The selections are written as-is from [`VdsMapping::source_selection`] and
/// [`VdsMapping::virtual_selection`]; the caller is responsible for ensuring
/// they are valid serialized `H5S` selections that [`crate::selection::Selection::decode_serialized`]
/// can consume.
///
/// `length_size` must be 2, 4, or 8; any other value falls back to 8.
pub fn serialize_vds_mappings(mappings: &[VdsMapping], length_size: u8) -> Vec<u8> {
let mut buf = Vec::new();
// Block version 0 = at least one external (non-same-file) source;
// block version 1 = all sources are in the same file (source_file == ".").
let all_same_file = mappings
.iter()
.all(|m| m.source_file.is_empty() || m.source_file == ".");
let version: u8 = if all_same_file { 1 } else { 0 };
buf.push(version);
// nused: number of mappings, encoded as little-endian `length_size` bytes.
write_length(&mut buf, mappings.len() as u64, length_size);
for m in mappings {
if version == 0 {
// External file: write the file name as a null-terminated string.
buf.extend_from_slice(m.source_file.as_bytes());
buf.push(0u8);
} else {
// Same-file: the marker byte that `parse_vds_mappings` recognises as
// the same-file sentinel (0x04).
buf.push(0x04u8);
}
// Source dataset path: null-terminated string.
buf.extend_from_slice(m.source_dataset.as_bytes());
buf.push(0u8);
// Source selection: raw self-describing bytes (no separate length prefix).
buf.extend_from_slice(&m.source_selection);
// Virtual selection: raw self-describing bytes (no separate length prefix).
buf.extend_from_slice(&m.virtual_selection);
}
buf
}
/// Encode `val` as a little-endian integer of `size` bytes and push it into
/// `buf`. Supported sizes: 2, 4, 8. Any other value falls back to 8 bytes.
pub(crate) fn write_length(buf: &mut Vec<u8>, val: u64, size: u8) {
match size {
2 => buf.extend_from_slice(&(val as u16).to_le_bytes()),
4 => buf.extend_from_slice(&(val as u32).to_le_bytes()),
_ => buf.extend_from_slice(&val.to_le_bytes()),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::data_layout::parse_vds_mappings;
/// A minimal, valid serialized H5S ALL selection (type=3, 16 bytes).
///
/// Layout: type(4 LE) + version(4 LE) + reserved(4) + length(4) = 16 bytes.
/// `decode_serialized` consumes exactly 16 bytes for ALL/NONE.
fn all_sel() -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(&3u32.to_le_bytes()); // type = H5S_SEL_ALL (3)
v.extend_from_slice(&1u32.to_le_bytes()); // version = 1
v.extend_from_slice(&[0u8; 4]); // reserved
v.extend_from_slice(&[0u8; 4]); // length field (unused for ALL)
v
}
#[test]
fn roundtrip_same_file_two_mappings() {
let sel = all_sel();
let mappings = vec![
VdsMapping {
source_file: ".".into(),
source_dataset: "/src_a".into(),
source_selection: sel.clone(),
virtual_selection: sel.clone(),
},
VdsMapping {
source_file: ".".into(),
source_dataset: "/src_b".into(),
source_selection: sel.clone(),
virtual_selection: sel.clone(),
},
];
let bytes = serialize_vds_mappings(&mappings, 8);
// Block version must be 1 (same-file).
assert_eq!(bytes[0], 1u8);
let parsed = parse_vds_mappings(&bytes, 8).unwrap();
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].source_file, ".");
assert_eq!(parsed[0].source_dataset, "/src_a");
assert_eq!(parsed[1].source_file, ".");
assert_eq!(parsed[1].source_dataset, "/src_b");
}
#[test]
fn roundtrip_external_file_mapping() {
let sel = all_sel();
let mappings = vec![VdsMapping {
source_file: "source.h5".into(),
source_dataset: "/data".into(),
source_selection: sel.clone(),
virtual_selection: sel.clone(),
}];
let bytes = serialize_vds_mappings(&mappings, 8);
// Block version must be 0 (external file present).
assert_eq!(bytes[0], 0u8);
let parsed = parse_vds_mappings(&bytes, 8).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].source_file, "source.h5");
assert_eq!(parsed[0].source_dataset, "/data");
assert_eq!(
parsed[0].source_selection, sel,
"source selection bytes must survive round-trip"
);
assert_eq!(
parsed[0].virtual_selection, sel,
"virtual selection bytes must survive round-trip"
);
}
#[test]
fn empty_mappings_roundtrip() {
// Empty slice: version 1 (vacuously all same-file), nused=0.
let bytes = serialize_vds_mappings(&[], 8);
let parsed = parse_vds_mappings(&bytes, 8).unwrap();
assert!(parsed.is_empty());
}
#[test]
fn roundtrip_empty_source_file_treated_as_same_file() {
// An empty source_file string is also treated as same-file (version 1).
let sel = all_sel();
let mappings = vec![VdsMapping {
source_file: String::new(),
source_dataset: "/ds".into(),
source_selection: sel.clone(),
virtual_selection: sel.clone(),
}];
let bytes = serialize_vds_mappings(&mappings, 8);
assert_eq!(bytes[0], 1u8);
let parsed = parse_vds_mappings(&bytes, 8).unwrap();
assert_eq!(parsed.len(), 1);
// parse_vds_mappings turns the 0x04 marker into "."
assert_eq!(parsed[0].source_file, ".");
}
#[test]
fn roundtrip_length_size_4() {
let sel = all_sel();
let mappings = vec![VdsMapping {
source_file: ".".into(),
source_dataset: "/x".into(),
source_selection: sel.clone(),
virtual_selection: sel.clone(),
}];
let bytes = serialize_vds_mappings(&mappings, 4);
let parsed = parse_vds_mappings(&bytes, 4).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].source_dataset, "/x");
}
}
+516 -34
View File
@@ -73,6 +73,16 @@ pub fn read_raw_data(
read_raw_data_full(file_data, layout, dataspace, datatype, None, 8, 8)
}
/// Resolves a Virtual Dataset source **file name** (as stored in the mapping,
/// e.g. `"ext_src.h5"`) to that file's raw bytes.
///
/// The pure-byte read API has no filesystem of its own, so external-file VDS
/// sources are read through a caller-supplied resolver. The std file API wires
/// one that reads relative to the virtual file's directory; callers can supply
/// their own (e.g. an in-memory map) in `no_std` builds. Returning `None` means
/// the source file is unavailable and the mapping is skipped.
pub type VdsSourceResolver<'a> = dyn Fn(&str) -> Option<Vec<u8>> + 'a;
/// Read raw bytes with full parameters including filter pipeline and sizes.
pub fn read_raw_data_full(
file_data: &[u8],
@@ -82,6 +92,54 @@ pub fn read_raw_data_full(
pipeline: Option<&FilterPipeline>,
offset_size: u8,
length_size: u8,
) -> Result<Vec<u8>, FormatError> {
read_raw_data_full_impl(
file_data,
layout,
dataspace,
datatype,
pipeline,
offset_size,
length_size,
None,
)
}
/// Like [`read_raw_data_full`], but with a resolver for external-file Virtual
/// Dataset sources. For non-virtual layouts the resolver is ignored.
#[allow(clippy::too_many_arguments)]
pub fn read_raw_data_full_with_resolver(
file_data: &[u8],
layout: &DataLayout,
dataspace: &Dataspace,
datatype: &Datatype,
pipeline: Option<&FilterPipeline>,
offset_size: u8,
length_size: u8,
resolver: Option<&VdsSourceResolver>,
) -> Result<Vec<u8>, FormatError> {
read_raw_data_full_impl(
file_data,
layout,
dataspace,
datatype,
pipeline,
offset_size,
length_size,
resolver,
)
}
#[allow(clippy::too_many_arguments)]
fn read_raw_data_full_impl(
file_data: &[u8],
layout: &DataLayout,
dataspace: &Dataspace,
datatype: &Datatype,
pipeline: Option<&FilterPipeline>,
offset_size: u8,
length_size: u8,
resolver: Option<&VdsSourceResolver>,
) -> Result<Vec<u8>, FormatError> {
let num_elements = dataspace.num_elements() as usize;
let elem_size = datatype.type_size() as usize;
@@ -128,7 +186,20 @@ pub fn read_raw_data_full(
offset_size,
length_size,
),
DataLayout::Virtual { .. } => Err(FormatError::UnsupportedVersion(0)),
DataLayout::Virtual {
global_heap_address,
global_heap_index,
..
} => read_virtual_data(
file_data,
*global_heap_address,
*global_heap_index,
dataspace,
datatype,
offset_size,
length_size,
resolver,
),
}
}
@@ -355,9 +426,171 @@ pub fn read_raw_data_selection(
)?;
extract_selection_from_buffer(&full_data, dims, elem_size, selection)
}
DataLayout::Virtual { .. } => Err(FormatError::UnsupportedVersion(0)),
DataLayout::Virtual { .. } => {
// Assemble the full virtual dataset, then apply the read selection.
let full_data = read_raw_data_full(
file_data,
layout,
dataspace,
datatype,
pipeline,
offset_size,
length_size,
)?;
extract_selection_from_buffer(&full_data, dims, elem_size, selection)
}
}
}
/// Assemble a **Virtual Dataset (VDS)** from its source mappings.
///
/// Supports virtual datasets of any rank. Same-file sources are read directly;
/// **external-file** sources are read through the caller-supplied `resolver`,
/// which maps a stored source file name to that file's bytes. Each mapping's
/// selected source elements are scattered into the virtual buffer at the
/// positions given by the virtual selection (both enumerated in row-major
/// order, as HDF5 pairs them). Unmapped regions are left at the zero fill value.
///
/// A mapping whose external source file the resolver cannot supply (`None`) is
/// skipped, leaving its region at fill — matching HDF5's tolerance of missing
/// sources. An external source with no resolver at all is a hard error.
#[allow(clippy::too_many_arguments)]
fn read_virtual_data(
file_data: &[u8],
global_heap_address: Option<u64>,
global_heap_index: u32,
dataspace: &Dataspace,
datatype: &Datatype,
offset_size: u8,
length_size: u8,
resolver: Option<&VdsSourceResolver>,
) -> Result<Vec<u8>, FormatError> {
use crate::data_layout::parse_vds_mappings;
use crate::global_heap::GlobalHeapCollection;
use crate::selection::Selection;
let elem_size = datatype.type_size() as usize;
let total_elems = dataspace.num_elements() as usize;
let mut out = vec![0u8; total_elems.saturating_mul(elem_size)];
let virtual_dims = &dataspace.dimensions;
let addr = global_heap_address.ok_or_else(|| {
FormatError::ChunkedReadError("virtual dataset has no mapping global heap".into())
})?;
let coll = GlobalHeapCollection::parse(file_data, addr as usize, length_size)?;
let obj =
coll.get_object(global_heap_index as u16)
.ok_or(FormatError::GlobalHeapObjectNotFound {
collection_address: addr,
index: global_heap_index as u16,
})?;
let mappings = parse_vds_mappings(&obj.data, length_size)?;
for m in &mappings {
let same_file = m.source_file.is_empty() || m.source_file == ".";
// Resolve the bytes of the file holding this source dataset.
let external;
let src_file_data: &[u8] = if same_file {
file_data
} else {
let r = resolver.ok_or_else(|| {
FormatError::ChunkedReadError(
"external-file virtual dataset sources require a file resolver".into(),
)
})?;
match r(&m.source_file) {
Some(bytes) => {
external = bytes;
&external
}
// Source file unavailable: leave this region at fill value.
None => continue,
}
};
let (vsel, _) = Selection::decode_serialized(&m.virtual_selection)?;
let (ssel, _) = Selection::decode_serialized(&m.source_selection)?;
let (src_raw, src_dims) =
read_named_dataset_raw(src_file_data, &m.source_dataset, offset_size, length_size)?;
let vidx = vsel.iter_linear(virtual_dims)?;
let sidx = ssel.iter_linear(&src_dims)?;
if vidx.len() != sidx.len() {
return Err(FormatError::ChunkedReadError(
"virtual/source selection element counts differ".into(),
));
}
for (&v, &s) in vidx.iter().zip(sidx.iter()) {
let (vo, so) = (v as usize * elem_size, s as usize * elem_size);
if vo + elem_size > out.len() || so + elem_size > src_raw.len() {
return Err(FormatError::ChunkedReadError(
"virtual dataset selection out of bounds".into(),
));
}
out[vo..vo + elem_size].copy_from_slice(&src_raw[so..so + elem_size]);
}
}
Ok(out)
}
/// Read a named dataset's raw (decoded) bytes and its dimensions, navigating
/// from the superblock. Used to pull VDS source datasets out of the same file.
fn read_named_dataset_raw(
file_data: &[u8],
path: &str,
_offset_size: u8,
_length_size: u8,
) -> Result<(Vec<u8>, Vec<u64>), FormatError> {
use crate::filter_pipeline::FilterPipeline;
use crate::group_v2::resolve_path_any;
use crate::message_type::MessageType;
use crate::object_header::ObjectHeader;
use crate::signature::find_signature;
use crate::superblock::Superblock;
let sig = find_signature(file_data)?;
let sb = Superblock::parse(file_data, sig)?;
let addr = resolve_path_any(file_data, &sb, path)?;
let hdr = ObjectHeader::parse(file_data, addr as usize, sb.offset_size, sb.length_size)?;
let find = |t: MessageType| hdr.messages.iter().find(|m| m.msg_type == t);
let ds_msg = find(MessageType::Dataspace)
.ok_or_else(|| FormatError::ChunkedReadError("VDS source has no dataspace".into()))?;
let dataspace = Dataspace::parse(&ds_msg.data, sb.length_size)?;
let dt_msg = find(MessageType::Datatype)
.ok_or_else(|| FormatError::ChunkedReadError("VDS source has no datatype".into()))?;
let (datatype, _) = Datatype::parse(&dt_msg.data)?;
let dl_msg = find(MessageType::DataLayout)
.ok_or_else(|| FormatError::ChunkedReadError("VDS source has no data layout".into()))?;
let layout = DataLayout::parse(&dl_msg.data, sb.offset_size, sb.length_size)?;
// A virtual dataset whose source is itself another virtual dataset could
// form a cycle (A -> B -> A) and recurse into a stack overflow. Nested
// virtual sources are exotic and unsupported, so stop here cleanly.
if matches!(layout, DataLayout::Virtual { .. }) {
return Err(FormatError::ChunkedReadError(
"virtual dataset source is itself virtual (unsupported)".into(),
));
}
let pipeline = find(MessageType::FilterPipeline)
.map(|m| FilterPipeline::parse(&m.data))
.transpose()?;
let raw = read_raw_data_full(
file_data,
&layout,
&dataspace,
&datatype,
pipeline.as_ref(),
sb.offset_size,
sb.length_size,
)?;
Ok((raw, dataspace.dimensions.clone()))
}
/// Extract selected elements from a full dataset buffer.
fn extract_selection_from_buffer(
@@ -618,6 +851,11 @@ fn get_size(dt: &Datatype) -> usize {
/// Convert raw bytes to `f64` values.
pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatError> {
// Array datatypes (e.g. an array-typed compound member) are read as a flat
// sequence of their base elements.
if let Datatype::Array { base_type, .. } = datatype {
return read_as_f64(raw, base_type);
}
ensure_numeric(datatype, "FloatingPoint or FixedPoint")?;
let elem_size = get_size(datatype);
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
@@ -671,19 +909,27 @@ fn convert_to_f64(
Ok(v as f64)
}
8 => Ok(read_f64_bytes(bytes, order)),
2 => Ok(read_f16_bytes(bytes, order) as f64),
_ => Err(FormatError::DataSizeMismatch {
expected: 8,
actual: *size as usize,
}),
},
Datatype::FixedPoint { size, signed, .. } => {
if *signed {
let v = read_signed_int(bytes, *size as usize, order);
Ok(v as f64)
Datatype::FixedPoint {
size,
signed,
bit_offset,
bit_precision,
..
} => {
let full = read_unsigned_int(bytes, *size as usize, order);
let (off, prec) = effective_bits(*size as usize, *bit_offset, *bit_precision);
let v = if *signed {
extract_signed(full, off, prec) as f64
} else {
let v = read_unsigned_int(bytes, *size as usize, order);
Ok(v as f64)
}
extract_unsigned(full, off, prec) as f64
};
Ok(v)
}
_ => Err(FormatError::TypeMismatch {
expected: "numeric",
@@ -694,6 +940,9 @@ fn convert_to_f64(
/// Convert raw bytes to `i64` values.
pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatError> {
if let Datatype::Array { base_type, .. } = datatype {
return read_as_i64(raw, base_type);
}
ensure_numeric(datatype, "FixedPoint (signed)")?;
let elem_size = get_size(datatype);
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
@@ -707,6 +956,7 @@ pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatEr
// Fast path: native LE i64 — single bulk memcpy
#[cfg(target_endian = "little")]
if elem_size == 8
&& is_full_width(datatype)
&& matches!(
datatype,
Datatype::FixedPoint {
@@ -725,17 +975,21 @@ pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatEr
}
let order = get_byte_order(datatype);
let (off, prec) = fixed_bits(datatype);
let mut result = Vec::with_capacity(count);
for i in 0..count {
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
let v = read_signed_int(chunk, elem_size, &order);
result.push(v);
let full = read_unsigned_int(chunk, elem_size, &order);
result.push(extract_signed(full, off, prec));
}
Ok(result)
}
/// Convert raw bytes to `u64` values.
pub fn read_as_u64(raw: &[u8], datatype: &Datatype) -> Result<Vec<u64>, FormatError> {
if let Datatype::Array { base_type, .. } = datatype {
return read_as_u64(raw, base_type);
}
ensure_numeric(datatype, "FixedPoint (unsigned)")?;
let elem_size = get_size(datatype);
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
@@ -746,17 +1000,21 @@ pub fn read_as_u64(raw: &[u8], datatype: &Datatype) -> Result<Vec<u64>, FormatEr
}
let count = raw.len() / elem_size;
let order = get_byte_order(datatype);
let (off, prec) = fixed_bits(datatype);
let mut result = Vec::with_capacity(count);
for i in 0..count {
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
let v = read_unsigned_int(chunk, elem_size, &order);
result.push(v);
let full = read_unsigned_int(chunk, elem_size, &order);
result.push(extract_unsigned(full, off, prec));
}
Ok(result)
}
/// Convert raw bytes to `f32` values.
pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatError> {
if let Datatype::Array { base_type, .. } = datatype {
return read_as_f32(raw, base_type);
}
ensure_numeric(datatype, "FloatingPoint")?;
let elem_size = get_size(datatype);
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
@@ -796,17 +1054,30 @@ pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatEr
Datatype::FloatingPoint { size: 8, .. } => {
result.push(read_f64_bytes(chunk, &order) as f32);
}
Datatype::FloatingPoint { size: 2, .. } => {
result.push(read_f16_bytes(chunk, &order));
}
Datatype::FixedPoint {
signed: true, size, ..
signed: true,
size,
bit_offset,
bit_precision,
..
} => {
result.push(read_signed_int(chunk, *size as usize, &order) as f32);
let full = read_unsigned_int(chunk, *size as usize, &order);
let (off, prec) = effective_bits(*size as usize, *bit_offset, *bit_precision);
result.push(extract_signed(full, off, prec) as f32);
}
Datatype::FixedPoint {
signed: false,
size,
bit_offset,
bit_precision,
..
} => {
result.push(read_unsigned_int(chunk, *size as usize, &order) as f32);
let full = read_unsigned_int(chunk, *size as usize, &order);
let (off, prec) = effective_bits(*size as usize, *bit_offset, *bit_precision);
result.push(extract_unsigned(full, off, prec) as f32);
}
_ => {
return Err(FormatError::TypeMismatch {
@@ -821,6 +1092,9 @@ pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatEr
/// Convert raw bytes to `i32` values.
pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatError> {
if let Datatype::Array { base_type, .. } = datatype {
return read_as_i32(raw, base_type);
}
ensure_numeric(datatype, "FixedPoint")?;
let elem_size = get_size(datatype);
if elem_size == 0 || !raw.len().is_multiple_of(elem_size) {
@@ -834,6 +1108,7 @@ pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatEr
// Fast path: native LE i32 — single bulk memcpy
#[cfg(target_endian = "little")]
if elem_size == 4
&& is_full_width(datatype)
&& matches!(
datatype,
Datatype::FixedPoint {
@@ -851,11 +1126,12 @@ pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatEr
}
let order = get_byte_order(datatype);
let (off, prec) = fixed_bits(datatype);
let mut result = Vec::with_capacity(count);
for i in 0..count {
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
let v = read_signed_int(chunk, elem_size, &order);
result.push(v as i32);
let full = read_unsigned_int(chunk, elem_size, &order);
result.push(extract_signed(full, off, prec) as i32);
}
Ok(result)
}
@@ -1232,6 +1508,53 @@ fn read_f64_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f64 {
f64::from_le_bytes(buf)
}
/// Decode an IEEE-754 half-precision (binary16) value to `f32`. Pure integer
/// bit manipulation (no_std-safe, no `powi`/`libm`).
fn read_f16_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f32 {
let mut buf = [0u8; 2];
let len = bytes.len().min(2);
match order {
DatatypeByteOrder::BigEndian => {
for i in 0..len {
buf[i] = bytes[len - 1 - i];
}
}
_ => buf[..len].copy_from_slice(&bytes[..len]),
}
f16_bits_to_f32(u16::from_le_bytes(buf))
}
/// Convert the bit pattern of an IEEE-754 half (binary16) to an `f32`.
fn f16_bits_to_f32(h: u16) -> f32 {
let h = h as u32;
let sign = (h & 0x8000) << 16;
let exp = (h >> 10) & 0x1f;
let mant = h & 0x3ff;
let bits = if exp == 0 {
if mant == 0 {
sign // signed zero
} else {
// Subnormal: normalize into an f32 normal.
let mut e: i32 = -1;
let mut m = mant;
loop {
e += 1;
m <<= 1;
if m & 0x400 != 0 {
break;
}
}
let m = m & 0x3ff;
sign | (((127 - 15 - e) as u32) << 23) | (m << 13)
}
} else if exp == 0x1f {
sign | 0x7f80_0000 | (mant << 13) // inf / NaN
} else {
sign | ((exp + (127 - 15)) << 23) | (mant << 13)
};
f32::from_bits(bits)
}
fn read_f32_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f32 {
let mut buf = [0u8; 4];
let len = bytes.len().min(4);
@@ -1248,6 +1571,68 @@ fn read_f32_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f32 {
f32::from_le_bytes(buf)
}
/// Effective (bit offset, bit precision) for a fixed-point field, defaulting a
/// zero precision to the full storage width.
fn effective_bits(size: usize, bit_offset: u16, bit_precision: u16) -> (u32, u32) {
let prec = if bit_precision == 0 {
(size * 8) as u32
} else {
bit_precision as u32
};
(bit_offset as u32, prec)
}
/// `(bit_offset, bit_precision)` for a fixed-point datatype, full width for
/// other types.
fn fixed_bits(datatype: &Datatype) -> (u32, u32) {
match datatype {
Datatype::FixedPoint {
size,
bit_offset,
bit_precision,
..
} => effective_bits(*size as usize, *bit_offset, *bit_precision),
_ => (0, 0),
}
}
/// Whether a datatype occupies its full storage width (bit offset 0, precision
/// == size·8), in which case the bulk-copy fast read paths apply. Non
/// fixed-point types are treated as full width.
fn is_full_width(datatype: &Datatype) -> bool {
match datatype {
Datatype::FixedPoint {
size,
bit_offset,
bit_precision,
..
} => *bit_offset == 0 && *bit_precision as u32 == *size * 8,
_ => true,
}
}
/// Extract the `precision`-bit field at `offset` from a full-width integer read
/// and sign-extend it. Full-width fields read as an ordinary signed integer;
/// reduced-precision fields sign-extend from the field's top bit (HDF5 stores
/// reduced-precision values zero-filled, so the sign lives in the precision
/// field, not the storage word).
fn extract_signed(full: u64, offset: u32, precision: u32) -> i64 {
if precision == 0 || precision >= 64 {
return full as i64;
}
let field = (full >> offset) & ((1u64 << precision) - 1);
let shift = 64 - precision;
((field << shift) as i64) >> shift
}
/// Extract the `precision`-bit field at `offset` from a full-width integer read.
fn extract_unsigned(full: u64, offset: u32, precision: u32) -> u64 {
if precision == 0 || precision >= 64 {
return full;
}
(full >> offset) & ((1u64 << precision) - 1)
}
fn read_unsigned_int(bytes: &[u8], size: usize, order: &DatatypeByteOrder) -> u64 {
let buf = reorder_bytes(bytes, order);
match size {
@@ -1266,22 +1651,6 @@ fn read_unsigned_int(bytes: &[u8], size: usize, order: &DatatypeByteOrder) -> u6
}
}
fn read_signed_int(bytes: &[u8], size: usize, order: &DatatypeByteOrder) -> i64 {
let buf = reorder_bytes(bytes, order);
match size {
1 => buf[0] as i8 as i64,
2 => i16::from_le_bytes([buf[0], buf[1]]) as i64,
4 => i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) as i64,
8 => i64::from_le_bytes(buf),
_ => {
let u = read_unsigned_int(bytes, size, order);
// Sign extend
let shift = 64 - (size * 8);
((u as i64) << shift) >> shift
}
}
}
// --- Type conversion cost analysis ---
/// Cost classification for type conversions.
@@ -1362,6 +1731,119 @@ mod tests {
use crate::dataspace::{Dataspace, DataspaceType};
use crate::datatype::{CharacterSet, StringPadding};
fn f16_datatype() -> Datatype {
Datatype::FloatingPoint {
size: 2,
byte_order: DatatypeByteOrder::LittleEndian,
bit_offset: 0,
bit_precision: 16,
exponent_location: 10,
exponent_size: 5,
mantissa_location: 0,
mantissa_size: 10,
exponent_bias: 15,
}
}
// IEEE-754 half bit patterns for known values.
fn f16_bits(v: f32) -> u16 {
// Encode a few exact values used by the test.
match v {
x if x == 0.0 => 0x0000,
x if x == 1.0 => 0x3c00,
x if x == -2.0 => 0xc000,
x if x == 0.5 => 0x3800,
x if x == 65504.0 => 0x7bff, // f16 max
_ => panic!("unsupported test value {v}"),
}
}
#[test]
fn read_f16_as_f32_and_f64() {
let values = [0.0f32, 1.0, -2.0, 0.5, 65504.0];
let raw: Vec<u8> = values
.iter()
.flat_map(|&v| f16_bits(v).to_le_bytes())
.collect();
let dt = f16_datatype();
let got32 = read_as_f32(&raw, &dt).unwrap();
assert_eq!(got32, values);
let got64 = read_as_f64(&raw, &dt).unwrap();
let expect64: Vec<f64> = values.iter().map(|&v| v as f64).collect();
assert_eq!(got64, expect64);
}
fn reduced_int(signed: bool, precision: u16) -> Datatype {
Datatype::FixedPoint {
size: 4,
byte_order: DatatypeByteOrder::LittleEndian,
signed,
bit_offset: 0,
bit_precision: precision,
}
}
#[test]
fn reduced_precision_signed_sign_extends() {
// 16-bit-precision signed values stored zero-filled (HDF5's canonical
// layout, e.g. after N-Bit): the reader must sign-extend from bit 15.
let dt = reduced_int(true, 16);
// [-1, 100, -50, -32768] as 0x0000ffff / 0x00000064 / 0x0000ffce / 0x00008000
let raw: Vec<u8> = vec![
0xff, 0xff, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xce, 0xff, 0x00, 0x00, 0x00, 0x80,
0x00, 0x00,
];
assert_eq!(read_as_i32(&raw, &dt).unwrap(), vec![-1, 100, -50, -32768]);
assert_eq!(read_as_i64(&raw, &dt).unwrap(), vec![-1, 100, -50, -32768]);
}
#[test]
fn reduced_precision_unsigned_masks() {
// 12-bit-precision unsigned: high bits must read as zero, not sign.
let dt = reduced_int(false, 12);
// [4095, 1, 2048] as 0x00000fff / 0x00000001 / 0x00000800
let raw: Vec<u8> = vec![
0xff, 0x0f, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00,
];
assert_eq!(read_as_u64(&raw, &dt).unwrap(), vec![4095, 1, 2048]);
}
#[test]
fn full_width_signed_unchanged() {
// Regression: full-width 32-bit signed must be unaffected.
let dt = reduced_int(true, 32);
let raw: Vec<u8> = vec![0xff, 0xff, 0xff, 0xff, 0x2a, 0x00, 0x00, 0x00];
assert_eq!(read_as_i32(&raw, &dt).unwrap(), vec![-1, 42]);
}
#[test]
fn array_datatype_reads_flat_base_elements() {
// An array-typed (e.g. compound member) datatype reads as a flat
// sequence of its base elements, applying base-type precision rules.
let arr = Datatype::Array {
base_type: Box::new(reduced_int(true, 16)),
dimensions: vec![2],
};
// [-1, 100, 1000, -32768] stored zero-filled at 16-bit precision.
let raw: Vec<u8> = vec![
0xff, 0xff, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xe8, 0x03, 0x00, 0x00, 0x00, 0x80,
0x00, 0x00,
];
assert_eq!(
read_as_i32(&raw, &arr).unwrap(),
vec![-1, 100, 1000, -32768]
);
// Nested array-of-array unwraps recursively.
let nested = Datatype::Array {
base_type: Box::new(arr),
dimensions: vec![2],
};
assert_eq!(
read_as_i32(&raw, &nested).unwrap(),
vec![-1, 100, 1000, -32768]
);
}
fn make_f64_le_type() -> Datatype {
Datatype::FloatingPoint {
size: 8,
+74 -2
View File
@@ -348,7 +348,10 @@ impl Datatype {
let num_members = (bf0 as u16) | ((bf1 as u16) << 8);
let mut members = Vec::with_capacity(num_members as usize);
if version == 3 || version == 4 {
if (3..=5).contains(&version) {
// v3, v4 and v5 share the compact member encoding (name,
// variable-width offset, member datatype). HDF5 1.14+/2.0
// with `libver=latest` emits v5 compound types.
let ob = offset_bytes_for_size(size);
for _ in 0..num_members {
let (name, name_len) = read_null_terminated_string(data, pos)?;
@@ -500,7 +503,9 @@ impl Datatype {
},
pos,
))
} else if version == 3 {
} else if (3..=5).contains(&version) {
// v3, v4 and v5 share the array encoding (ndims, dims, base
// type); HDF5 1.14+/2.0 with `libver=latest` emits v5.
ensure_len(data, pos, 1)?;
let ndims = data[pos] as usize;
pos += 1;
@@ -1007,6 +1012,73 @@ mod tests {
}
}
#[test]
fn test_compound_v5_from_hdf5_2_0() {
// Real datatype message bytes emitted by h5py 3.16 / HDF5 2.0 with
// `libver=latest` for a compound dtype [('x','f8'),('y','f8'),('id','i4')].
// The wrapper is datatype version 5; members reuse the v3 compact
// encoding. Regression guard for reading modern-format compound types.
let bytes: [u8; 70] = [
0x56, 0x03, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x11, 0x20, 0x3f,
0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x34, 0x0b, 0x00, 0x34, 0xff,
0x03, 0x00, 0x00, 0x79, 0x00, 0x08, 0x11, 0x20, 0x3f, 0x00, 0x08, 0x00, 0x00, 0x00,
0x00, 0x00, 0x40, 0x00, 0x34, 0x0b, 0x00, 0x34, 0xff, 0x03, 0x00, 0x00, 0x69, 0x64,
0x00, 0x10, 0x10, 0x08, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00,
];
let (dt, _) = Datatype::parse(&bytes).unwrap();
match dt {
Datatype::Compound { size, members } => {
assert_eq!(size, 20);
assert_eq!(members.len(), 3);
assert_eq!((members[0].name.as_str(), members[0].byte_offset), ("x", 0));
assert_eq!((members[1].name.as_str(), members[1].byte_offset), ("y", 8));
assert_eq!(
(members[2].name.as_str(), members[2].byte_offset),
("id", 16)
);
assert!(matches!(
members[0].datatype,
Datatype::FloatingPoint { size: 8, .. }
));
assert!(matches!(
members[2].datatype,
Datatype::FixedPoint {
size: 4,
signed: true,
..
}
));
}
_ => panic!("expected Compound"),
}
}
#[test]
fn test_array_v5_from_hdf5_2_0() {
// Real datatype message from h5py 3.16 / HDF5 2.0 (`libver=latest`) for
// an array dtype `('f8', (3,))`: datatype version 5, class 10, reusing
// the v3 array encoding (ndims, dims, base type).
let bytes: [u8; 33] = [
0x5a, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x11,
0x20, 0x3f, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x34, 0x0b, 0x00,
0x34, 0xff, 0x03, 0x00, 0x00,
];
let (dt, _) = Datatype::parse(&bytes).unwrap();
match dt {
Datatype::Array {
base_type,
dimensions,
} => {
assert_eq!(dimensions, vec![3]);
assert!(matches!(
*base_type,
Datatype::FloatingPoint { size: 8, .. }
));
}
other => panic!("expected Array, got {other:?}"),
}
}
#[test]
fn test_reference_object() {
let buf = build_dt_header(7, 1, [0, 0, 0], 8);
+1 -1
View File
@@ -20,7 +20,7 @@
//! ```
#[cfg(not(feature = "std"))]
use alloc::{string::String, vec, vec::Vec};
use alloc::{format, string::String, vec, vec::Vec};
#[cfg(not(feature = "std"))]
use alloc::collections::BTreeMap;
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,8 @@ pub const FILTER_SCALEOFFSET: u16 = 6;
pub const FILTER_LZ4: u16 = 32004;
/// Zstandard compression.
pub const FILTER_ZSTD: u16 = 32015;
/// Pcodec lossless numerical codec (clawhdf5 internal; not yet HDF5-registered).
pub const FILTER_PCODEC: u16 = 32023;
/// Description of a single filter in a pipeline.
#[derive(Debug, Clone, PartialEq)]
File diff suppressed because it is too large Load Diff
+176
View File
@@ -0,0 +1,176 @@
//! SZIP (libaec Adaptive Entropy Coding) decompression.
//!
//! Gated by the `szip` feature which links against the system libaec library.
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
use crate::error::FormatError;
/// Decompress SZIP-compressed data using libaec.
///
/// `cd` is the HDF5 SZIP filter client data (matches `H5Z_SZIP_PARM_*` indices):
/// cd[0] = options mask (`H5_SZIP_NN_OPTION_MASK = 0x20` enables NN preprocessing)
/// cd[1] = pixels per block (H5Z_SZIP_PARM_PPB; 8, 10, 16, or 32)
/// cd[2] = bits per sample (H5Z_SZIP_PARM_BPP; element bit width)
/// cd[3] = pixels per scan line (H5Z_SZIP_PARM_PPS; informational only)
pub(crate) fn szip_decompress(
_data: &[u8],
_cd: &[u32],
_chunk_size: usize,
) -> Result<Vec<u8>, FormatError> {
#[cfg(feature = "szip")]
{
szip_decode_impl(_data, _cd, _chunk_size)
}
#[cfg(not(feature = "szip"))]
{
Err(FormatError::UnsupportedFilter(
crate::filter_pipeline::FILTER_SZIP,
))
}
}
#[cfg(feature = "szip")]
fn szip_decode_impl(data: &[u8], cd: &[u32], chunk_size: usize) -> Result<Vec<u8>, FormatError> {
if cd.len() < 3 {
return Err(FormatError::ChunkedReadError(
"szip: missing client data".into(),
));
}
let options = cd[0];
let pixels_per_block = cd[1];
let bits_per_sample = cd[2]; // H5Z_SZIP_PARM_BPP
if bits_per_sample == 0 || bits_per_sample > 32 {
return Err(FormatError::ChunkedReadError(
"szip: invalid bits per sample".into(),
));
}
if chunk_size == 0 {
return Err(FormatError::ChunkedReadError(
"szip: unknown output size".into(),
));
}
if data.is_empty() {
return Err(FormatError::ChunkedReadError("szip: empty input".into()));
}
// Map HDF5 option mask to libaec flags.
// HDF5 always stores SZIP data in MSB order, so AEC_DATA_MSB is unconditional.
// H5_SZIP_NN_OPTION_MASK (0x20): NN differential preprocessing.
let mut flags: u32 = libaec_sys::AEC_DATA_MSB;
if options & 0x20 != 0 {
flags |= libaec_sys::AEC_DATA_PREPROCESS;
}
let mut out = vec![0u8; chunk_size];
let mut strm = libaec_sys::AecStream::zeroed();
strm.next_in = data.as_ptr();
strm.avail_in = data.len();
strm.next_out = out.as_mut_ptr();
strm.avail_out = chunk_size;
strm.bits_per_sample = bits_per_sample;
strm.block_size = pixels_per_block;
strm.rsi = 128; // HDF5 default: 128 blocks per reference sample interval
strm.flags = flags;
let result = unsafe { libaec_sys::aec_buffer_decode(&mut strm) };
if result != 0 {
return Err(FormatError::DecompressionError(format!(
"szip: libaec error {result}"
)));
}
let decoded_len = chunk_size - strm.avail_out;
out.truncate(decoded_len);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn szip_disabled_returns_unsupported() {
#[cfg(not(feature = "szip"))]
{
let result = szip_decompress(&[], &[0, 8, 8, 1024], 64);
assert!(
matches!(result, Err(FormatError::UnsupportedFilter(4))),
"expected UnsupportedFilter(4), got {result:?}"
);
}
#[cfg(feature = "szip")]
{
// When szip IS enabled, an empty buffer should error but not panic.
let result = szip_decompress(&[], &[0, 8, 8, 1024], 64);
assert!(result.is_err(), "empty buffer must not succeed");
}
}
/// Round-trip test: encode with libaec then decode through szip_decompress.
///
/// Uses 1024 samples (rsi=128 × block_size=8) so the block count is exact.
#[cfg(feature = "szip")]
#[test]
fn roundtrip_u8_msb_no_nn() {
use libaec_sys::{AEC_DATA_MSB, AecStream};
let original: Vec<u8> = (0..1024u32).map(|i| (i % 256) as u8).collect();
// Encode with libaec directly (no NN, MSB — mirrors what HDF5 always writes).
let mut encoded = vec![0u8; original.len() * 2];
let mut enc = AecStream::zeroed();
enc.next_in = original.as_ptr();
enc.avail_in = original.len();
enc.next_out = encoded.as_mut_ptr();
enc.avail_out = encoded.len();
enc.bits_per_sample = 8;
enc.block_size = 8;
enc.rsi = 128;
enc.flags = AEC_DATA_MSB;
let rc = unsafe { libaec_sys::aec_buffer_encode(&mut enc) };
assert_eq!(rc, 0, "aec_buffer_encode failed: {rc}");
let enc_len = encoded.len() - enc.avail_out;
encoded.truncate(enc_len);
// Decode through our public interface.
// cd[0]=0 (no NN bit 0x20), cd[1]=8 (ppb), cd[2]=8 (bpp), cd[3]=1024 (pps).
let cd = [0u32, 8, 8, 1024];
let decoded = szip_decompress(&encoded, &cd, original.len())
.expect("szip_decompress must succeed on valid libaec output");
assert_eq!(decoded, original, "round-trip must reproduce original data");
}
/// Same round-trip but with NN preprocessing enabled (H5_SZIP_NN_OPTION_MASK = 0x20).
#[cfg(feature = "szip")]
#[test]
fn roundtrip_u8_msb_with_nn() {
use libaec_sys::{AEC_DATA_MSB, AEC_DATA_PREPROCESS, AecStream};
let original: Vec<u8> = (0..1024u32).map(|i| (i % 256) as u8).collect();
let mut encoded = vec![0u8; original.len() * 2];
let mut enc = AecStream::zeroed();
enc.next_in = original.as_ptr();
enc.avail_in = original.len();
enc.next_out = encoded.as_mut_ptr();
enc.avail_out = encoded.len();
enc.bits_per_sample = 8;
enc.block_size = 8;
enc.rsi = 128;
enc.flags = AEC_DATA_MSB | AEC_DATA_PREPROCESS;
let rc = unsafe { libaec_sys::aec_buffer_encode(&mut enc) };
assert_eq!(rc, 0, "aec_buffer_encode with NN failed: {rc}");
let enc_len = encoded.len() - enc.avail_out;
encoded.truncate(enc_len);
// cd[0] = 0x20 (H5_SZIP_NN_OPTION_MASK) → decoder must set AEC_DATA_PREPROCESS.
let cd = [0x20u32, 8, 8, 1024];
let decoded = szip_decompress(&encoded, &cd, original.len())
.expect("szip_decompress with NN must succeed");
assert_eq!(
decoded, original,
"NN round-trip must reproduce original data"
);
}
}
+279 -86
View File
@@ -140,27 +140,36 @@ pub fn read_fixed_array_chunks(
));
}
// Skip version(1) + client_id(1) + header_address(offset_size)
let mut pos = db_header_size;
// Elements start immediately after the data block prefix.
let elements_start = db_offset + db_header_size;
// Check if paged
let page_size = 1u64 << header.max_nelmts_bits;
let is_paged = header.num_elements > page_size;
if is_paged {
// For paged data blocks, we need to handle page bitmap + pages
// For now, implement non-paged path (covers most real-world cases)
let num_elements = header.num_elements as usize;
// A chunk index cannot describe more elements than the file has bytes (each
// element occupies at least `offset_size` bytes). Reject a corrupt count
// before it can drive a huge loop or overflow an offset computation.
if num_elements > file_data.len() {
return Err(FormatError::ChunkedReadError(
"paged Fixed Array data blocks not yet supported".into(),
"Fixed Array element count exceeds file size".into(),
));
}
// Non-paged: elements stored directly
let num_elements = header.num_elements as usize;
let os = offset_size as usize;
// On-disk stride of one element. For non-filtered arrays the element is just
// the chunk address (== offset_size); for filtered arrays it is
// address + chunk_size + filter_mask (== header.element_size).
let elem_stride = (header.element_size as usize).max(os);
// Compute chunk offsets based on index
// Chunks are stored in row-major order within the dataset space
// Absolute file offset of element `idx` within a run starting at `base`,
// with overflow surfaced as a clean error rather than a panic/wrap.
let elem_at = |base: usize, idx: usize| -> Result<usize, FormatError> {
idx.checked_mul(elem_stride)
.and_then(|o| base.checked_add(o))
.ok_or(FormatError::ChunkedReadError(
"Fixed Array element offset overflow".into(),
))
};
// Compute chunk offsets based on index.
// Chunks are stored in row-major order within the dataset space.
let mut num_chunks_per_dim = Vec::with_capacity(rank);
for d_idx in 0..rank {
let ch_dim = chunk_dimensions[d_idx] as u64;
@@ -177,97 +186,150 @@ pub fn read_fixed_array_chunks(
chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64;
let mut chunks = Vec::new();
for i in 0..num_elements {
let abs_pos = db_offset
.checked_add(pos)
.ok_or(FormatError::UnexpectedEof {
expected: usize::MAX,
available: file_data.len(),
})?;
if abs_pos > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: abs_pos,
available: file_data.len(),
});
}
let elem_data = &file_data[abs_pos..];
if header.client_id == 0 {
// Non-filtered: just address
if db_offset
.checked_add(pos)
.and_then(|p| p.checked_add(os))
.is_none_or(|end| end > file_data.len())
{
return Err(FormatError::UnexpectedEof {
expected: db_offset.saturating_add(pos).saturating_add(os),
available: file_data.len(),
});
}
let address = read_offset(elem_data, 0, offset_size)?;
pos += os;
if is_undefined(file_data, db_offset + pos - os, offset_size) {
continue; // unallocated chunk
}
let push_element =
|i: usize, abs: usize, chunks: &mut Vec<ChunkInfo>| -> Result<(), FormatError> {
if let Some((address, chunk_size, filter_mask)) = parse_fa_element(
file_data,
abs,
header.client_id,
offset_size,
header.element_size,
chunk_byte_size,
)? {
let offsets = index_to_chunk_offsets(i, &num_chunks_per_dim, chunk_dimensions);
chunks.push(ChunkInfo {
chunk_size: chunk_byte_size as u32,
filter_mask: 0,
chunk_size,
filter_mask,
offsets,
address,
});
}
Ok(())
};
// A data block is paged when it holds more elements than fit in one page.
// `max_nelmts_bits` is an untrusted u8; a shift >= the pointer width would
// panic, so reject it (real page-size bits are tiny — 10 by default).
if header.max_nelmts_bits as u32 >= usize::BITS {
return Err(FormatError::ChunkedReadError(
"Fixed Array max_nelmts_bits too large".into(),
));
}
let page_nelmts = 1usize << header.max_nelmts_bits;
let is_paged = num_elements > page_nelmts;
if !is_paged {
// Non-paged: prefix, then `num_elements` elements packed directly,
// then a trailing checksum (which we don't validate).
for i in 0..num_elements {
push_element(i, elem_at(elements_start, i)?, &mut chunks)?;
}
return Ok(chunks);
}
// Paged layout: prefix, then a page-init bitmap (one bit per page, MSB-first
// within each byte), then a 4-byte checksum, then the pages. Every page
// occupies a full slot of `page_nelmts` elements plus a 4-byte checksum;
// only the final page holds fewer elements. Uninitialized pages (bit clear)
// still occupy their slot on disk but are zero-filled, so the bitmap — not a
// 0xFF sentinel — is what marks a whole page as unallocated.
let stride_overflow =
|| FormatError::ChunkedReadError("Fixed Array page offset overflow".into());
let npages = num_elements.div_ceil(page_nelmts);
let bitmap_size = npages.div_ceil(8);
let bitmap_start = elements_start;
// prefix(db_header_size) + bitmap + checksum(4)
let pages_start = db_offset + db_header_size + bitmap_size + 4;
let page_stride = page_nelmts
.checked_mul(elem_stride)
.and_then(|x| x.checked_add(4))
.ok_or_else(stride_overflow)?;
if bitmap_start + bitmap_size > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: bitmap_start + bitmap_size,
available: file_data.len(),
});
}
for p in 0..npages {
let page_first = p * page_nelmts; // < num_elements, cannot overflow
let page_count = core::cmp::min(page_nelmts, num_elements - page_first);
// Check the page-init bit (MSB-first within each byte).
let bit_byte = file_data[bitmap_start + p / 8];
let bit_mask = 1u8 << (7 - (p % 8));
if bit_byte & bit_mask == 0 {
continue; // entire page unallocated
}
let page_off = p
.checked_mul(page_stride)
.and_then(|o| pages_start.checked_add(o))
.ok_or_else(stride_overflow)?;
for e in 0..page_count {
push_element(page_first + e, elem_at(page_off, e)?, &mut chunks)?;
}
}
Ok(chunks)
}
/// Parse a single Fixed Array element at absolute file offset `abs`.
///
/// Returns `Some((address, chunk_size, filter_mask))` for an allocated chunk, or
/// `None` if the element is undefined (an unallocated chunk, address all-`0xFF`).
fn parse_fa_element(
file_data: &[u8],
abs: usize,
client_id: u8,
offset_size: u8,
element_size: u8,
chunk_byte_size: u64,
) -> Result<Option<(u64, u32, u32)>, FormatError> {
let os = offset_size as usize;
if client_id == 0 {
// Non-filtered: element is just the chunk address.
if abs + os > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: abs + os,
available: file_data.len(),
});
}
if is_undefined(file_data, abs, offset_size) {
return Ok(None);
}
let address = read_offset(file_data, abs, offset_size)?;
Ok(Some((address, chunk_byte_size as u32, 0)))
} else {
// Filtered: address(offset_size) + chunk_size(variable) + filter_mask(4)
let es = header.element_size as usize;
let es = element_size as usize;
if es < os + 4 {
return Err(FormatError::ChunkedReadError(
"element_size too small for filtered element".into(),
));
}
let chunk_size_bytes = es - os - 4;
let elem_total = os + chunk_size_bytes + 4;
if db_offset
.checked_add(pos)
.and_then(|p| p.checked_add(elem_total))
.is_none_or(|end| end > file_data.len())
{
if abs + es > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: db_offset.saturating_add(pos).saturating_add(elem_total),
expected: abs + es,
available: file_data.len(),
});
}
let address = read_offset(elem_data, 0, offset_size)?;
// Read chunk_size (variable length, little-endian)
let chunk_size = read_variable_length(&elem_data[os..], chunk_size_bytes)?;
let fm_off = os + chunk_size_bytes;
if is_undefined(file_data, abs, offset_size) {
return Ok(None);
}
let address = read_offset(file_data, abs, offset_size)?;
let chunk_size = read_variable_length(&file_data[abs + os..], chunk_size_bytes)?;
let fm_off = abs + os + chunk_size_bytes;
let filter_mask = u32::from_le_bytes([
elem_data[fm_off],
elem_data[fm_off + 1],
elem_data[fm_off + 2],
elem_data[fm_off + 3],
file_data[fm_off],
file_data[fm_off + 1],
file_data[fm_off + 2],
file_data[fm_off + 3],
]);
pos += elem_total;
if is_undefined(file_data, db_offset + pos - elem_total, offset_size) {
continue; // unallocated chunk
Ok(Some((address, chunk_size as u32, filter_mask)))
}
let offsets = index_to_chunk_offsets(i, &num_chunks_per_dim, chunk_dimensions);
chunks.push(ChunkInfo {
chunk_size: chunk_size as u32,
filter_mask,
offsets,
address,
});
}
}
Ok(chunks)
}
/// Convert a linear chunk index to N-dimensional chunk offsets in dataset space.
@@ -392,6 +454,41 @@ mod tests {
assert!(result.is_err());
}
/// Malformed headers must error, never panic (shift overflow, huge counts).
#[test]
fn read_rejects_oversized_max_nelmts_bits() {
let mut buf = vec![0u8; 512];
let fahd = 0x40usize;
buf[fahd..fahd + 4].copy_from_slice(b"FAHD");
buf[fahd + 4] = 0; // version
buf[fahd + 5] = 0; // client_id
buf[fahd + 6] = 8; // element_size
buf[fahd + 7] = 200; // max_nelmts_bits — absurd, would overflow a shift
buf[fahd + 8..fahd + 16].copy_from_slice(&3u64.to_le_bytes()); // num_elements
buf[fahd + 16..fahd + 24].copy_from_slice(&0x100u64.to_le_bytes());
// FADB so parsing reaches the paged check
let db = 0x100usize;
buf[db..db + 4].copy_from_slice(b"FADB");
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
assert!(r.is_err());
}
#[test]
fn read_rejects_num_elements_larger_than_file() {
let mut buf = vec![0u8; 256];
let fahd = 0x40usize;
buf[fahd..fahd + 4].copy_from_slice(b"FAHD");
buf[fahd + 6] = 8;
buf[fahd + 7] = 10;
buf[fahd + 8..fahd + 16].copy_from_slice(&u64::MAX.to_le_bytes()); // absurd count
buf[fahd + 16..fahd + 24].copy_from_slice(&0x80u64.to_le_bytes());
buf[0x80..0x84].copy_from_slice(b"FADB");
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
assert!(r.is_err());
}
#[test]
fn parse_fixed_array_header_invalid_version() {
let mut buf = vec![0u8; 256];
@@ -535,4 +632,100 @@ mod tests {
assert_eq!(chunks[2].address, 0x3000);
assert_eq!(chunks[2].chunk_size, 100);
}
/// Build a synthetic *paged* Fixed Array (non-filtered) and verify reading.
///
/// Layout reverse-engineered and confirmed against an HDF5 2.0 file:
/// after the FADB prefix comes a page-init bitmap (MSB-first within each
/// byte), a 4-byte checksum, then full-size page slots (`page_nelmts`
/// elements + a 4-byte checksum each), with only the last page shorter.
/// Uninitialized pages occupy their slot but are skipped via the bitmap.
#[test]
fn read_paged_non_filtered_chunks() {
let offset_size: u8 = 8;
let length_size: u8 = 8;
let os = offset_size as usize;
// page_nelmts = 1 << 2 = 4. Use 11 elements => 3 pages
// (page0: 4, page1: 4, page2: 3 short). Initialize pages 0 and 2; leave
// page 1 uninitialized. 3 pages still fits one bitmap byte, but we place
// the set bits at positions 7 and 5 to lock the MSB-first ordering.
let max_nelmts_bits = 2u8;
let page_nelmts = 1usize << max_nelmts_bits; // 4
let num_elements = 11u64;
let db_header_size = 4 + 1 + 1 + os; // FADB sig+ver+client+header_addr
let bitmap_size = 1usize; // ceil(3/8)
let page_total = page_nelmts * os + 4; // elements + checksum
let fahd_offset = 0x100usize;
let db_offset = 0x400usize;
let mut file_data = vec![0u8; 0x4000];
// FAHD
file_data[fahd_offset..fahd_offset + 4].copy_from_slice(b"FAHD");
file_data[fahd_offset + 4] = 0; // version
file_data[fahd_offset + 5] = 0; // client_id = non-filtered
file_data[fahd_offset + 6] = os as u8; // element_size = address only
file_data[fahd_offset + 7] = max_nelmts_bits;
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_elements.to_le_bytes());
file_data[fahd_offset + 16..fahd_offset + 24]
.copy_from_slice(&(db_offset as u64).to_le_bytes());
// FADB prefix
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
file_data[db_offset + 4] = 0; // version
file_data[db_offset + 5] = 0; // client_id
file_data[db_offset + 6..db_offset + 6 + os]
.copy_from_slice(&(fahd_offset as u64).to_le_bytes());
// Page-init bitmap: pages 0 and 2 initialized, page 1 not.
// MSB-first => page0 -> bit7 (0x80), page2 -> bit5 (0x20) => 0xA0.
let bitmap_off = db_offset + db_header_size;
file_data[bitmap_off] = 0b1010_0000;
// Pages start after bitmap + 4-byte checksum.
let pages_start = db_offset + db_header_size + bitmap_size + 4;
let base_addr = 0x1000u64;
// Page 0 (elements 0..4) and page 2 (elements 8..11) carry addresses;
// page 1's slot is left zero-filled and must be skipped.
for &p in &[0usize, 2usize] {
let page_off = pages_start + p * page_total;
let count = core::cmp::min(page_nelmts, num_elements as usize - p * page_nelmts);
for e in 0..count {
let i = p * page_nelmts + e;
let addr = base_addr + i as u64 * 0x100;
let pos = page_off + e * os;
file_data[pos..pos + os].copy_from_slice(&addr.to_le_bytes());
}
}
let header =
FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap();
assert_eq!(header.num_elements, 11);
let ds_dims = vec![11u64 * 20];
let chunk_dims = vec![20u32];
let chunks = read_fixed_array_chunks(
&file_data,
&header,
&ds_dims,
&chunk_dims,
8,
offset_size,
length_size,
)
.unwrap();
// Page 1 (elements 4,5,6,7) is uninitialized => skipped. The remaining
// 7 chunks (0..4 and 8..11) come back with their original linear index.
assert_eq!(chunks.len(), 7);
let mut got: Vec<(u64, u64)> = chunks.iter().map(|c| (c.offsets[0], c.address)).collect();
got.sort();
let expect: Vec<(u64, u64)> = [0usize, 1, 2, 3, 8, 9, 10]
.iter()
.map(|&i| (i as u64 * 20, base_addr + i as u64 * 0x100))
.collect();
assert_eq!(got, expect);
}
}
+22 -2
View File
@@ -379,8 +379,9 @@ impl FractalHeapHeader {
// Build table of (block_size, heap_offset) for each child entry
let mut current_heap_offset = iblock_heap_offset;
// Count direct block entries vs indirect block entries
let start_indirect = self.starting_row_of_indirect_blocks as usize;
// Rows below max_direct_rows hold direct blocks; rows at/above hold
// child indirect blocks. (NOT the FRHP "starting rows" field.)
let start_indirect = self.max_direct_rows();
// Read child addresses for direct block rows
let max_direct_rows = nrows_usize.min(start_indirect);
@@ -455,6 +456,25 @@ impl FractalHeapHeader {
})
}
/// Number of rows in the doubling table whose block size is at most the
/// maximum *direct* block size. Rows below this hold direct blocks; rows at
/// or above it hold child indirect blocks.
///
/// This is derived from the heap geometry, NOT the FRHP
/// "Starting # of Rows in Root Indirect Block" field (a constant, often 1)
/// — confusing the two makes a multi-direct-block heap unreadable.
fn max_direct_rows(&self) -> usize {
if self.starting_block_size == 0 {
return usize::MAX;
}
// Rows 0 and 1 share the starting block size; row r (r >= 1) is
// starting_block_size * 2^(r-1). The largest direct row reaches
// max_direct_block_size, giving log2(max/start) + 2 direct rows.
let ratio = (self.max_direct_block_size / self.starting_block_size).max(1);
let log2 = 63 - ratio.leading_zeros() as usize;
log2 + 2
}
/// Get block size for a given row in the doubling table.
fn block_size_for_row(&self, row: usize) -> u64 {
let sbs = self.starting_block_size;
+1 -1
View File
@@ -6,7 +6,7 @@ use alloc::vec::Vec;
use crate::error::FormatError;
/// Magic signature for global heap collections.
const GCOL_SIGNATURE: [u8; 4] = [b'G', b'C', b'O', b'L'];
const GCOL_SIGNATURE: [u8; 4] = *b"GCOL";
/// A parsed global heap collection.
#[derive(Debug, Clone)]
+2
View File
@@ -58,6 +58,7 @@ pub mod chunk_index;
pub mod chunked_read;
pub mod chunked_write;
pub mod data_layout;
pub mod data_layout_write;
pub mod data_read;
pub mod dataspace;
pub mod datatype;
@@ -68,6 +69,7 @@ pub mod extensible_array;
pub mod file_writer;
pub mod filter_pipeline;
pub mod filters;
mod filters_szip;
pub mod fixed_array;
pub mod fractal_heap;
pub mod global_heap;
+3 -4
View File
@@ -9,10 +9,10 @@ use crate::error::FormatError;
use crate::message_type::MessageType;
/// OHDR signature for v2 object headers.
const OHDR_SIGNATURE: [u8; 4] = [b'O', b'H', b'D', b'R'];
const OHDR_SIGNATURE: [u8; 4] = *b"OHDR";
/// OCHK signature for v2 continuation chunks.
const OCHK_SIGNATURE: [u8; 4] = [b'O', b'C', b'H', b'K'];
const OCHK_SIGNATURE: [u8; 4] = *b"OCHK";
/// A single parsed header message.
#[derive(Debug, Clone)]
@@ -555,8 +555,7 @@ mod tests {
buf.push(2); // version
buf.push(flags);
if has_timestamps
&& let Some((at, mt, ct, bt)) = timestamps {
if has_timestamps && let Some((at, mt, ct, bt)) = timestamps {
buf.extend_from_slice(&at.to_le_bytes());
buf.extend_from_slice(&mt.to_le_bytes());
buf.extend_from_slice(&ct.to_le_bytes());
+1 -1
View File
@@ -4,7 +4,7 @@
//! events. The [`DefaultProfiler`] implementation uses atomic counters for
//! thread-safe, low-overhead profiling.
use core::sync::atomic::{AtomicU64, Ordering};
use portable_atomic::{AtomicU64, Ordering};
/// Trait for profiling I/O operations.
///
+427
View File
@@ -19,6 +19,8 @@ use alloc::{vec, vec::Vec};
use core::ops::Range;
use crate::error::FormatError;
/// A selection describing which elements of a dataset to access.
#[derive(Debug, Clone, PartialEq)]
pub enum Selection {
@@ -220,6 +222,262 @@ impl Selection {
}
}
}
/// Decode a selection from its on-disk **`H5S_select_serialize`** form.
///
/// Returns the selection and the number of bytes consumed (selections are
/// self-describing in length, so the count lets a caller walk a packed list
/// of selections — as the Virtual Dataset global-heap block does).
///
/// Only the forms needed for VDS assembly are decoded: `ALL`, `NONE`, and
/// **regular** hyperslabs serialized at **version 3** (the encoding HDF5
/// 1.10+/2.0 emit). Point selections, irregular hyperslabs, and older
/// hyperslab versions return an error rather than mis-decoding.
pub fn decode_serialized(data: &[u8]) -> Result<(Selection, usize), FormatError> {
if data.len() < 8 {
return Err(FormatError::UnexpectedEof {
expected: 8,
available: data.len(),
});
}
let sel_type = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
match sel_type {
// ALL / NONE: type(4) + version(4) + reserved(4) + length(4) = 16 bytes.
3 | 0 => {
if data.len() < 16 {
return Err(FormatError::UnexpectedEof {
expected: 16,
available: data.len(),
});
}
let sel = if sel_type == 3 {
Selection::All
} else {
Selection::None
};
Ok((sel, 16))
}
2 => decode_hyperslab_serialized(data, version),
1 => Err(FormatError::ChunkedReadError(
"VDS point selections are not supported".into(),
)),
_ => Err(FormatError::ChunkedReadError(
"unknown dataspace selection type".into(),
)),
}
}
/// Enumerate the selected element indices of a **1-D** dataspace of the
/// given `extent`, in row-major selection order.
///
/// Convenience wrapper over [`Selection::iter_linear`] for rank-1 spaces.
pub fn iter_linear_1d(&self, extent: u64) -> Result<Vec<u64>, FormatError> {
self.iter_linear(&[extent])
}
/// Enumerate the **row-major linear indices** of the selected elements of a
/// dataspace with shape `dims`, in row-major (C) iteration order.
///
/// This is the order HDF5 uses to pair a virtual selection with a source
/// selection in a Virtual Dataset, so the i-th index returned here for the
/// virtual selection corresponds to the i-th index for the source
/// selection. Hyperslab/point selections whose rank differs from
/// `dims.len()` are rejected.
pub fn iter_linear(&self, dims: &[u64]) -> Result<Vec<u64>, FormatError> {
let overflow = || FormatError::Overflow("VDS selection index overflow".into());
let total: u64 = dims
.iter()
.try_fold(1u64, |acc, &d| acc.checked_mul(d))
.ok_or_else(overflow)?;
// Row-major strides: row_stride[d] = product(dims[d+1..]).
let rank = dims.len();
let mut row_stride = vec![1u64; rank];
for d in (0..rank.saturating_sub(1)).rev() {
row_stride[d] = row_stride[d + 1]
.checked_mul(dims[d + 1])
.ok_or_else(overflow)?;
}
match self {
Selection::All => Ok((0..total).collect()),
Selection::None => Ok(Vec::new()),
Selection::Hyperslab {
start,
stride,
count,
block,
} => {
if start.len() != rank {
return Err(FormatError::ChunkedReadError(
"VDS selection rank does not match dataspace rank".into(),
));
}
// Selected coordinates along each dimension, in order.
let mut per_dim: Vec<Vec<u64>> = Vec::with_capacity(rank);
for d in 0..rank {
let mut coords = Vec::new();
for ci in 0..count[d] {
let base = ci
.checked_mul(stride[d])
.and_then(|o| start[d].checked_add(o))
.ok_or_else(overflow)?;
for bi in 0..block[d] {
let coord = base.checked_add(bi).ok_or_else(overflow)?;
// Anything past the extent is malformed; bail before the
// coordinate list can grow without bound.
if coord >= dims[d] {
return Err(FormatError::ChunkedReadError(
"VDS hyperslab selection exceeds dataspace extent".into(),
));
}
coords.push(coord);
}
}
per_dim.push(coords);
}
if per_dim.iter().any(|c| c.is_empty()) {
return Ok(Vec::new());
}
// Cartesian product in row-major order (dim 0 slowest-varying).
let out_len: usize = per_dim
.iter()
.try_fold(1usize, |acc, c| acc.checked_mul(c.len()))
.ok_or_else(overflow)?;
let mut out = Vec::with_capacity(out_len);
let mut idx = vec![0usize; rank];
loop {
let mut lin = 0u64;
for d in 0..rank {
lin = per_dim[d][idx[d]]
.checked_mul(row_stride[d])
.and_then(|o| lin.checked_add(o))
.ok_or_else(overflow)?;
}
out.push(lin);
// Increment the mixed-radix counter, last dimension fastest.
let mut carry = true;
for d in (0..rank).rev() {
idx[d] += 1;
if idx[d] < per_dim[d].len() {
carry = false;
break;
}
idx[d] = 0;
}
if carry {
break;
}
}
Ok(out)
}
Selection::Points(pts) => {
let mut out = Vec::with_capacity(pts.len());
for p in pts {
if p.len() != rank {
return Err(FormatError::ChunkedReadError(
"VDS point selection rank does not match dataspace rank".into(),
));
}
let mut lin = 0u64;
for d in 0..rank {
if p[d] >= dims[d] {
return Err(FormatError::ChunkedReadError(
"VDS point selection exceeds dataspace extent".into(),
));
}
lin = p[d]
.checked_mul(row_stride[d])
.and_then(|o| lin.checked_add(o))
.ok_or_else(overflow)?;
}
out.push(lin);
}
Ok(out)
}
}
}
}
/// Decode an `H5S_SEL_HYPER` selection in its serialized form. Only version-3
/// **regular** hyperslabs are supported.
fn decode_hyperslab_serialized(
data: &[u8],
version: u32,
) -> Result<(Selection, usize), FormatError> {
if version != 3 {
return Err(FormatError::ChunkedReadError(
"only version-3 hyperslab selections are supported".into(),
));
}
// type(4) ver(4) flags(1) enc_size(1) rank(4) [start,stride,count,block]*rank
if data.len() < 14 {
return Err(FormatError::UnexpectedEof {
expected: 14,
available: data.len(),
});
}
let flags = data[8];
let enc_size = data[9] as usize;
// Bit 0 set => regular hyperslab. Irregular hyperslabs list explicit blocks.
if flags & 0x01 == 0 {
return Err(FormatError::ChunkedReadError(
"irregular VDS hyperslab selections are not supported".into(),
));
}
if enc_size != 2 && enc_size != 4 && enc_size != 8 {
return Err(FormatError::ChunkedReadError(
"unsupported hyperslab coordinate encoding size".into(),
));
}
let rank = u32::from_le_bytes([data[10], data[11], data[12], data[13]]) as usize;
// HDF5 caps dataspace rank at 32 (H5S_MAX_RANK). Reject anything larger so a
// corrupt rank can't drive a huge allocation or read loop.
if rank > 32 {
return Err(FormatError::ChunkedReadError(
"hyperslab selection rank exceeds maximum (32)".into(),
));
}
let mut pos = 14;
let read_coord = |data: &[u8], pos: usize| -> Result<u64, FormatError> {
if pos + enc_size > data.len() {
return Err(FormatError::UnexpectedEof {
expected: pos + enc_size,
available: data.len(),
});
}
let mut v = 0u64;
for (i, &b) in data[pos..pos + enc_size].iter().enumerate() {
v |= (b as u64) << (i * 8);
}
Ok(v)
};
let (mut start, mut stride, mut count, mut block) = (
Vec::with_capacity(rank),
Vec::with_capacity(rank),
Vec::with_capacity(rank),
Vec::with_capacity(rank),
);
for _ in 0..rank {
start.push(read_coord(data, pos)?);
pos += enc_size;
stride.push(read_coord(data, pos)?);
pos += enc_size;
count.push(read_coord(data, pos)?);
pos += enc_size;
block.push(read_coord(data, pos)?);
pos += enc_size;
}
Ok((
Selection::Hyperslab {
start,
stride,
count,
block,
},
pos,
))
}
// ---------------------------------------------------------------------------
@@ -313,4 +571,173 @@ mod tests {
// Chunk [9..10] should not intersect (only row 9, but selection ends at row 8)
assert!(!sel.intersects_chunk(&[9], &[1]));
}
#[test]
fn decode_all_selection_16_bytes() {
let bytes = [3u8, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
let (sel, consumed) = Selection::decode_serialized(&bytes).unwrap();
assert_eq!(sel, Selection::All);
assert_eq!(consumed, 16);
assert_eq!(sel.iter_linear_1d(4).unwrap(), vec![0, 1, 2, 3]);
}
#[test]
fn decode_regular_hyperslab_matches_vds_fixture() {
// Exact virtual selection for src_a in the VDS fixture:
// start=0 stride=1 count=1 block=4, version 3, enc_size 2, rank 1.
let bytes = [
0x02, 0, 0, 0, // type = HYPER
0x03, 0, 0, 0, // version 3
0x01, // flags = regular
0x02, // enc_size = 2
0x01, 0, 0, 0, // rank = 1
0x00, 0x00, // start
0x01, 0x00, // stride
0x01, 0x00, // count
0x04, 0x00, // block
];
let (sel, consumed) = Selection::decode_serialized(&bytes).unwrap();
assert_eq!(consumed, 22);
assert_eq!(
sel,
Selection::Hyperslab {
start: vec![0],
stride: vec![1],
count: vec![1],
block: vec![4],
}
);
assert_eq!(sel.iter_linear_1d(8).unwrap(), vec![0, 1, 2, 3]);
}
#[test]
fn decode_hyperslab_start4() {
let bytes = [
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x01, 0, 0, 0, //
0x04, 0x00, 0x01, 0x00, 0x01, 0x00, 0x04, 0x00,
];
let (sel, _) = Selection::decode_serialized(&bytes).unwrap();
assert_eq!(sel.iter_linear_1d(8).unwrap(), vec![4, 5, 6, 7]);
}
#[test]
fn decode_strided_hyperslab_iter() {
// start=1 stride=3 count=2 block=2 => 1,2, 4,5
let bytes = [
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x01, 0, 0, 0, //
0x01, 0x00, 0x03, 0x00, 0x02, 0x00, 0x02, 0x00,
];
let (sel, _) = Selection::decode_serialized(&bytes).unwrap();
assert_eq!(sel.iter_linear_1d(8).unwrap(), vec![1, 2, 4, 5]);
}
#[test]
fn decode_nd_hyperslab_iter_rejected() {
let bytes = [
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x02, 0, 0, 0, // rank 2
0, 0, 1, 0, 1, 0, 2, 0, 0, 0, 1, 0, 1, 0, 2, 0,
];
let (sel, _) = Selection::decode_serialized(&bytes).unwrap();
assert!(sel.iter_linear_1d(16).is_err());
}
#[test]
fn decode_irregular_hyperslab_rejected() {
let bytes = [0x02u8, 0, 0, 0, 0x03, 0, 0, 0, 0x00, 0x02, 0x01, 0, 0, 0];
assert!(Selection::decode_serialized(&bytes).is_err());
}
#[test]
fn iter_linear_2d_block_row_major() {
// A 2x2 block at the top-left of a 4x4 space => linear 0,1,4,5.
let sel = Selection::Hyperslab {
start: vec![0, 0],
stride: vec![1, 1],
count: vec![1, 1],
block: vec![2, 2],
};
assert_eq!(sel.iter_linear(&[4, 4]).unwrap(), vec![0, 1, 4, 5]);
// The same block shifted to the bottom-right => 10,11,14,15.
let sel2 = Selection::Hyperslab {
start: vec![2, 2],
stride: vec![1, 1],
count: vec![1, 1],
block: vec![2, 2],
};
assert_eq!(sel2.iter_linear(&[4, 4]).unwrap(), vec![10, 11, 14, 15]);
}
#[test]
fn iter_linear_2d_strided() {
// start=(0,0) stride=(2,2) count=(2,2) block=(1,1) over 4x4 =>
// coords (0,0)(0,2)(2,0)(2,2) => linear 0,2,8,10.
let sel = Selection::Hyperslab {
start: vec![0, 0],
stride: vec![2, 2],
count: vec![2, 2],
block: vec![1, 1],
};
assert_eq!(sel.iter_linear(&[4, 4]).unwrap(), vec![0, 2, 8, 10]);
}
#[test]
fn iter_linear_all_2d() {
assert_eq!(
Selection::All.iter_linear(&[2, 3]).unwrap(),
(0..6).collect::<Vec<_>>()
);
}
#[test]
fn iter_linear_rank_mismatch_rejected() {
let sel = Selection::Hyperslab {
start: vec![0],
stride: vec![1],
count: vec![1],
block: vec![2],
};
assert!(sel.iter_linear(&[4, 4]).is_err());
}
// ----- Adversarial / hardening: malformed input must error, never panic -----
#[test]
fn decode_all_truncated_does_not_overrun() {
// ALL claims to consume 16 bytes but only 8 are present.
let bytes = [3u8, 0, 0, 0, 1, 0, 0, 0];
assert!(Selection::decode_serialized(&bytes).is_err());
}
#[test]
fn decode_hyperslab_huge_rank_rejected() {
// rank = 0xFFFFFFFF must not drive a giant allocation.
let bytes = [
0x02u8, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0xFF, 0xFF, 0xFF, 0xFF,
];
assert!(Selection::decode_serialized(&bytes).is_err());
}
#[test]
fn iter_linear_hyperslab_overflow_is_error() {
// start/stride/count near u64::MAX must not panic on multiply/add.
let sel = Selection::Hyperslab {
start: vec![u64::MAX - 1],
stride: vec![u64::MAX],
count: vec![u64::MAX],
block: vec![u64::MAX],
};
assert!(sel.iter_linear(&[100]).is_err());
}
#[test]
fn iter_linear_dims_product_overflow_is_error() {
assert!(Selection::All.iter_linear(&[u64::MAX, u64::MAX]).is_err());
}
#[test]
fn decode_empty_or_short_is_error_not_panic() {
assert!(Selection::decode_serialized(&[]).is_err());
assert!(Selection::decode_serialized(&[2, 0, 0, 0, 3, 0]).is_err());
}
}
+157 -1
View File
@@ -39,6 +39,8 @@ pub struct Superblock {
pub superblock_extension_address: Option<u64>,
/// CRC32C checksum (v2/v3 only).
pub checksum: Option<u32>,
/// Page size for page-buffer mode (v4 only). `None` for v0–v3.
pub page_size: Option<u32>,
}
/// Read an unsigned integer of `size` bytes (LE) from `data` at `pos`.
@@ -125,7 +127,8 @@ impl Superblock {
/// Serialize this superblock to bytes.
///
/// Always writes v2/v3 format. Computes and appends Jenkins lookup3 checksum.
/// Writes v2/v3 format, or v4 (with `page_size`) when `self.version == 4`.
/// Computes and appends Jenkins lookup3 checksum.
pub fn serialize(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(48);
buf.extend_from_slice(&HDF5_SIGNATURE);
@@ -142,6 +145,11 @@ impl Superblock {
Self::write_offset(&mut buf, self.eof_address, self.offset_size);
// root_group_address
Self::write_offset(&mut buf, self.root_group_address, self.offset_size);
// page_size (v4 only)
if self.version >= 4 {
let ps = self.page_size.unwrap_or(0);
buf.extend_from_slice(&ps.to_le_bytes());
}
// checksum
let checksum = crate::checksum::jenkins_lookup3(&buf);
buf.extend_from_slice(&checksum.to_le_bytes());
@@ -179,6 +187,7 @@ impl Superblock {
0 => Self::parse_v0(d),
1 => Self::parse_v1(d),
2 | 3 => Self::parse_v2v3(d, version),
4 => Self::parse_v4(d),
v => Err(FormatError::UnsupportedVersion(v)),
}
}
@@ -235,6 +244,7 @@ impl Superblock {
consistency_flags,
superblock_extension_address: None,
checksum: None,
page_size: None,
})
}
@@ -292,6 +302,7 @@ impl Superblock {
consistency_flags,
superblock_extension_address: None,
checksum: None,
page_size: None,
})
}
@@ -348,6 +359,71 @@ impl Superblock {
consistency_flags,
superblock_extension_address: Some(superblock_extension_address),
checksum: Some(stored_checksum),
page_size: None,
})
}
fn parse_v4(d: &[u8]) -> Result<Superblock, FormatError> {
// Same layout as v2/v3, plus page_size(4) inserted before the checksum.
ensure_len(d, 12)?;
let offset_size = d[9];
let length_size = d[10];
validate_sizes(offset_size, length_size)?;
let consistency_flags = d[11] as u32;
let os = offset_size as usize;
// 4 addresses + page_size(4) + checksum(4)
let total = 12 + 4 * os + 4 + 4;
ensure_len(d, total)?;
let mut pos = 12;
let base_address = read_offset(d, pos, offset_size)?;
pos += os;
let superblock_extension_address = read_offset(d, pos, offset_size)?;
pos += os;
let eof_address = read_offset(d, pos, offset_size)?;
pos += os;
let root_group_address = read_offset(d, pos, offset_size)?;
pos += os;
let page_size = LittleEndian::read_u32(&d[pos..pos + 4]);
pos += 4;
let stored_checksum = LittleEndian::read_u32(&d[pos..pos + 4]);
pos += 4;
#[cfg(feature = "checksum")]
{
let computed = crate::checksum::jenkins_lookup3(&d[..pos - 4]);
if computed != stored_checksum {
return Err(FormatError::ChecksumMismatch {
expected: stored_checksum,
computed,
});
}
}
#[cfg(not(feature = "checksum"))]
{
let _ = pos;
}
Ok(Superblock {
version: 4,
offset_size,
length_size,
base_address,
eof_address,
root_group_address,
group_leaf_node_k: None,
group_internal_node_k: None,
indexed_storage_internal_node_k: None,
free_space_address: None,
driver_info_address: None,
consistency_flags,
superblock_extension_address: Some(superblock_extension_address),
checksum: Some(stored_checksum),
page_size: Some(page_size),
})
}
}
@@ -652,4 +728,84 @@ mod tests {
let new_eof = sb.refresh_eof(&data, 0).unwrap();
assert_eq!(new_eof, old_eof);
}
#[test]
fn parse_v4_with_page_size() {
// Superblock v4 = v2/v3 layout + page_size(4) before checksum.
let mut buf = Vec::new();
buf.extend_from_slice(&HDF5_SIGNATURE);
buf.push(4); // version = 4
buf.push(8); // offset_size
buf.push(8); // length_size
buf.push(0); // consistency_flags
write_offset(&mut buf, 0, 8); // base_address
write_offset(&mut buf, u64::MAX, 8); // superblock_extension_address = UNDEF
write_offset(&mut buf, 512, 8); // eof_address
write_offset(&mut buf, 96, 8); // root_group_address
buf.extend_from_slice(&4096u32.to_le_bytes()); // page_size (v4 addition)
let checksum = crate::checksum::jenkins_lookup3(&buf);
buf.extend_from_slice(&checksum.to_le_bytes());
let sb = Superblock::parse(&buf, 0).unwrap();
assert_eq!(sb.version, 4);
assert_eq!(sb.offset_size, 8);
assert_eq!(sb.eof_address, 512);
assert_eq!(sb.root_group_address, 96);
assert_eq!(sb.page_size, Some(4096));
}
#[test]
fn serialize_v4_roundtrip() {
let sb = Superblock {
version: 4,
offset_size: 8,
length_size: 8,
base_address: 0,
eof_address: 1024,
root_group_address: 96,
group_leaf_node_k: None,
group_internal_node_k: None,
indexed_storage_internal_node_k: None,
free_space_address: None,
driver_info_address: None,
consistency_flags: 0,
superblock_extension_address: Some(u64::MAX),
checksum: None,
page_size: Some(4096),
};
let bytes = sb.serialize();
let parsed = Superblock::parse(&bytes, 0).unwrap();
assert_eq!(parsed.version, 4);
assert_eq!(parsed.page_size, Some(4096));
assert_eq!(parsed.eof_address, 1024);
assert_eq!(parsed.root_group_address, 96);
}
#[test]
fn serialize_v3_unchanged_by_page_size_field() {
// v3 (page_size: None) must serialize identically to before this feature existed.
let sb = Superblock {
version: 3,
offset_size: 8,
length_size: 8,
base_address: 0,
eof_address: 2048,
root_group_address: 96,
group_leaf_node_k: None,
group_internal_node_k: None,
indexed_storage_internal_node_k: None,
free_space_address: None,
driver_info_address: None,
consistency_flags: 0,
superblock_extension_address: Some(u64::MAX),
checksum: None,
page_size: None,
};
let bytes = sb.serialize();
// sig(8) + version/offset/length/flags(4) + 4 addresses(8 each) + checksum(4)
assert_eq!(bytes.len(), 8 + 4 + 4 * 8 + 4);
let parsed = Superblock::parse(&bytes, 0).unwrap();
assert_eq!(parsed.version, 3);
assert_eq!(parsed.page_size, None);
}
}
@@ -7,6 +7,7 @@ use alloc::{boxed::Box, string::String, string::ToString, vec, vec::Vec};
use crate::attribute::AttributeMessage;
use crate::chunked_write::ChunkOptions;
use crate::data_layout::VdsMapping;
use crate::dataspace::{Dataspace, DataspaceType};
use crate::datatype::{
CharacterSet, CompoundMember, Datatype, DatatypeByteOrder, EnumMember, StringPadding,
@@ -362,6 +363,12 @@ pub struct DatasetBuilder {
pub(crate) compact: bool,
/// Per-dataset alignment in bytes (0 = no special alignment).
pub(crate) alignment: usize,
/// Virtual Dataset (VDS) source mappings.
///
/// When set, this dataset uses Virtual Dataset layout (v4 class 3). The
/// `data` field is ignored; instead the global heap blob is built from
/// these mappings and a VDS layout message is emitted.
pub(crate) virtual_sources: Option<Vec<VdsMapping>>,
#[cfg(feature = "provenance")]
pub(crate) provenance: Option<ProvenanceConfig>,
}
@@ -379,6 +386,7 @@ impl DatasetBuilder {
fill_time: FillTime::default(),
compact: false,
alignment: 0,
virtual_sources: None,
#[cfg(feature = "provenance")]
provenance: None,
}
@@ -534,6 +542,11 @@ impl DatasetBuilder {
/// Enable zstd compression at `level` (1-22). HDF5 filter ID 32015.
/// Implies chunked storage. Requires the `zstd` cargo feature.
///
/// **Recommended for write-heavy workloads:** Zstd level 3 encodes at
/// ~500+ MiB/s vs deflate's ~300 MiB/s at the same or better compression
/// ratio (see arXiv 2604.06221). Shuffle is applied automatically before
/// compression; call `.without_shuffle()` to disable it.
pub fn with_zstd(&mut self, level: u32) -> &mut Self {
self.chunk_options.zstd_level = Some(level);
self
@@ -546,12 +559,35 @@ impl DatasetBuilder {
self
}
/// Enable Pcodec lossless numerical compression (clawhdf5 filter ID 32023).
///
/// Pcodec achieves 30–94% better compression ratio than Zstd for f32/f64
/// columns at 1–5 GiB/s decompression speed (arXiv:2502.06112). Requires
/// the `pcodec` cargo feature.
pub fn with_pcodec(&mut self) -> &mut Self {
self.chunk_options.pcodec = true;
self
}
/// Enable shuffle filter (usually combined with deflate or zstd).
/// Note: shuffle is auto-applied before any compression codec by default.
pub fn with_shuffle(&mut self) -> &mut Self {
self.chunk_options.shuffle = true;
self
}
/// Disable the automatic shuffle pre-filter.
///
/// By default, the shuffle filter is applied before any compression codec
/// (deflate, Zstd, LZ4, Pcodec) to improve compression ratios on float/int
/// arrays. Call this to disable it, e.g. for already-shuffled data or when
/// storing byte arrays where shuffle hurts compression.
pub fn without_shuffle(&mut self) -> &mut Self {
self.chunk_options.no_shuffle = true;
self.chunk_options.shuffle = false;
self
}
/// Enable fletcher32 checksum.
pub fn with_fletcher32(&mut self) -> &mut Self {
self.chunk_options.fletcher32 = true;
@@ -586,6 +622,23 @@ impl DatasetBuilder {
self
}
/// Configure this dataset as a Virtual Dataset (VDS).
///
/// The supplied `mappings` list describes each source → virtual region
/// correspondence. The dataset will use HDF5 layout class 3 (Virtual).
/// Any previously set `data` is ignored when virtual sources are present.
///
/// `datatype` and `shape` must still be set via `with_*_data()` or
/// `with_shape()` / `with_f64_data()` etc.; the actual raw bytes are
/// not written for VDS datasets. A non-empty `mappings` list is required;
/// an empty list is silently ignored (no VDS layout is written).
pub fn with_virtual_sources(&mut self, mappings: Vec<VdsMapping>) -> &mut Self {
if !mappings.is_empty() {
self.virtual_sources = Some(mappings);
}
self
}
/// Attach SHINES provenance metadata (SHA-256, creator, timestamp).
///
/// The SHA-256 hash of the raw dataset bytes is computed automatically
@@ -613,6 +666,8 @@ pub struct GroupBuilder {
pub(crate) name: String,
pub(crate) datasets: Vec<DatasetBuilder>,
pub(crate) attrs: Vec<(String, AttrValue)>,
/// (link_name, target_file, target_path)
pub(crate) external_links: Vec<(String, String, String)>,
}
impl GroupBuilder {
@@ -621,6 +676,7 @@ impl GroupBuilder {
name: name.to_string(),
datasets: Vec::new(),
attrs: Vec::new(),
external_links: Vec::new(),
}
}
@@ -633,12 +689,28 @@ impl GroupBuilder {
self.attrs.push((name.to_string(), value));
}
/// Add an external link: a named pointer to an object in another HDF5 file.
pub fn add_external_link(
&mut self,
name: &str,
target_file: &str,
target_path: &str,
) -> &mut Self {
self.external_links.push((
name.to_string(),
target_file.to_string(),
target_path.to_string(),
));
self
}
/// Consume the builder, returning a FinishedGroup to add to FileWriter.
pub fn finish(self) -> FinishedGroup {
FinishedGroup {
name: self.name,
datasets: self.datasets,
attrs: self.attrs,
external_links: self.external_links,
}
}
}
@@ -648,4 +720,6 @@ pub struct FinishedGroup {
pub(crate) name: String,
pub(crate) datasets: Vec<DatasetBuilder>,
pub(crate) attrs: Vec<(String, AttrValue)>,
/// (link_name, target_file, target_path)
pub(crate) external_links: Vec<(String, String, String)>,
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -662,6 +662,209 @@ fn v4_fixed_array_read() {
}
}
#[test]
fn v4_virtual_dataset_same_file_read() {
// A 1-D virtual dataset assembled from two same-file sources:
// virt[0:4] <- src_a[1:5] (partial source hyperslab) => 11,12,13,14
// virt[4:8] <- (unmapped) => fill 0
// virt[8:12] <- src_b[0:4] (ALL) => 20,21,22,23
let file_data = include_bytes!("fixtures/vds_same_file.h5");
let (raw, datatype, _) = read_chunked_dataset(file_data, "virt");
let values = read_as_i32(&raw, &datatype).unwrap();
assert_eq!(
values,
vec![11, 12, 13, 14, 0, 0, 0, 0, 20, 21, 22, 23],
"VDS assembly (partial source slice + fill gap) mismatch"
);
}
#[test]
fn v4_virtual_dataset_2d_same_file_read() {
// A 4x4 virtual dataset assembled from two 2x2 same-file sources placed as
// non-contiguous blocks (exercises N-dimensional row-major scatter):
// virt[0:2,0:2] <- src_a = [[1,2],[3,4]]
// virt[2:4,2:4] <- src_b = [[5,6],[7,8]]
// everything else -> fill 0
let file_data = include_bytes!("fixtures/vds_2d_same_file.h5");
let (raw, datatype, _) = read_chunked_dataset(file_data, "virt");
let values = read_as_i32(&raw, &datatype).unwrap();
assert_eq!(
values,
vec![1, 2, 0, 0, 3, 4, 0, 0, 0, 0, 5, 6, 0, 0, 7, 8],
"2-D VDS block scatter mismatch"
);
}
#[test]
fn scaleoffset_float_escale_reads_as_raw() {
// The scale-offset filter's floating-point *E-scale* mode (cd_values[0] = 1)
// is not actually implemented by the HDF5 library: when asked for it, HDF5
// stores the chunk raw (no minbits/minval header) and sets the chunk filter
// mask to skip the filter. So such a dataset must read back verbatim purely
// by honoring the per-chunk filter mask — no E-scale decoder is needed.
let file_data = include_bytes!("fixtures/scaleoffset_float_escale.h5");
let (raw, datatype, _) = read_chunked_dataset(file_data, "x");
let values = read_as_f64(&raw, &datatype).unwrap();
let expect: Vec<f64> = (0..20).map(|i| i as f64 * 0.25).collect();
assert_eq!(
values, expect,
"E-scale (raw + masked filter) must read verbatim"
);
}
#[test]
fn v4_virtual_dataset_cycle_errors_not_overflow() {
// virt -> virt2 -> virt (both virtual, same file). The reader must reject
// the nested virtual source rather than recurse into a stack overflow.
let file_data = include_bytes!("fixtures/vds_cyclic.h5");
let offset = find_signature(file_data).unwrap();
let sb = Superblock::parse(file_data, offset).unwrap();
let addr = resolve_path_any(file_data, &sb, "virt").unwrap();
let hdr =
ObjectHeader::parse(file_data, addr as usize, sb.offset_size, sb.length_size).unwrap();
let ds = Dataspace::parse(
&hdr.messages
.iter()
.find(|m| m.msg_type == MessageType::Dataspace)
.unwrap()
.data,
sb.length_size,
)
.unwrap();
let (dt, _) = Datatype::parse(
&hdr.messages
.iter()
.find(|m| m.msg_type == MessageType::Datatype)
.unwrap()
.data,
)
.unwrap();
let layout = DataLayout::parse(
&hdr.messages
.iter()
.find(|m| m.msg_type == MessageType::DataLayout)
.unwrap()
.data,
sb.offset_size,
sb.length_size,
)
.unwrap();
let r = read_raw_data_full(
file_data,
&layout,
&ds,
&dt,
None,
sb.offset_size,
sb.length_size,
);
assert!(
r.is_err(),
"cyclic virtual dataset must error, not overflow"
);
}
#[test]
fn v4_virtual_dataset_external_file_read() {
use clawhdf5_format::data_read::read_raw_data_full_with_resolver;
// The virtual file maps virt[0:8] <- (external) ext_src.h5:/data = [10..17].
let virt = include_bytes!("fixtures/vds_external_virt.h5");
let src = include_bytes!("fixtures/vds_external_src.h5").to_vec();
let sig = find_signature(virt).unwrap();
let sb = Superblock::parse(virt, sig).unwrap();
let addr = resolve_path_any(virt, &sb, "virt").unwrap();
let hdr = ObjectHeader::parse(virt, addr as usize, sb.offset_size, sb.length_size).unwrap();
let ds = Dataspace::parse(
&hdr.messages
.iter()
.find(|m| m.msg_type == MessageType::Dataspace)
.unwrap()
.data,
sb.length_size,
)
.unwrap();
let (dt, _) = Datatype::parse(
&hdr.messages
.iter()
.find(|m| m.msg_type == MessageType::Datatype)
.unwrap()
.data,
)
.unwrap();
let layout = DataLayout::parse(
&hdr.messages
.iter()
.find(|m| m.msg_type == MessageType::DataLayout)
.unwrap()
.data,
sb.offset_size,
sb.length_size,
)
.unwrap();
// Resolver supplies the external source file's bytes by its stored name.
let resolver = |name: &str| -> Option<Vec<u8>> {
if name == "ext_src.h5" {
Some(src.clone())
} else {
None
}
};
let raw = read_raw_data_full_with_resolver(
virt,
&layout,
&ds,
&dt,
None,
sb.offset_size,
sb.length_size,
Some(&resolver),
)
.unwrap();
let values = read_as_i32(&raw, &dt).unwrap();
assert_eq!(values, vec![10, 11, 12, 13, 14, 15, 16, 17]);
// With no resolver, an external source is a clean error (not wrong data).
let no_resolver = read_raw_data_full_with_resolver(
virt,
&layout,
&ds,
&dt,
None,
sb.offset_size,
sb.length_size,
None,
);
assert!(no_resolver.is_err());
}
#[test]
fn v4_paged_fixed_array_read() {
// 1025 chunks of 16 int32s, gzip-filtered => Fixed Array index whose data
// block is *paged* (page holds 1024 elements). Page 0 is full, page 1 holds
// the single trailing chunk. Chunk k stores value k at its first element.
let file_data = include_bytes!("fixtures/v4_fixed_array_paged.h5");
let (raw, datatype, _) = read_chunked_dataset(file_data, "big");
let values = read_as_i32(&raw, &datatype).unwrap();
assert_eq!(values.len(), 1025 * 16);
for k in 0..1025usize {
assert_eq!(
values[k * 16],
k as i32,
"chunk-start mismatch at chunk {k}"
);
for j in 1..16 {
assert_eq!(
values[k * 16 + j],
0,
"non-start element nonzero at {}",
k * 16 + j
);
}
}
}
#[test]
fn v4_2d_fixed_array_read() {
let file_data = include_bytes!("fixtures/v4_2d.h5");
+5 -5
View File
@@ -1,9 +1,9 @@
# rustyhdf5-gpu
# clawhdf5-gpu
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-gpu.svg)](https://crates.io/crates/rustyhdf5-gpu)
[![docs.rs](https://docs.rs/rustyhdf5-gpu/badge.svg)](https://docs.rs/rustyhdf5-gpu)
[![crates.io](https://img.shields.io/crates/v/clawhdf5-gpu.svg)](https://crates.io/crates/clawhdf5-gpu)
[![docs.rs](https://docs.rs/clawhdf5-gpu/badge.svg)](https://docs.rs/clawhdf5-gpu)
GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders.
GPU-accelerated vector operations for clawhdf5 using wgpu compute shaders.
## Features
@@ -14,7 +14,7 @@ GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders.
## Usage
```rust
use rustyhdf5_gpu::GpuAccelerator;
use clawhdf5_gpu::GpuAccelerator;
let accel = GpuAccelerator::new().unwrap();
let distances = accel.l2_distances(&query, &vectors).unwrap();
+3
View File
@@ -17,12 +17,15 @@ tokio = { version = "1", features = ["fs", "io-util"], optional = true }
reqwest = { version = "0.12", features = ["json"], optional = true }
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
mpi = { version = "0.8", optional = true }
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
tempfile = "3"
[features]
default = []
mmap = ["memmap2", "libc"]
async = ["tokio"]
hsds = ["reqwest", "serde", "serde_json", "async"]
mpi-io = ["mpi"]
+5 -5
View File
@@ -1,9 +1,9 @@
# rustyhdf5-io
# clawhdf5-io
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-io.svg)](https://crates.io/crates/rustyhdf5-io)
[![docs.rs](https://docs.rs/rustyhdf5-io/badge.svg)](https://docs.rs/rustyhdf5-io)
[![crates.io](https://img.shields.io/crates/v/clawhdf5-io.svg)](https://crates.io/crates/clawhdf5-io)
[![docs.rs](https://docs.rs/clawhdf5-io/badge.svg)](https://docs.rs/clawhdf5-io)
I/O abstraction layer for rustyhdf5.
I/O abstraction layer for clawhdf5.
## Features
@@ -15,7 +15,7 @@ I/O abstraction layer for rustyhdf5.
## Usage
```rust
use rustyhdf5_io::MmapReader;
use clawhdf5_io::MmapReader;
let reader = MmapReader::open("data.h5").unwrap();
```
+26
View File
@@ -231,6 +231,30 @@ impl FileWriter {
pub fn path(&self) -> &std::path::Path {
&self.path
}
/// Write `data` into this writer, taking ownership to avoid a copy.
///
/// Prefer over [`HDF5ReadWrite::write_all_bytes`] when the caller already
/// owns a `Vec<u8>` (e.g., from `FileWriter::finish()`).
pub fn write_bytes_owned(&mut self, data: Vec<u8>) -> io::Result<()> {
self.data = data;
if let Some(ref mut interceptor) = self.interceptor {
let ps = self.page_size as usize;
if ps > 0 {
let mut offset: u64 = 0;
let mut pos = 0usize;
while pos + ps <= self.data.len() {
interceptor.on_page_write(offset, &self.data[pos..pos + ps]);
pos += ps;
offset += ps as u64;
}
if pos < self.data.len() {
interceptor.on_page_write(offset, &self.data[pos..]);
}
}
}
self.flush_to_disk()
}
}
impl HDF5Read for FileWriter {
@@ -281,6 +305,8 @@ pub mod mmap;
#[cfg(feature = "mmap")]
pub use mmap::{MmapReadWrite, MmapReader};
pub mod mpi_vol;
pub use mpi_vol::MpiVol;
pub mod prefetch;
pub mod subfiling;
pub mod sweep;
+510
View File
@@ -0,0 +1,510 @@
//! MPI-IO VOL connector for parallel HDF5 reads and writes.
//!
//! Enable with the `mpi-io` feature: `cargo build --features mpi-io`.
//!
//! # Parallelism model
//!
//! **Read**: rank 0 reads the full file with `std::fs::read`, parses the
//! requested dataset, then broadcasts the raw bytes to all other ranks via
//! MPI broadcast. This is a root-read + broadcast pattern, *not* true
//! collective I/O (`MPI_File_read_at_all`).
//!
//! **Write**: each rank gathers its data shard to rank 0, which stitches
//! the contributions and writes the merged dataset atomically to disk. A
//! barrier ensures all ranks observe the completed file before continuing.
use crate::vol::{VirtualObjectLayer, VolCapability, VolError};
#[cfg(feature = "mpi-io")]
use mpi::traits::*;
/// Rank within the communicator.
type Rank = i32;
/// MPI-IO Virtual Object Layer connector.
///
/// Wraps an MPI communicator for collective HDF5 file I/O.
pub struct MpiVol {
location: Option<String>,
#[cfg(feature = "mpi-io")]
pub universe: mpi::environment::Universe,
#[cfg(not(feature = "mpi-io"))]
_placeholder: (),
}
impl std::fmt::Debug for MpiVol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MpiVol")
.field("location", &self.location)
.finish_non_exhaustive()
}
}
impl MpiVol {
/// Create an `MpiVol` using `MPI_COMM_WORLD`.
///
/// Initializes MPI if not already initialized. Call once per process.
#[cfg(feature = "mpi-io")]
pub fn new_world() -> Result<Self, VolError> {
let universe = mpi::initialize()
.ok_or_else(|| VolError::Unsupported("MPI already finalized or init failed".into()))?;
Ok(Self {
location: None,
universe,
})
}
/// Stub for when the feature is disabled.
#[cfg(not(feature = "mpi-io"))]
pub fn new_world() -> Result<Self, VolError> {
Err(VolError::Unsupported(
"MPI-IO support requires the `mpi-io` feature".into(),
))
}
/// Returns the set of capabilities this VOL connector claims.
///
/// This associated function mirrors the trait method and can be used in
/// tests without constructing a live MPI universe.
pub fn expected_capabilities() -> Vec<VolCapability> {
vec![
VolCapability::ReadData,
VolCapability::WriteData,
VolCapability::ListObjects,
VolCapability::ChunkedStorage,
VolCapability::ParallelIO,
]
}
/// Returns the MPI rank within COMM_WORLD (0-based).
///
/// Returns 0 when MPI is not available.
pub fn rank(&self) -> Rank {
#[cfg(feature = "mpi-io")]
{
self.universe.world().rank()
}
#[cfg(not(feature = "mpi-io"))]
{
0
}
}
/// Returns the total number of MPI processes.
///
/// Returns 1 when MPI is not available.
pub fn size(&self) -> Rank {
#[cfg(feature = "mpi-io")]
{
self.universe.world().size()
}
#[cfg(not(feature = "mpi-io"))]
{
1
}
}
}
#[allow(unused_variables)]
impl VirtualObjectLayer for MpiVol {
fn name(&self) -> &str {
"mpi-io"
}
fn capabilities(&self) -> Vec<VolCapability> {
vec![
VolCapability::ReadData,
VolCapability::WriteData,
VolCapability::ListObjects,
VolCapability::ChunkedStorage,
VolCapability::ParallelIO,
]
}
fn open(&mut self, location: &str) -> Result<(), VolError> {
self.location = Some(location.to_string());
Ok(())
}
fn close(&mut self) -> Result<(), VolError> {
self.location = None;
Ok(())
}
fn read_dataset(&self, path: &str) -> Result<Vec<u8>, VolError> {
let _loc = self.location.as_deref().ok_or_else(|| {
VolError::Io(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"file not open",
))
})?;
#[cfg(feature = "mpi-io")]
{
mpi_collective_read(self, _loc, path)
}
#[cfg(not(feature = "mpi-io"))]
{
Err(VolError::Unsupported("mpi-io feature not enabled".into()))
}
}
fn write_dataset(
&mut self,
path: &str,
data: &[u8],
shape: &[u64],
dtype: &str,
) -> Result<(), VolError> {
let _loc = self.location.as_deref().ok_or_else(|| {
VolError::Io(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"file not open",
))
})?;
#[cfg(feature = "mpi-io")]
{
mpi_collective_write(self, _loc, path, data, shape, dtype)
}
#[cfg(not(feature = "mpi-io"))]
{
Err(VolError::Unsupported("mpi-io feature not enabled".into()))
}
}
}
/// Collective read: root reads the file, broadcasts the target dataset to all ranks.
#[cfg(feature = "mpi-io")]
fn mpi_collective_read(vol: &MpiVol, location: &str, path: &str) -> Result<Vec<u8>, VolError> {
use clawhdf5_format::{
data_layout::DataLayout, data_read::read_raw_data_full, dataspace::Dataspace,
datatype::Datatype, filter_pipeline::FilterPipeline, group_v2::resolve_path_any,
message_type::MessageType, object_header::ObjectHeader, signature::find_signature,
superblock::Superblock,
};
use mpi::traits::*;
let world = vol.universe.world();
let rank = world.rank();
let raw_data: Vec<u8>;
let mut len_buf = [0usize; 1];
if rank == 0 {
let bytes = std::fs::read(location).map_err(VolError::Io)?;
let sig = find_signature(&bytes).map_err(|e| VolError::DataError(e.to_string()))?;
let sb = Superblock::parse(&bytes, sig).map_err(|e| VolError::DataError(e.to_string()))?;
let addr = resolve_path_any(&bytes, &sb, path)
.map_err(|e| VolError::NotFound(format!("{path}: {e}")))?;
let oh = ObjectHeader::parse(&bytes, addr as usize, sb.offset_size, sb.length_size)
.map_err(|e| VolError::DataError(e.to_string()))?;
let dt = oh
.messages
.iter()
.find(|m| m.msg_type == MessageType::Datatype)
.ok_or_else(|| VolError::DataError("no datatype".into()))?;
let (datatype, _) =
Datatype::parse(&dt.data).map_err(|e| VolError::DataError(e.to_string()))?;
let ds = oh
.messages
.iter()
.find(|m| m.msg_type == MessageType::Dataspace)
.ok_or_else(|| VolError::DataError("no dataspace".into()))?;
let dataspace = Dataspace::parse(&ds.data, sb.length_size)
.map_err(|e| VolError::DataError(e.to_string()))?;
let dl = oh
.messages
.iter()
.find(|m| m.msg_type == MessageType::DataLayout)
.ok_or_else(|| VolError::DataError("no data layout".into()))?;
let layout = DataLayout::parse(&dl.data, sb.offset_size, sb.length_size)
.map_err(|e| VolError::DataError(e.to_string()))?;
let pipeline = oh
.messages
.iter()
.find(|m| m.msg_type == MessageType::FilterPipeline)
.and_then(|m| FilterPipeline::parse(&m.data).ok());
raw_data = read_raw_data_full(
&bytes,
&layout,
&dataspace,
&datatype,
pipeline.as_ref(),
sb.offset_size,
sb.length_size,
)
.map_err(|e| VolError::DataError(e.to_string()))?;
len_buf[0] = raw_data.len();
} else {
raw_data = Vec::new();
}
// Broadcast length then data
world.process_at_rank(0).broadcast_into(&mut len_buf);
let mut result = vec![0u8; len_buf[0]];
if rank == 0 {
result.copy_from_slice(&raw_data);
}
world.process_at_rank(0).broadcast_into(&mut result);
Ok(result)
}
/// Collective write: rank 0 accumulates all contributions and writes atomically.
///
/// In a real parallel workload each rank provides its own data shard for a
/// different hyperslab. Here we demonstrate the pattern: all ranks send their
/// data to rank 0 which stitches and writes.
#[cfg(feature = "mpi-io")]
fn mpi_collective_write(
vol: &MpiVol,
location: &str,
path: &str,
data: &[u8],
shape: &[u64],
dtype: &str,
) -> Result<(), VolError> {
use clawhdf5_format::file_writer::FileWriter as FmtWriter;
use mpi::traits::*;
let world = vol.universe.world();
let size = world.size() as usize;
// Each rank sends its data length to root
let local_len = data.len();
let mut all_lens = if world.rank() == 0 {
vec![0usize; size]
} else {
Vec::new()
};
world
.process_at_rank(0)
.gather_into_root(&local_len, &mut all_lens);
// Root collects all contributions and writes
if world.rank() == 0 {
let total: usize = all_lens.iter().sum();
let mut merged = Vec::with_capacity(total);
// Rank 0's own contribution first
merged.extend_from_slice(data);
// Receive from ranks 1..size
for r in 1..size as i32 {
let expected = all_lens[r as usize];
let mut buf = vec![0u8; expected];
world.process_at_rank(r).receive_into(&mut buf);
merged.extend_from_slice(&buf);
}
// Write merged data via FileWriter
let mut fw = FmtWriter::new();
match dtype {
"f64" => {
let values: Vec<f64> = merged
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.collect();
fw.create_dataset(path).with_f64_data(&values);
}
"f32" => {
let values: Vec<f32> = merged
.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.collect();
fw.create_dataset(path).with_f32_data(&values);
}
_ => {
return Err(VolError::Unsupported(format!(
"mpi-io write: unsupported dtype {dtype}"
)));
}
}
let bytes = fw
.finish()
.map_err(|e| VolError::DataError(e.to_string()))?;
std::fs::write(location, &bytes).map_err(VolError::Io)?;
} else {
// Non-root ranks send their data to root
world.process_at_rank(0).send(data);
}
// Barrier: all ranks wait until root finishes writing
world.barrier();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mpi_vol_no_feature_returns_unsupported() {
#[cfg(not(feature = "mpi-io"))]
{
let result = MpiVol::new_world();
assert!(
matches!(result, Err(VolError::Unsupported(_))),
"expected Unsupported error without mpi-io feature"
);
}
#[cfg(feature = "mpi-io")]
{
// With MPI enabled, new_world() may succeed if MPI is installed.
// Just verify it doesn't panic.
let _ = MpiVol::new_world();
}
}
#[test]
fn mpi_vol_capabilities_include_parallel_io() {
let caps = MpiVol::expected_capabilities();
assert!(
caps.contains(&VolCapability::ParallelIO),
"expected ParallelIO in {caps:?}"
);
assert!(caps.contains(&VolCapability::ReadData));
assert!(caps.contains(&VolCapability::WriteData));
}
#[test]
fn no_feature_error_contains_feature_name() {
#[cfg(not(feature = "mpi-io"))]
{
let e = MpiVol::new_world().unwrap_err();
assert!(
e.to_string().contains("mpi-io"),
"error should mention 'mpi-io': {e}"
);
}
#[cfg(feature = "mpi-io")]
{
// With mpi-io enabled this test is vacuous; the feature-off path
// is what we're documenting.
}
}
#[test]
#[cfg(feature = "mpi-io")]
fn collective_read_all_ranks_get_same_data() {
use crate::vol::VirtualObjectLayer;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("test.h5");
{
use clawhdf5_format::file_writer::FileWriter as FmtWriter;
let mut fw = FmtWriter::new();
fw.create_dataset("temperature")
.with_f64_data(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let bytes = fw.finish().unwrap();
std::fs::write(&path, &bytes).unwrap();
}
let mut vol = MpiVol::new_world().expect("MPI init failed");
vol.open(path.to_str().unwrap()).unwrap();
let data = vol.read_dataset("temperature").unwrap();
assert_eq!(
data.len(),
40,
"rank {} got {} bytes",
vol.rank(),
data.len()
);
let values: Vec<f64> = data
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.collect();
assert_eq!(
values,
vec![1.0, 2.0, 3.0, 4.0, 5.0],
"rank {} got wrong data",
vol.rank()
);
}
#[test]
#[cfg(feature = "mpi-io")]
fn collective_write_assembles_all_shards() {
use crate::vol::VirtualObjectLayer;
use mpi::traits::*;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("parallel_out.h5");
let mut vol = MpiVol::new_world().expect("MPI init failed");
vol.open(path.to_str().unwrap()).unwrap();
let world = vol.universe.world();
let rank = world.rank() as usize;
let shard = ((rank as f64) * 10.0f64).to_le_bytes().to_vec();
vol.write_dataset("values", &shard, &[world.size() as u64], "f64")
.unwrap();
let total_size = world.size() as usize;
if rank == 0 {
let bytes = std::fs::read(&path).unwrap();
use clawhdf5_format::{
data_layout::DataLayout, data_read::read_raw_data_full, dataspace::Dataspace,
datatype::Datatype, group_v2::resolve_path_any, message_type::MessageType,
object_header::ObjectHeader, signature::find_signature, superblock::Superblock,
};
let sig = find_signature(&bytes).unwrap();
let sb = Superblock::parse(&bytes, sig).unwrap();
let addr = resolve_path_any(&bytes, &sb, "values").unwrap();
let oh =
ObjectHeader::parse(&bytes, addr as usize, sb.offset_size, sb.length_size).unwrap();
let (dt, _) = Datatype::parse(
&oh.messages
.iter()
.find(|m| m.msg_type == MessageType::Datatype)
.unwrap()
.data,
)
.unwrap();
let ds = Dataspace::parse(
&oh.messages
.iter()
.find(|m| m.msg_type == MessageType::Dataspace)
.unwrap()
.data,
sb.length_size,
)
.unwrap();
let dl = DataLayout::parse(
&oh.messages
.iter()
.find(|m| m.msg_type == MessageType::DataLayout)
.unwrap()
.data,
sb.offset_size,
sb.length_size,
)
.unwrap();
let raw =
read_raw_data_full(&bytes, &dl, &ds, &dt, None, sb.offset_size, sb.length_size)
.unwrap();
assert_eq!(
raw.len(),
total_size * 8,
"expected {} f64 values",
total_size
);
let values: Vec<f64> = raw
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.collect();
for (i, &v) in values.iter().enumerate() {
assert!(
(v - (i as f64 * 10.0)).abs() < 1e-9,
"rank {i} shard wrong: got {v}"
);
}
}
world.barrier();
}
}
+6 -6
View File
@@ -1,22 +1,22 @@
# edgehdf5-migrate
# clawhdf5-migrate
[![crates.io](https://img.shields.io/crates/v/edgehdf5-migrate.svg)](https://crates.io/crates/edgehdf5-migrate)
[![docs.rs](https://img.shields.io/docsrs/edgehdf5-migrate)](https://docs.rs/edgehdf5-migrate)
[![crates.io](https://img.shields.io/crates/v/clawhdf5-migrate.svg)](https://crates.io/crates/clawhdf5-migrate)
[![docs.rs](https://img.shields.io/docsrs/clawhdf5-migrate)](https://docs.rs/clawhdf5-migrate)
CLI tool to migrate SQLite agent memory databases to HDF5 format.
Converts existing SQLite-based agent memory stores (embeddings, text chunks, metadata) into the HDF5 format used by [edgehdf5-memory](https://crates.io/crates/edgehdf5-memory).
Converts existing SQLite-based agent memory stores (embeddings, text chunks, metadata) into the HDF5 format used by [clawhdf5-agent](https://crates.io/crates/clawhdf5-agent).
## Installation
```bash
cargo install edgehdf5-migrate
cargo install clawhdf5-migrate
```
## Usage
```bash
edgehdf5-migrate --input agent.db --output agent.h5
clawhdf5-migrate --input agent.db --output agent.h5
```
## License
+159
View File
@@ -0,0 +1,159 @@
//! Read a migration HDF5 file back into the in-memory data model.
//!
//! Used to verify migrated content (real validation) and to merge new rows into
//! an existing output (incremental migration). Mirrors the layout produced by
//! [`crate::hdf5_writer`].
use clawhdf5::reader::{File, Group};
use clawhdf5_format::type_builders::AttrValue;
use crate::sqlite_reader::{Entity, MemoryChunk, Relation, Session, SqliteData};
type BoxErr = Box<dyn std::error::Error>;
fn read_strings(group: &Group<'_>, name: &str) -> Result<Vec<String>, BoxErr> {
Ok(group.dataset(name)?.read_string()?)
}
fn read_i64s(group: &Group<'_>, name: &str) -> Result<Vec<i64>, BoxErr> {
Ok(group.dataset(name)?.read_i64()?)
}
fn read_f64s(group: &Group<'_>, name: &str) -> Result<Vec<f64>, BoxErr> {
Ok(group.dataset(name)?.read_f64()?)
}
/// Read the embeddings dataset as a flat `Vec<f32>` of `n * dim` values,
/// handling both f32 and (lossy) f16 storage.
fn read_embeddings_flat(group: &Group<'_>) -> Result<Vec<f32>, BoxErr> {
Ok(group.dataset("embeddings")?.read_f32()?)
}
/// Read a migration HDF5 file into a [`SqliteData`].
pub fn read_hdf5(path: &str) -> Result<SqliteData, BoxErr> {
let file = File::open(path)?;
let embedding_dim = match file.root().attrs()?.get("embedding_dim") {
Some(AttrValue::I64(d)) => *d as usize,
_ => 0,
};
let chunks = read_chunks(&file, embedding_dim)?;
let sessions = read_sessions(&file)?;
let entities = read_entities(&file)?;
let relations = read_relations(&file)?;
Ok(SqliteData {
chunks,
sessions,
entities,
relations,
embedding_dim,
})
}
fn read_chunks(file: &File, dim: usize) -> Result<Vec<MemoryChunk>, BoxErr> {
let g = file.group("chunks")?;
let count = group_count(&g)?;
if count == 0 {
return Ok(Vec::new());
}
let ids = read_i64s(&g, "id")?;
let texts = read_strings(&g, "text")?;
let channels = read_strings(&g, "source_channel")?;
let timestamps = read_f64s(&g, "timestamp")?;
let session_ids = read_strings(&g, "session_id")?;
let tags = read_strings(&g, "tags")?;
let deleted = g.dataset("deleted")?.read_i32()?;
let emb_flat = read_embeddings_flat(&g)?;
let dim = dim.max(1);
let mut chunks = Vec::with_capacity(ids.len());
for (i, &id) in ids.iter().enumerate() {
let embedding = emb_flat
.get(i * dim..(i + 1) * dim)
.map(|s| s.to_vec())
.unwrap_or_default();
chunks.push(MemoryChunk {
id,
chunk: texts.get(i).cloned().unwrap_or_default(),
embedding,
source_channel: channels.get(i).cloned().unwrap_or_default(),
timestamp: timestamps.get(i).copied().unwrap_or(0.0),
session_id: session_ids.get(i).cloned().unwrap_or_default(),
tags: tags.get(i).cloned().unwrap_or_default(),
deleted: deleted.get(i).copied().unwrap_or(0),
});
}
Ok(chunks)
}
fn read_sessions(file: &File) -> Result<Vec<Session>, BoxErr> {
let g = file.group("sessions")?;
if group_count(&g)? == 0 {
return Ok(Vec::new());
}
let ids = read_strings(&g, "id")?;
let starts = read_i64s(&g, "start_idx")?;
let ends = read_i64s(&g, "end_idx")?;
let channels = read_strings(&g, "channel")?;
let timestamps = read_f64s(&g, "timestamp")?;
let summaries = read_strings(&g, "summary")?;
Ok((0..ids.len())
.map(|i| Session {
id: ids[i].clone(),
start_idx: starts.get(i).copied().unwrap_or(0),
end_idx: ends.get(i).copied().unwrap_or(0),
channel: channels.get(i).cloned().unwrap_or_default(),
timestamp: timestamps.get(i).copied().unwrap_or(0.0),
summary: summaries.get(i).cloned().unwrap_or_default(),
})
.collect())
}
fn read_entities(file: &File) -> Result<Vec<Entity>, BoxErr> {
let g = file.group("entities")?;
if group_count(&g)? == 0 {
return Ok(Vec::new());
}
let ids = read_i64s(&g, "id")?;
let names = read_strings(&g, "name")?;
let types = read_strings(&g, "type")?;
let emb_idxs = read_i64s(&g, "embedding_idx")?;
Ok((0..ids.len())
.map(|i| Entity {
id: ids[i],
name: names.get(i).cloned().unwrap_or_default(),
entity_type: types.get(i).cloned().unwrap_or_default(),
embedding_idx: emb_idxs.get(i).copied().unwrap_or(-1),
})
.collect())
}
fn read_relations(file: &File) -> Result<Vec<Relation>, BoxErr> {
let g = file.group("relations")?;
if group_count(&g)? == 0 {
return Ok(Vec::new());
}
let srcs = read_i64s(&g, "src")?;
let tgts = read_i64s(&g, "tgt")?;
let rels = read_strings(&g, "relation")?;
let weights = read_f64s(&g, "weight")?;
let timestamps = read_f64s(&g, "timestamp")?;
Ok((0..srcs.len())
.map(|i| Relation {
src: srcs[i],
tgt: tgts.get(i).copied().unwrap_or(0),
relation: rels.get(i).cloned().unwrap_or_default(),
weight: weights.get(i).copied().unwrap_or(1.0),
timestamp: timestamps.get(i).copied().unwrap_or(0.0),
})
.collect())
}
fn group_count(group: &Group<'_>) -> Result<u64, BoxErr> {
match group.attrs()?.get("count") {
Some(AttrValue::I64(n)) => Ok(*n as u64),
_ => Ok(0),
}
}
+288 -69
View File
@@ -1,9 +1,12 @@
mod hdf5_reader;
mod hdf5_writer;
mod sqlite_reader;
mod validate;
use clap::Parser;
use sqlite_reader::SchemaConfig;
/// Migrate ZeroClaw agent memory from SQLite to HDF5 format.
#[derive(Parser, Debug)]
#[command(name = "clawhdf5-migrate", version, about)]
@@ -48,45 +51,126 @@ struct Cli {
#[arg(long)]
dry_run: bool,
/// Content-check every migrated row (default: a representative sample)
#[arg(long)]
validate_full: bool,
/// Append only rows newer than the existing output (by chunk id), merging
/// into the file at --hdf5 if it exists
#[arg(long)]
incremental: bool,
/// Override the SQLite table name for memory chunks
#[arg(long)]
chunks_table: Option<String>,
/// Override the SQLite table name for sessions
#[arg(long)]
sessions_table: Option<String>,
/// Override the SQLite table name for entities
#[arg(long)]
entities_table: Option<String>,
/// Override the SQLite table name for relations
#[arg(long)]
relations_table: Option<String>,
/// Print progress
#[arg(long)]
verbose: bool,
}
/// Build the schema config from CLI table-name overrides (defaults otherwise).
fn schema_from_cli(cli: &Cli) -> SchemaConfig {
let mut c = SchemaConfig::default();
if let Some(t) = &cli.chunks_table {
c.chunks.table = t.clone();
}
if let Some(t) = &cli.sessions_table {
c.sessions.table = t.clone();
}
if let Some(t) = &cli.entities_table {
c.entities.table = t.clone();
}
if let Some(t) = &cli.relations_table {
c.relations.table = t.clone();
}
c
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let schema = schema_from_cli(&cli);
// Dry run: a fast count-only pass that does not buffer the database.
if cli.dry_run {
let counts = sqlite_reader::read_counts(&cli.sqlite, cli.skip_deleted, &schema)?;
eprintln!("Dry run — no output file written.");
eprintln!(
"Would migrate: {} chunks, {} sessions, {} entities, {} relations",
counts.chunks, counts.sessions, counts.entities, counts.relations
);
return Ok(());
}
if cli.verbose {
eprintln!("Reading SQLite database: {}", cli.sqlite);
}
let data = sqlite_reader::read_sqlite(&cli.sqlite, cli.skip_deleted, cli.embedding_dim)?;
// Incremental: merge new rows into the existing output (if present).
let incremental_base = if cli.incremental && std::path::Path::new(&cli.hdf5).exists() {
Some(hdf5_reader::read_hdf5(&cli.hdf5)?)
} else {
None
};
let min_chunk_id = incremental_base
.as_ref()
.map(|d| d.chunks.iter().map(|c| c.id).max().unwrap_or(0))
.unwrap_or(0);
let dim_hint = cli
.embedding_dim
.or_else(|| incremental_base.as_ref().map(|d| d.embedding_dim));
let source = if min_chunk_id > 0 {
sqlite_reader::read_sqlite_filtered(
&cli.sqlite,
cli.skip_deleted,
dim_hint,
&schema,
min_chunk_id,
)?
} else {
sqlite_reader::read_sqlite(&cli.sqlite, cli.skip_deleted, dim_hint, &schema)?
};
// Build the dataset to write: either the source alone, or the existing
// output plus the newly-read rows (metadata groups refreshed from source).
let data = match incremental_base {
Some(mut base) => {
let added = source.chunks.len();
base.chunks.extend(source.chunks);
base.sessions = source.sessions;
base.entities = source.entities;
base.relations = source.relations;
base.embedding_dim = source.embedding_dim.max(base.embedding_dim);
if cli.verbose {
eprintln!("Incremental: appended {added} new chunks (id > {min_chunk_id})");
}
base
}
None => source,
};
if cli.verbose {
eprintln!(
"Read {} chunks, {} sessions, {} entities, {} relations",
data.chunks.len(),
data.sessions.len(),
data.entities.len(),
data.relations.len()
);
eprintln!("Embedding dimension: {}", data.embedding_dim);
}
if cli.dry_run {
eprintln!("Dry run — no output file written.");
eprintln!(
"Would migrate: {} chunks, {} sessions, {} entities, {} relations (dim={})",
"Migrating {} chunks, {} sessions, {} entities, {} relations (dim={})",
data.chunks.len(),
data.sessions.len(),
data.entities.len(),
data.relations.len(),
data.embedding_dim
);
return Ok(());
}
if cli.verbose {
eprintln!("Writing HDF5 file: {}", cli.hdf5);
}
@@ -101,25 +185,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
hdf5_writer::write_hdf5(&cli.hdf5, &data, &opts)?;
if cli.verbose {
eprintln!("Validating output...");
eprintln!("Validating output (content check)...");
}
let summary = validate::validate_hdf5(
&cli.hdf5,
data.chunks.len(),
data.sessions.len(),
data.entities.len(),
data.relations.len(),
data.embedding_dim,
)?;
let summary = validate::validate_hdf5(&cli.hdf5, &data, cli.validate_full, cli.float16)?;
eprintln!(
"Migration complete: {} chunks, {} sessions, {} entities, {} relations (dim={})",
"Migration complete: {} chunks, {} sessions, {} entities, {} relations (dim={}); {} rows content-verified",
summary.chunks,
summary.sessions,
summary.entities,
summary.relations,
summary.embedding_dim
summary.embedding_dim,
summary.rows_checked,
);
Ok(())
@@ -231,7 +309,8 @@ mod tests {
insert_relation(&conn, 1, 1, "self");
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "test-agent".into(),
embedder: "test-embed".into(),
@@ -241,7 +320,8 @@ mod tests {
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 2, 1, 1, 1, 8).unwrap();
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 2);
assert_eq!(summary.sessions, 1);
assert_eq!(summary.entities, 1);
@@ -262,7 +342,8 @@ mod tests {
insert_chunk(&conn, 3, "also active", &make_embedding(4, 3.0), 0);
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, true, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, true, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 2);
let opts = hdf5_writer::WriteOptions {
@@ -274,7 +355,8 @@ mod tests {
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 2, 0, 0, 0, 4).unwrap();
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 2);
}
@@ -289,7 +371,8 @@ mod tests {
insert_chunk(&conn, 2, "deleted", &make_embedding(4, 2.0), 1);
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 2);
}
@@ -303,7 +386,8 @@ mod tests {
insert_chunk(&conn, 1, "test", &make_embedding(16, 0.5), 0);
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.embedding_dim, 16);
}
@@ -317,7 +401,8 @@ mod tests {
insert_chunk(&conn, 1, "test", &make_embedding(16, 0.5), 0);
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, Some(8)).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, Some(8), &SchemaConfig::default()).unwrap();
assert_eq!(data.embedding_dim, 8);
// Embedding truncated to dim 8
assert_eq!(data.chunks[0].embedding.len(), 8);
@@ -335,7 +420,8 @@ mod tests {
insert_chunk(&conn, 1, "test", &emb, 0);
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
@@ -345,8 +431,9 @@ mod tests {
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
// Verify file was created and is valid
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 1, 0, 0, 0, 4).unwrap();
// Content-validate with the float16 tolerance enabled.
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, true, true).unwrap();
assert_eq!(summary.chunks, 1);
// Verify float16 values are within tolerance
@@ -375,7 +462,8 @@ mod tests {
}
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts_compressed = hdf5_writer::WriteOptions {
agent_id: "t".into(),
@@ -415,7 +503,8 @@ mod tests {
drop(conn);
// Simulate dry-run: read data but don't write
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 1);
assert!(!h5_path.exists());
}
@@ -427,7 +516,8 @@ mod tests {
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 0);
assert_eq!(data.sessions.len(), 0);
assert_eq!(data.entities.len(), 0);
@@ -442,7 +532,8 @@ mod tests {
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 0, 0, 0, 0, 0).unwrap();
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 0);
}
@@ -465,7 +556,8 @@ mod tests {
}
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 1000);
let opts = hdf5_writer::WriteOptions {
@@ -478,7 +570,7 @@ mod tests {
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), 1000, 0, 0, 0, 64).unwrap();
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 1000);
}
@@ -495,7 +587,8 @@ mod tests {
insert_session(&conn, "session-gamma", 21, 30);
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.sessions.len(), 3);
let opts = hdf5_writer::WriteOptions {
@@ -507,7 +600,8 @@ mod tests {
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 0, 3, 0, 0, 0).unwrap();
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.sessions, 3);
}
@@ -527,7 +621,8 @@ mod tests {
insert_relation(&conn, 2, 3, "uses");
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.entities.len(), 3);
assert_eq!(data.relations.len(), 3);
@@ -540,7 +635,8 @@ mod tests {
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 0, 0, 3, 3, 0).unwrap();
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.entities, 3);
assert_eq!(summary.relations, 3);
}
@@ -556,7 +652,8 @@ mod tests {
insert_chunk(&conn, 1, "test", &make_embedding(4, 1.0), 0);
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
@@ -566,15 +663,15 @@ mod tests {
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
// Expect 5 chunks but only 1 was written
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), 5, 0, 0, 0, 4);
// Validating against a source with an extra (unwritten) chunk must fail.
let mut bigger =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let mut extra = bigger.chunks[0].clone();
extra.id = 999;
bigger.chunks.push(extra);
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), &bigger, false, false);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Chunk count mismatch")
);
assert!(result.unwrap_err().to_string().contains("count mismatch"));
}
// ---------- Test 14: Metadata attributes are stored ----------
@@ -588,7 +685,8 @@ mod tests {
insert_chunk(&conn, 1, "test", &make_embedding(8, 1.0), 0);
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "my-agent-42".into(),
embedder: "openai-ada".into(),
@@ -634,7 +732,8 @@ mod tests {
insert_chunk(&conn, 1, "test", &emb, 0);
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
@@ -680,7 +779,8 @@ mod tests {
drop(conn);
// Skip deleted
let data = sqlite_reader::read_sqlite(&db_path, true, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, true, None, &SchemaConfig::default()).unwrap();
assert_eq!(data.chunks.len(), 4); // chunk 3 is deleted
let opts = hdf5_writer::WriteOptions {
@@ -692,7 +792,8 @@ mod tests {
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let summary = validate::validate_hdf5(h5_path.to_str().unwrap(), 4, 2, 2, 1, 16).unwrap();
let summary =
validate::validate_hdf5(h5_path.to_str().unwrap(), &data, false, false).unwrap();
assert_eq!(summary.chunks, 4);
assert_eq!(summary.sessions, 2);
assert_eq!(summary.entities, 2);
@@ -711,7 +812,8 @@ mod tests {
insert_session(&conn, "s1", 0, 10);
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None).unwrap();
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
@@ -721,13 +823,130 @@ mod tests {
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), 0, 99, 0, 0, 0);
// Validating against a source whose session content differs must fail.
let mut tampered =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
tampered.sessions[0].summary = "DIFFERENT".into();
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), &tampered, false, false);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Session count mismatch")
assert!(result.unwrap_err().to_string().contains("session"));
}
// ---------- Real content validation catches corrupt embeddings ----------
#[test]
fn test_content_validation_catches_embedding_corruption() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 1, "hello", &make_embedding(8, 1.0), 0);
drop(conn);
let data =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
// A source whose embedding differs (but counts match) must fail validation.
let mut tampered =
sqlite_reader::read_sqlite(&db_path, false, None, &SchemaConfig::default()).unwrap();
tampered.chunks[0].embedding[3] += 9.0;
let result = validate::validate_hdf5(h5_path.to_str().unwrap(), &tampered, true, false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("embedding"));
}
// ---------- Configurable schema: custom table names ----------
#[test]
fn test_configurable_table_names() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("custom.db");
let path_str = db_path.to_str().unwrap().to_string();
let conn = Connection::open(&path_str).unwrap();
// Chunks live in a differently-named table; the others use defaults.
conn.execute_batch(
"CREATE TABLE my_chunks (
id INTEGER PRIMARY KEY, chunk TEXT, embedding BLOB,
source_channel TEXT, timestamp REAL, session_id TEXT, tags TEXT, deleted INTEGER
);
CREATE TABLE sessions (id TEXT, start_idx INTEGER, end_idx INTEGER, channel TEXT, timestamp REAL, summary TEXT);
CREATE TABLE entities (id INTEGER, name TEXT, type TEXT, embedding_idx INTEGER);
CREATE TABLE relations (src INTEGER, tgt INTEGER, relation TEXT, weight REAL, timestamp REAL);",
)
.unwrap();
let blob: Vec<u8> = make_embedding(4, 1.0)
.iter()
.flat_map(|v| v.to_le_bytes())
.collect();
conn.execute(
"INSERT INTO my_chunks VALUES (1, 'hi', ?1, 'api', 1.0, 's', '', 0)",
rusqlite::params![blob],
)
.unwrap();
drop(conn);
let mut schema = SchemaConfig::default();
schema.chunks.table = "my_chunks".into();
let data = sqlite_reader::read_sqlite(&path_str, false, None, &schema).unwrap();
assert_eq!(data.chunks.len(), 1);
assert_eq!(data.chunks[0].chunk, "hi");
assert_eq!(data.embedding_dim, 4);
// Counts pass should also honor the custom table name.
let counts = sqlite_reader::read_counts(&path_str, false, &schema).unwrap();
assert_eq!(counts.chunks, 1);
}
// ---------- Incremental migration appends only new rows ----------
#[test]
fn test_incremental_migration() {
let dir = TempDir::new().unwrap();
let db_path = create_test_db(&dir);
let h5_path = dir.path().join("out.h5");
let cfg = SchemaConfig::default();
let opts = hdf5_writer::WriteOptions {
agent_id: "t".into(),
embedder: "t".into(),
compression: false,
compression_level: 4,
float16: false,
};
// First migration: 2 chunks.
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 1, "one", &make_embedding(4, 1.0), 0);
insert_chunk(&conn, 2, "two", &make_embedding(4, 2.0), 0);
drop(conn);
let data = sqlite_reader::read_sqlite(&db_path, false, None, &cfg).unwrap();
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &data, &opts).unwrap();
// Add two more rows, then migrate incrementally.
let conn = Connection::open(&db_path).unwrap();
insert_chunk(&conn, 3, "three", &make_embedding(4, 3.0), 0);
insert_chunk(&conn, 4, "four", &make_embedding(4, 4.0), 0);
drop(conn);
let base = hdf5_reader::read_hdf5(h5_path.to_str().unwrap()).unwrap();
let max_id = base.chunks.iter().map(|c| c.id).max().unwrap_or(0);
assert_eq!(max_id, 2);
let new =
sqlite_reader::read_sqlite_filtered(&db_path, false, Some(4), &cfg, max_id).unwrap();
assert_eq!(new.chunks.len(), 2); // only id 3 and 4
let mut merged = base;
merged.chunks.extend(new.chunks);
hdf5_writer::write_hdf5(h5_path.to_str().unwrap(), &merged, &opts).unwrap();
let final_data = hdf5_reader::read_hdf5(h5_path.to_str().unwrap()).unwrap();
assert_eq!(final_data.chunks.len(), 4);
let texts: Vec<&str> = final_data.chunks.iter().map(|c| c.chunk.as_str()).collect();
assert_eq!(texts, vec!["one", "two", "three", "four"]);
}
}
+155 -20
View File
@@ -53,9 +53,122 @@ pub struct SqliteData {
pub embedding_dim: usize,
}
/// A table name plus the ordered column names the reader maps by position.
#[derive(Debug, Clone)]
pub struct TableSchema {
pub table: String,
pub columns: Vec<&'static str>,
}
/// Configurable mapping from a SQLite layout to the migration's data model.
///
/// Defaults to the ZeroClaw schema; the CLI can override the table names so the
/// tool can migrate databases whose tables are named differently. Column names
/// (and order) are part of the config too, so a library caller can remap them.
#[derive(Debug, Clone)]
pub struct SchemaConfig {
pub chunks: TableSchema,
pub sessions: TableSchema,
pub entities: TableSchema,
pub relations: TableSchema,
}
impl Default for SchemaConfig {
fn default() -> Self {
SchemaConfig {
chunks: TableSchema {
table: "memory_chunks".into(),
columns: vec![
"id",
"chunk",
"embedding",
"source_channel",
"timestamp",
"session_id",
"tags",
"deleted",
],
},
sessions: TableSchema {
table: "sessions".into(),
columns: vec![
"id",
"start_idx",
"end_idx",
"channel",
"timestamp",
"summary",
],
},
entities: TableSchema {
table: "entities".into(),
columns: vec!["id", "name", "type", "embedding_idx"],
},
relations: TableSchema {
table: "relations".into(),
columns: vec!["src", "tgt", "relation", "weight", "timestamp"],
},
}
}
}
impl TableSchema {
fn select(&self, where_clause: &str) -> String {
format!(
"SELECT {} FROM {}{}",
self.columns.join(", "),
self.table,
where_clause
)
}
}
/// Row counts for each table — a fast pass that does not load row contents.
/// Used for `--dry-run` and progress without buffering the whole database.
#[derive(Debug, Default, Clone, Copy)]
pub struct RowCounts {
pub chunks: u64,
pub sessions: u64,
pub entities: u64,
pub relations: u64,
}
fn count_rows(conn: &Connection, table: &str, where_clause: &str) -> SqlResult<u64> {
conn.query_row(
&format!("SELECT COUNT(*) FROM {table}{where_clause}"),
[],
|r| r.get(0),
)
}
/// Count rows in each table without reading their contents.
pub fn read_counts(
path: &str,
skip_deleted: bool,
config: &SchemaConfig,
) -> Result<RowCounts, Box<dyn std::error::Error>> {
let conn = Connection::open(path)?;
let deleted_col = config.chunks.columns.get(7).copied().unwrap_or("deleted");
let chunk_where = if skip_deleted {
format!(" WHERE {deleted_col} = 0")
} else {
String::new()
};
Ok(RowCounts {
chunks: count_rows(&conn, &config.chunks.table, &chunk_where)?,
sessions: count_rows(&conn, &config.sessions.table, "")?,
entities: count_rows(&conn, &config.entities.table, "")?,
relations: count_rows(&conn, &config.relations.table, "")?,
})
}
/// Auto-detect embedding dimension from the first chunk's BLOB size.
fn detect_embedding_dim(conn: &Connection) -> SqlResult<Option<usize>> {
let mut stmt = conn.prepare("SELECT embedding FROM memory_chunks LIMIT 1")?;
fn detect_embedding_dim(conn: &Connection, config: &SchemaConfig) -> SqlResult<Option<usize>> {
let emb_col = config.chunks.columns.get(2).copied().unwrap_or("embedding");
let mut stmt = conn.prepare(&format!(
"SELECT {emb_col} FROM {} LIMIT 1",
config.chunks.table
))?;
let mut rows = stmt.query([])?;
if let Some(row) = rows.next()? {
let blob: Vec<u8> = row.get(0)?;
@@ -80,18 +193,31 @@ pub fn read_sqlite(
path: &str,
skip_deleted: bool,
embedding_dim: Option<usize>,
config: &SchemaConfig,
) -> Result<SqliteData, Box<dyn std::error::Error>> {
read_sqlite_filtered(path, skip_deleted, embedding_dim, config, 0)
}
/// Like [`read_sqlite`] but only reads chunks whose id is greater than
/// `min_chunk_id` (0 = all). Used for incremental migration.
pub fn read_sqlite_filtered(
path: &str,
skip_deleted: bool,
embedding_dim: Option<usize>,
config: &SchemaConfig,
min_chunk_id: i64,
) -> Result<SqliteData, Box<dyn std::error::Error>> {
let conn = Connection::open(path)?;
let dim = match embedding_dim {
Some(d) => d,
None => detect_embedding_dim(&conn)?.unwrap_or(0),
None => detect_embedding_dim(&conn, config)?.unwrap_or(0),
};
let chunks = read_chunks(&conn, skip_deleted, dim)?;
let sessions = read_sessions(&conn)?;
let entities = read_entities(&conn)?;
let relations = read_relations(&conn)?;
let chunks = read_chunks(&conn, skip_deleted, dim, config, min_chunk_id)?;
let sessions = read_sessions(&conn, config)?;
let entities = read_entities(&conn, config)?;
let relations = read_relations(&conn, config)?;
Ok(SqliteData {
chunks,
@@ -106,16 +232,26 @@ fn read_chunks(
conn: &Connection,
skip_deleted: bool,
expected_dim: usize,
config: &SchemaConfig,
min_chunk_id: i64,
) -> SqlResult<Vec<MemoryChunk>> {
let sql = if skip_deleted {
"SELECT id, chunk, embedding, source_channel, timestamp, session_id, tags, deleted \
FROM memory_chunks WHERE deleted = 0"
let id_col = config.chunks.columns.first().copied().unwrap_or("id");
let deleted_col = config.chunks.columns.get(7).copied().unwrap_or("deleted");
let mut conds = Vec::new();
if skip_deleted {
conds.push(format!("{deleted_col} = 0"));
}
if min_chunk_id > 0 {
conds.push(format!("{id_col} > {min_chunk_id}"));
}
let where_clause = if conds.is_empty() {
String::new()
} else {
"SELECT id, chunk, embedding, source_channel, timestamp, session_id, tags, deleted \
FROM memory_chunks"
format!(" WHERE {}", conds.join(" AND "))
};
let sql = config.chunks.select(&where_clause);
let mut stmt = conn.prepare(sql)?;
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], |row| {
let blob: Vec<u8> = row.get(2)?;
let mut embedding = blob_to_f32(&blob);
@@ -140,9 +276,8 @@ fn read_chunks(
rows.collect()
}
fn read_sessions(conn: &Connection) -> SqlResult<Vec<Session>> {
let mut stmt =
conn.prepare("SELECT id, start_idx, end_idx, channel, timestamp, summary FROM sessions")?;
fn read_sessions(conn: &Connection, config: &SchemaConfig) -> SqlResult<Vec<Session>> {
let mut stmt = conn.prepare(&config.sessions.select(""))?;
let rows = stmt.query_map([], |row| {
Ok(Session {
id: row.get(0)?,
@@ -156,8 +291,8 @@ fn read_sessions(conn: &Connection) -> SqlResult<Vec<Session>> {
rows.collect()
}
fn read_entities(conn: &Connection) -> SqlResult<Vec<Entity>> {
let mut stmt = conn.prepare("SELECT id, name, type, embedding_idx FROM entities")?;
fn read_entities(conn: &Connection, config: &SchemaConfig) -> SqlResult<Vec<Entity>> {
let mut stmt = conn.prepare(&config.entities.select(""))?;
let rows = stmt.query_map([], |row| {
Ok(Entity {
id: row.get(0)?,
@@ -169,8 +304,8 @@ fn read_entities(conn: &Connection) -> SqlResult<Vec<Entity>> {
rows.collect()
}
fn read_relations(conn: &Connection) -> SqlResult<Vec<Relation>> {
let mut stmt = conn.prepare("SELECT src, tgt, relation, weight, timestamp FROM relations")?;
fn read_relations(conn: &Connection, config: &SchemaConfig) -> SqlResult<Vec<Relation>> {
let mut stmt = conn.prepare(&config.relations.select(""))?;
let rows = stmt.query_map([], |row| {
Ok(Relation {
src: row.get(0)?,
+126 -90
View File
@@ -1,5 +1,7 @@
use clawhdf5::reader::File;
use clawhdf5_format::type_builders::AttrValue;
use crate::hdf5_reader::read_hdf5;
use crate::sqlite_reader::SqliteData;
type BoxErr = Box<dyn std::error::Error>;
/// Summary of a migration validation.
#[derive(Debug)]
@@ -9,119 +11,153 @@ pub struct ValidationSummary {
pub entities: u64,
pub relations: u64,
pub embedding_dim: u64,
/// Number of rows whose full content was compared against the source.
pub rows_checked: u64,
}
/// Validate an HDF5 file written by the migration tool.
/// Validate a migrated HDF5 file against the source data.
///
/// Checks that row counts and embedding dimensions match expectations.
/// Reads the written file back and compares actual content — chunk text,
/// embeddings, and every session/entity/relation field — to the source, not
/// just the row counts. When `full` is false a representative sample of chunk
/// rows is content-checked (counts and all other groups are always checked in
/// full); when `full` is true every chunk row is compared too. `float16` widens
/// the embedding tolerance to allow for half-precision quantization.
pub fn validate_hdf5(
path: &str,
expected_chunks: usize,
expected_sessions: usize,
expected_entities: usize,
expected_relations: usize,
expected_dim: usize,
) -> Result<ValidationSummary, Box<dyn std::error::Error>> {
let file = File::open(path)?;
let root = file.root();
source: &SqliteData,
full: bool,
float16: bool,
) -> Result<ValidationSummary, BoxErr> {
let got = read_hdf5(path)?;
// Read root attributes
let attrs = root.attrs()?;
let stored_dim = match attrs.get("embedding_dim") {
Some(AttrValue::I64(d)) => *d as u64,
_ => 0,
};
// Validate chunks group
let chunks_group = file.group("chunks")?;
let chunk_attrs = chunks_group.attrs()?;
let chunk_count = match chunk_attrs.get("count") {
Some(AttrValue::I64(n)) => *n as u64,
_ => 0,
};
if chunk_count != expected_chunks as u64 {
// ---- Counts ----
check_count("chunk", got.chunks.len(), source.chunks.len())?;
check_count("session", got.sessions.len(), source.sessions.len())?;
check_count("entity", got.entities.len(), source.entities.len())?;
check_count("relation", got.relations.len(), source.relations.len())?;
if got.embedding_dim != source.embedding_dim {
return Err(format!(
"Chunk count mismatch: HDF5 has {}, expected {}",
chunk_count, expected_chunks
"embedding_dim mismatch: HDF5 has {}, source has {}",
got.embedding_dim, source.embedding_dim
)
.into());
}
// Validate embedding dimensions if chunks exist
if chunk_count > 0 && expected_dim > 0 {
let emb_ds = chunks_group.dataset("embeddings")?;
let shape = emb_ds.shape()?;
if shape.len() == 2 && shape[1] != expected_dim as u64 {
// ---- Chunk content (sampled or full) ----
let (emb_abs, emb_rel) = if float16 { (1e-2, 1e-2) } else { (1e-4, 0.0) };
let mut rows_checked = 0u64;
for i in sample_indices(source.chunks.len(), full) {
let (s, g) = (&source.chunks[i], &got.chunks[i]);
if s.id != g.id {
return Err(field_err("chunk", i, "id", s.id, g.id));
}
if s.chunk != g.chunk {
return Err(format!(
"Embedding dim mismatch: HDF5 has {}, expected {}",
shape[1], expected_dim
"chunk[{i}].text mismatch: source {:?}, HDF5 {:?}",
truncate(&s.chunk),
truncate(&g.chunk)
)
.into());
}
if stored_dim != expected_dim as u64 {
if s.session_id != g.session_id || s.source_channel != g.source_channel || s.tags != g.tags
{
return Err(format!("chunk[{i}] string field mismatch").into());
}
if s.deleted != g.deleted {
return Err(field_err("chunk", i, "deleted", s.deleted, g.deleted));
}
if s.embedding.len() != g.embedding.len() {
return Err(format!(
"Embedding dim attr mismatch: HDF5 attr={}, expected {}",
stored_dim, expected_dim
"chunk[{i}] embedding length mismatch: {} vs {}",
s.embedding.len(),
g.embedding.len()
)
.into());
}
for (k, (&a, &b)) in s.embedding.iter().zip(g.embedding.iter()).enumerate() {
if (a - b).abs() > emb_abs + emb_rel * a.abs() {
return Err(
format!("chunk[{i}].embedding[{k}] mismatch: source {a}, HDF5 {b}").into(),
);
}
}
rows_checked += 1;
}
// Validate sessions group
let sessions_group = file.group("sessions")?;
let sess_attrs = sessions_group.attrs()?;
let session_count = match sess_attrs.get("count") {
Some(AttrValue::I64(n)) => *n as u64,
_ => 0,
};
if session_count != expected_sessions as u64 {
return Err(format!(
"Session count mismatch: HDF5 has {}, expected {}",
session_count, expected_sessions
)
.into());
// ---- Other groups (always full — they are small) ----
for (i, (s, g)) in source.sessions.iter().zip(got.sessions.iter()).enumerate() {
if s.id != g.id
|| s.start_idx != g.start_idx
|| s.end_idx != g.end_idx
|| s.channel != g.channel
|| s.summary != g.summary
{
return Err(format!("session[{i}] mismatch").into());
}
// Validate entities group
let entities_group = file.group("entities")?;
let ent_attrs = entities_group.attrs()?;
let entity_count = match ent_attrs.get("count") {
Some(AttrValue::I64(n)) => *n as u64,
_ => 0,
};
if entity_count != expected_entities as u64 {
return Err(format!(
"Entity count mismatch: HDF5 has {}, expected {}",
entity_count, expected_entities
)
.into());
rows_checked += 1;
}
// Validate relations group
let relations_group = file.group("relations")?;
let rel_attrs = relations_group.attrs()?;
let relation_count = match rel_attrs.get("count") {
Some(AttrValue::I64(n)) => *n as u64,
_ => 0,
};
if relation_count != expected_relations as u64 {
return Err(format!(
"Relation count mismatch: HDF5 has {}, expected {}",
relation_count, expected_relations
)
.into());
for (i, (s, g)) in source.entities.iter().zip(got.entities.iter()).enumerate() {
if s.id != g.id
|| s.name != g.name
|| s.entity_type != g.entity_type
|| s.embedding_idx != g.embedding_idx
{
return Err(format!("entity[{i}] mismatch").into());
}
rows_checked += 1;
}
for (i, (s, g)) in source
.relations
.iter()
.zip(got.relations.iter())
.enumerate()
{
if s.src != g.src || s.tgt != g.tgt || s.relation != g.relation {
return Err(format!("relation[{i}] mismatch").into());
}
rows_checked += 1;
}
Ok(ValidationSummary {
chunks: chunk_count,
sessions: session_count,
entities: entity_count,
relations: relation_count,
embedding_dim: stored_dim,
chunks: got.chunks.len() as u64,
sessions: got.sessions.len() as u64,
entities: got.entities.len() as u64,
relations: got.relations.len() as u64,
embedding_dim: got.embedding_dim as u64,
rows_checked,
})
}
fn check_count(kind: &str, got: usize, expected: usize) -> Result<(), BoxErr> {
if got != expected {
return Err(format!("{kind} count mismatch: HDF5 has {got}, source has {expected}").into());
}
Ok(())
}
fn field_err<T: std::fmt::Display>(kind: &str, i: usize, field: &str, s: T, g: T) -> BoxErr {
format!("{kind}[{i}].{field} mismatch: source {s}, HDF5 {g}").into()
}
fn truncate(s: &str) -> String {
if s.len() <= 40 {
s.to_string()
} else {
format!("{}…", &s[..40])
}
}
/// Indices of chunk rows to content-check. Full = all; otherwise a spread of
/// representative rows (first/last and evenly-spaced interior samples).
fn sample_indices(n: usize, full: bool) -> Vec<usize> {
if n == 0 {
return Vec::new();
}
if full || n <= 16 {
return (0..n).collect();
}
let mut idx: Vec<usize> = (0..16).map(|k| k * (n - 1) / 15).collect();
idx.dedup();
idx
}
+5 -5
View File
@@ -1,9 +1,9 @@
# rustyhdf5-netcdf4
# clawhdf5-netcdf4
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-netcdf4.svg)](https://crates.io/crates/rustyhdf5-netcdf4)
[![docs.rs](https://docs.rs/rustyhdf5-netcdf4/badge.svg)](https://docs.rs/rustyhdf5-netcdf4)
[![crates.io](https://img.shields.io/crates/v/clawhdf5-netcdf4.svg)](https://crates.io/crates/clawhdf5-netcdf4)
[![docs.rs](https://docs.rs/clawhdf5-netcdf4/badge.svg)](https://docs.rs/clawhdf5-netcdf4)
NetCDF-4 read support built on rustyhdf5 — pure Rust, no C dependencies.
NetCDF-4 read support built on clawhdf5 — pure Rust, no C dependencies.
## Features
@@ -14,7 +14,7 @@ NetCDF-4 read support built on rustyhdf5 — pure Rust, no C dependencies.
## Usage
```rust
use rustyhdf5_netcdf4::NetCDF4File;
use clawhdf5_netcdf4::NetCDF4File;
let nc = NetCDF4File::open("climate.nc").unwrap();
let temp = nc.variable("temperature").unwrap();
+6 -6
View File
@@ -1,9 +1,9 @@
# rustyhdf5-py
# clawhdf5-py
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-py.svg)](https://crates.io/crates/rustyhdf5-py)
[![docs.rs](https://docs.rs/rustyhdf5-py/badge.svg)](https://docs.rs/rustyhdf5-py)
[![crates.io](https://img.shields.io/crates/v/clawhdf5-py.svg)](https://crates.io/crates/clawhdf5-py)
[![docs.rs](https://docs.rs/clawhdf5-py/badge.svg)](https://docs.rs/clawhdf5-py)
Python bindings for rustyhdf5 — a pure-Rust HDF5 library.
Python bindings for clawhdf5 — a pure-Rust HDF5 library.
## Features
@@ -14,9 +14,9 @@ Python bindings for rustyhdf5 — a pure-Rust HDF5 library.
## Usage
```python
import rustyhdf5
import clawhdf5
with rustyhdf5.File('data.h5', 'r') as f:
with clawhdf5.File('data.h5', 'r') as f:
data = f['/dataset'][:]
```
-10
View File
@@ -1,10 +0,0 @@
[package]
name = "clawhdf5-types"
version = "2.1.0"
edition = "2024"
description = "HDF5 type system definitions for rustyhdf5"
license = "MIT"
repository = "https://github.com/redclawsystems/clawhdf5"
readme = "README.md"
keywords = ["hdf5", "types", "science", "data"]
categories = ["data-structures", "science"]
-21
View File
@@ -1,21 +0,0 @@
# rustyhdf5-types
[![crates.io](https://img.shields.io/crates/v/rustyhdf5-types.svg)](https://crates.io/crates/rustyhdf5-types)
[![docs.rs](https://docs.rs/rustyhdf5-types/badge.svg)](https://docs.rs/rustyhdf5-types)
HDF5 type system definitions for the rustyhdf5 ecosystem.
## Features
- Complete HDF5 datatype representations (integer, float, string, compound, array, enum, etc.)
- Type conversion and validation utilities
## Usage
```rust
use rustyhdf5_types::HDF5Type;
```
## License
MIT
-1
View File
@@ -1 +0,0 @@
//! HDF5 type system representation.
+1
View File
@@ -38,6 +38,7 @@ apple-compression = []
zstd = ["clawhdf5-format/zstd"]
blake3_hash = ["clawhdf5-format/blake3_hash"]
lz4 = ["clawhdf5-format/lz4"]
pcodec = ["clawhdf5-format/pcodec"]
[package.metadata.docs.rs]
features = ["mmap"]
+4 -4
View File
@@ -1,7 +1,7 @@
# rustyhdf5
# clawhdf5
[![crates.io](https://img.shields.io/crates/v/rustyhdf5.svg)](https://crates.io/crates/rustyhdf5)
[![docs.rs](https://docs.rs/rustyhdf5/badge.svg)](https://docs.rs/rustyhdf5)
[![crates.io](https://img.shields.io/crates/v/clawhdf5.svg)](https://crates.io/crates/clawhdf5)
[![docs.rs](https://docs.rs/clawhdf5/badge.svg)](https://docs.rs/clawhdf5)
Pure-Rust HDF5 reader/writer — no C dependencies.
@@ -16,7 +16,7 @@ Pure-Rust HDF5 reader/writer — no C dependencies.
## Usage
```rust
use rustyhdf5::File;
use clawhdf5::File;
let file = File::open("data.h5").unwrap();
let dataset = file.dataset("/group/data").unwrap();
+3 -1
View File
@@ -494,7 +494,9 @@ mod tests {
let file = File::open(&path).unwrap();
let ds = file.dataset("data").unwrap();
if let Ok(slice) = ds.read_f32_zerocopy() { assert_eq!(slice, &original[..]) }
if let Ok(slice) = ds.read_f32_zerocopy() {
assert_eq!(slice, &original[..])
}
assert_eq!(ds.read_f32().unwrap(), original);
std::fs::remove_file(&path).ok();
+37 -2
View File
@@ -66,6 +66,9 @@ pub struct File {
superblock: Superblock,
/// Per-file chunk cache shared across all dataset reads.
chunk_cache: ChunkCache,
/// Directory the file was opened from, used to resolve external Virtual
/// Dataset source files relative to this file. `None` for in-memory files.
base_dir: Option<std::path::PathBuf>,
}
impl File {
@@ -74,6 +77,7 @@ impl File {
/// When the `mmap` feature is enabled (default), this uses memory-mapped
/// I/O. Otherwise it reads the entire file into a `Vec<u8>`.
pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
let base_dir = path.as_ref().parent().map(|p| p.to_path_buf());
#[cfg(feature = "mmap")]
{
let reader = clawhdf5_io::MmapReader::open(path).map_err(Error::Io)?;
@@ -84,12 +88,15 @@ impl File {
data: FileData::Mmap(reader),
superblock,
chunk_cache: ChunkCache::new(),
base_dir,
})
}
#[cfg(not(feature = "mmap"))]
{
let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?;
Self::from_bytes(bytes)
let mut f = Self::from_bytes(bytes)?;
f.base_dir = base_dir;
Ok(f)
}
}
@@ -99,10 +106,15 @@ impl File {
/// undesirable (e.g. network filesystems, very small files, etc.).
pub fn open_buffered<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?;
Self::from_bytes(bytes)
let mut f = Self::from_bytes(bytes)?;
f.base_dir = path.as_ref().parent().map(|p| p.to_path_buf());
Ok(f)
}
/// Open an HDF5 file from an in-memory byte vector.
///
/// In-memory files have no directory, so external Virtual Dataset sources
/// cannot be resolved automatically (same-file VDS still works).
pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
let sig_offset = signature::find_signature(&data)?;
let superblock = Superblock::parse(&data, sig_offset)?;
@@ -110,6 +122,7 @@ impl File {
data: FileData::Owned(data),
superblock,
chunk_cache: ChunkCache::new(),
base_dir: None,
})
}
@@ -710,6 +723,28 @@ impl<'f> Dataset<'f> {
let ds = self.dataspace()?;
let dl = self.data_layout()?;
let pipeline = self.filter_pipeline();
// Virtual datasets are assembled from source datasets; the per-file
// chunk cache does not apply. Route them through the resolver path so
// external sibling files resolve relative to this file's directory.
if matches!(dl, DataLayout::Virtual { .. }) {
let base_dir = self.file.base_dir.clone();
let resolver = move |name: &str| -> Option<Vec<u8>> {
let dir = base_dir.as_ref()?;
std::fs::read(dir.join(name)).ok()
};
return Ok(data_read::read_raw_data_full_with_resolver(
self.file.data.as_bytes(),
&dl,
&ds,
&dt,
pipeline.as_ref(),
self.file.offset_size(),
self.file.length_size(),
Some(&resolver),
)?);
}
Ok(data_read::read_raw_data_cached(
self.file.data.as_bytes(),
&dl,
@@ -481,3 +481,77 @@ fn serde_json_minimal_parse(s: &str) -> Vec<f64> {
.map(|v| v.trim().parse::<f64>().unwrap())
.collect()
}
// ---------------------------------------------------------------------------
// A_dense. Write a group with many links (dense storage) -> h5py reads all
// ---------------------------------------------------------------------------
#[test]
fn clawhdf5_writes_dense_group_h5py_reads() {
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dense_group.h5");
let path_str = path.display().to_string();
// 20 links exceeds the compact threshold (8) -> dense fractal-heap storage.
let mut b = FileBuilder::new();
let mut g = b.create_group("big");
for i in 0..20 {
g.create_dataset(&format!("dataset_{i:03}"))
.with_i32_data(&[i, i * 10]);
}
b.add_group(g.finish());
b.write(&path).unwrap();
let script = format!(
r#"
import h5py
with h5py.File("{path_str}", "r") as f:
big = f["big"]
names = sorted(big.keys())
assert len(names) == 20, f"expected 20 links, got {{len(names)}}"
for i in range(20):
v = big[f"dataset_{{i:03}}"][()].tolist()
assert v == [i, i*10], f"link {{i}} = {{v}}"
print("OK")
"#
);
let out = run_python_output(&script);
assert_eq!(out, "OK");
}
// ---------------------------------------------------------------------------
// A_multiblock. Write a multi-direct-block fractal heap -> h5py reads
// ---------------------------------------------------------------------------
#[test]
fn clawhdf5_writes_multiblock_heap_h5py_reads() {
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("multiblock.h5");
let path_str = path.display().to_string();
// ~1600 dense attributes overflow a single 64KiB fractal-heap direct block.
let mut b = FileBuilder::new();
let mut g = b.create_group("g");
for i in 0..1600i64 {
g.set_attr(&format!("attribute_number_{i:05}"), AttrValue::I64(i * 2));
}
g.create_dataset("d").with_i32_data(&[1]);
b.add_group(g.finish());
b.write(&path).unwrap();
let script = format!(
r#"
import h5py
with h5py.File("{path_str}", "r") as f:
a = f["g"].attrs
assert len(a) == 1600, f"expected 1600 attrs, got {{len(a)}}"
for i in (0, 1, 999, 1599):
v = int(a[f"attribute_number_{{i:05}}"])
assert v == i*2, f"attr {{i}} = {{v}}"
print("OK")
"#
);
assert_eq!(run_python_output(&script), "OK");
}
+205
View File
@@ -706,6 +706,97 @@ fn fletcher32_roundtrip() {
// 15. Multiple groups with same-named datasets
// ---------------------------------------------------------------------------
#[test]
fn multiple_chunked_datasets_share_file_cache() {
// The per-file ChunkCache is shared across datasets. Two chunked datasets
// of *different rank* must each read correctly: a 1-D dataset's chunk index
// (rank 1) must not be reused for a 2-D dataset (rank 2). Read the 1-D one
// first so it seeds the shared cache, then the 2-D one.
use clawhdf5_format::datatype::{CharacterSet, Datatype, StringPadding};
// 1-D chunked + compressed fixed-length strings (payload > compress threshold).
let strings: Vec<String> = (0..64)
.map(|i| format!("entry-{i:06}-{}", "x".repeat(80)))
.collect();
let max_len = strings.iter().map(|s| s.len()).max().unwrap();
let mut sraw = Vec::new();
for s in &strings {
let mut b = s.as_bytes().to_vec();
b.resize(max_len, 0);
sraw.extend_from_slice(&b);
}
let sdt = Datatype::String {
size: max_len as u32,
padding: StringPadding::NullPad,
charset: CharacterSet::Utf8,
};
// 2-D chunked + compressed f32 matrix.
let (n, d) = (40usize, 8usize);
let mat: Vec<f32> = (0..n * d).map(|i| i as f32).collect();
let mut b = FileBuilder::new();
{
let ds = b.create_dataset("strs");
ds.with_compound_data(sdt, sraw, strings.len() as u64);
ds.with_chunks(&[16]);
ds.with_deflate(6);
}
{
let ds = b.create_dataset("mat");
ds.with_f32_data(&mat).with_shape(&[n as u64, d as u64]);
ds.with_chunks(&[10, d as u64])
.with_shuffle()
.with_deflate(6);
}
let bytes = b.finish().unwrap();
let file = File::from_bytes(bytes).unwrap();
// Read the 1-D dataset first (seeds the shared cache with a rank-1 index),
// then the 2-D dataset through the same File/cache.
let got_strs = file.dataset("strs").unwrap().read_string().unwrap();
assert_eq!(got_strs, strings);
let got_mat = file.dataset("mat").unwrap().read_f32().unwrap();
assert_eq!(got_mat, mat);
// Read the 1-D one again to confirm the cache rebinds back correctly.
assert_eq!(
file.dataset("strs").unwrap().read_string().unwrap(),
strings
);
}
#[test]
fn virtual_dataset_external_file_auto_resolved() {
// The facade resolves external Virtual Dataset sources relative to the
// opened file's directory automatically. Drop both files side by side in a
// temp dir and open the virtual one through the public File API.
let virt = include_bytes!("../../clawhdf5-format/tests/fixtures/vds_external_virt.h5");
let src = include_bytes!("../../clawhdf5-format/tests/fixtures/vds_external_src.h5");
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("ext_src.h5"), src).unwrap();
let virt_path = dir.path().join("ext_virt.h5");
std::fs::write(&virt_path, virt).unwrap();
let file = File::open(&virt_path).unwrap();
let values = file.dataset("virt").unwrap().read_i32().unwrap();
assert_eq!(values, vec![10, 11, 12, 13, 14, 15, 16, 17]);
}
#[test]
fn virtual_dataset_external_missing_source_is_fill() {
// If the external source file is absent, its region reads as the zero fill
// value rather than erroring.
let virt = include_bytes!("../../clawhdf5-format/tests/fixtures/vds_external_virt.h5");
let dir = tempfile::tempdir().unwrap();
let virt_path = dir.path().join("ext_virt.h5");
std::fs::write(&virt_path, virt).unwrap();
let file = File::open(&virt_path).unwrap();
let values = file.dataset("virt").unwrap().read_i32().unwrap();
assert_eq!(values, vec![0; 8]);
}
#[test]
fn same_dataset_name_in_different_groups() {
let mut b = FileBuilder::new();
@@ -730,3 +821,117 @@ fn same_dataset_name_in_different_groups() {
vec![3.0, 4.0]
);
}
#[test]
fn dense_group_links_roundtrip() {
// A group with more than the compact threshold (8) of links is stored
// densely (fractal heap + v2 B-tree). It must round-trip; a small sibling
// group stays compact. Names are chosen so hashes are non-trivial.
let mut b = FileBuilder::new();
let mut big = b.create_group("big");
for i in 0..20 {
big.create_dataset(&format!("dataset_{i:03}"))
.with_i32_data(&[i, i * 2, i * 3]);
}
b.add_group(big.finish());
let mut small = b.create_group("small");
small.create_dataset("a").with_f64_data(&[1.0]);
small.create_dataset("b").with_f64_data(&[2.0]);
b.add_group(small.finish());
let bytes = b.finish().unwrap();
let file = File::from_bytes(bytes).unwrap();
// All 20 dense-group links resolve, with correct data.
let mut names = file.group("big").unwrap().datasets().unwrap();
names.sort();
assert_eq!(names.len(), 20);
for i in 0..20 {
assert_eq!(
file.dataset(&format!("big/dataset_{i:03}"))
.unwrap()
.read_i32()
.unwrap(),
vec![i, i * 2, i * 3],
"dense link {i} mismatch"
);
}
// The small (compact) group still works.
assert_eq!(
file.dataset("small/a").unwrap().read_f64().unwrap(),
vec![1.0]
);
assert_eq!(
file.dataset("small/b").unwrap().read_f64().unwrap(),
vec![2.0]
);
}
#[test]
fn reads_libhdf5_multiblock_fractal_heap() {
// A group whose dense attributes overflow a single fractal-heap direct
// block, so libhdf5 stored them under a root indirect block (FHIB) with
// multiple direct blocks. Reading requires deriving the direct/indirect row
// split from the heap geometry, not the FRHP "starting rows" field.
let bytes = include_bytes!("../../clawhdf5-format/tests/fixtures/fractal_heap_multiblock.h5");
let file = File::from_bytes(bytes.to_vec()).unwrap();
let attrs = file.group("g").unwrap().attrs().unwrap();
for i in 0..80i64 {
let name = format!("a{i:03}");
match attrs.get(&name) {
Some(AttrValue::I64(v)) => assert_eq!(*v, i * 3, "{name}"),
other => panic!("{name} = {other:?}"),
}
}
}
#[test]
fn dense_attrs_multiblock_fractal_heap_roundtrip() {
// Enough dense attributes to overflow a single 64 KiB fractal-heap direct
// block, forcing a root indirect block over multiple direct blocks.
let mut b = FileBuilder::new();
let mut g = b.create_group("g");
let n = 1600i64;
for i in 0..n {
g.set_attr(&format!("attribute_number_{i:05}"), AttrValue::I64(i * 2));
}
g.create_dataset("d").with_i32_data(&[1]);
b.add_group(g.finish());
let file = File::from_bytes(b.finish().unwrap()).unwrap();
let attrs = file.group("g").unwrap().attrs().unwrap();
for i in 0..n {
match attrs.get(&format!("attribute_number_{i:05}")) {
Some(AttrValue::I64(v)) => assert_eq!(*v, i * 2, "attr {i}"),
other => panic!("attr {i} = {other:?}"),
}
}
}
#[test]
fn dense_links_multiblock_fractal_heap_roundtrip() {
// Enough links to overflow a single fractal-heap direct block.
let mut b = FileBuilder::new();
let mut g = b.create_group("big");
let n = 2200;
for i in 0..n {
g.create_dataset(&format!("dataset_number_{i:05}"))
.with_i32_data(&[i]);
}
b.add_group(g.finish());
let file = File::from_bytes(b.finish().unwrap()).unwrap();
assert_eq!(
file.group("big").unwrap().datasets().unwrap().len(),
n as usize
);
for i in [0, 1, 1234, n - 1] {
assert_eq!(
file.dataset(&format!("big/dataset_number_{i:05}"))
.unwrap()
.read_i32()
.unwrap(),
vec![i]
);
}
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "libaec-sys"
version = "0.1.0"
edition = "2024"
links = "aec"
[build-dependencies]
pkg-config = "0.3"
+27
View File
@@ -0,0 +1,27 @@
fn main() {
if pkg_config::Config::new()
.atleast_version("1.0")
.probe("libaec")
.is_ok()
{
return; // pkg-config found libaec and emitted the link directives
}
// Fallback: look for libaec.so / libaec.a in standard library paths.
// libaec-dev on Debian/Ubuntu installs the library but omits the .pc file.
let lib_dirs = [
"/usr/lib/x86_64-linux-gnu",
"/usr/lib",
"/usr/local/lib",
"/usr/local/lib/x86_64-linux-gnu",
];
for dir in &lib_dirs {
let so = std::path::Path::new(dir).join("libaec.so");
let a = std::path::Path::new(dir).join("libaec.a");
if so.exists() || a.exists() {
println!("cargo:rustc-link-search=native={dir}");
println!("cargo:rustc-link-lib=aec");
return;
}
}
// libaec not found — szip feature will be unavailable but crate still compiles.
}
+89
View File
@@ -0,0 +1,89 @@
//! Raw FFI bindings to libaec (Adaptive Entropy Coding library).
//!
//! Exposes `aec_buffer_encode` and `aec_buffer_decode` via the `AecStream`
//! control structure, matching the libaec C API defined in `<libaec.h>`.
use std::os::raw::c_void;
// AEC flag constants — values match <libaec.h> exactly.
pub const AEC_DATA_SIGNED: u32 = 1;
pub const AEC_DATA_3BYTE: u32 = 2;
pub const AEC_DATA_MSB: u32 = 4;
pub const AEC_DATA_PREPROCESS: u32 = 8;
pub const AEC_RESTRICTED: u32 = 16;
/// Mirror of `struct aec_stream` from `<libaec.h>`.
///
/// Must match the C layout exactly — all fields are C ABI integers/pointers.
#[repr(C)]
pub struct AecStream {
pub next_in: *const u8,
pub avail_in: usize,
pub total_in: usize,
pub next_out: *mut u8,
pub avail_out: usize,
pub total_out: usize,
pub bits_per_sample: u32,
pub block_size: u32,
pub rsi: u32,
pub flags: u32,
/// Opaque internal state; initialised to null, set by libaec on first call.
pub state: *mut c_void,
}
impl AecStream {
/// Return a zero-initialised stream safe to pass to libaec.
pub fn zeroed() -> Self {
Self {
next_in: std::ptr::null(),
avail_in: 0,
total_in: 0,
next_out: std::ptr::null_mut(),
avail_out: 0,
total_out: 0,
bits_per_sample: 0,
block_size: 0,
rsi: 0,
flags: 0,
state: std::ptr::null_mut(),
}
}
}
unsafe extern "C" {
/// One-shot compression. Returns `AEC_OK` (0) on success.
///
/// # Safety
/// `strm.next_in` must be valid for `strm.avail_in` bytes;
/// `strm.next_out` must be valid for `strm.avail_out` bytes.
pub fn aec_buffer_encode(strm: *mut AecStream) -> i32;
/// One-shot decompression. Returns `AEC_OK` (0) on success.
///
/// # Safety
/// `strm.next_in` must be valid for `strm.avail_in` bytes;
/// `strm.next_out` must be valid for `strm.avail_out` bytes.
pub fn aec_buffer_decode(strm: *mut AecStream) -> i32;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constants_match_libaec_header() {
assert_eq!(AEC_DATA_SIGNED, 1);
assert_eq!(AEC_DATA_3BYTE, 2);
assert_eq!(AEC_DATA_MSB, 4);
assert_eq!(AEC_DATA_PREPROCESS, 8);
assert_eq!(AEC_RESTRICTED, 16);
}
#[test]
fn aec_stream_zeroed_has_null_ptrs() {
let s = AecStream::zeroed();
assert!(s.next_in.is_null());
assert!(s.next_out.is_null());
assert!(s.state.is_null());
}
}
+2 -2
View File
@@ -344,7 +344,7 @@ let data = temp.read_f64()?;
### Performance
ClawhDF5 is 2–300× faster than h5py/C HDF5 for common operations. See [BENCHMARKS.md](../BENCHMARKS.md) for details. The zero-copy mmap path reads 1M floats in 313 nanoseconds.
ClawhDF5 is 3–45× faster than libhdf5 for common operations (see [BENCHMARKS.md](../BENCHMARKS.md#vs-libhdf5-summary) for methodology and an independent second-machine reproduction).
---
@@ -548,7 +548,7 @@ let final_results = confidence::reject_low_confidence(
- Hierarchical groups (natural fit for entity/relation/session organization)
- Compression built in (zlib, lz4, zstd)
- Battle-tested format (30+ years in scientific computing)
- Our implementation is pure Rust, 2–300× faster than C HDF5 for metadata ops
- Our implementation is pure Rust, 10–11× faster than libhdf5 for metadata ops (attribute writes, group creation) — see [BENCHMARKS.md](../BENCHMARKS.md#vs-libhdf5-summary)
---
@@ -0,0 +1,656 @@
# Filter Codecs Implementation Plan
> **Status (2026-08-03):** Implemented — shipped in commit `d6c4d4f` (2026-06-30), with FFI/constant fixes in `cb0b0e9`/`e91f7fc`. This doc was authored 2026-06-29 as the pre-work plan and committed to the repo retroactively on 2026-08-03; checkboxes below have been marked complete to match. Treat this as a historical record, not an open task list.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add SZIP decompression (filter ID 4) and N-Bit E-scale decompression (scale type 1 of filter ID 6) to the clawhdf5-format crate.
**Architecture:** N-Bit E-scale extends the existing `scaleoffset_decompress` function in `filters.rs` with a ~15-line new branch. SZIP is added as an optional `szip` feature using FFI to the system `libaec` C library (same pattern as the existing `system-zlib-decompress` feature), with a `build.rs` that uses `pkg-config` or `cc` to locate/compile it.
**Tech Stack:** Rust (no_std-compatible where possible), `libaec` C library (optional FFI via `cc` crate), `pkg-config` crate for system library discovery.
## Global Constraints
- All code in `crates/clawhdf5-format/` and `crates/clawhdf5-filters/`.
- SZIP must be feature-gated: `szip` feature, disabled by default. When not enabled, `FILTER_SZIP` must return `FormatError::UnsupportedFilter(4)` as it does today.
- N-Bit E-scale requires no new features — it is a fix within the existing `deflate`-free path.
- Tests must not require h5py or Python; use hand-crafted compressed byte sequences verified against the HDF5 reference implementation commentary in the test file.
- Run `cargo test -p clawhdf5-format` after every task.
---
### Task 1: N-Bit E-scale (float scale-offset, scale type 1)
**Files:**
- Modify: `crates/clawhdf5-format/src/filters.rs:96-108` (the `scaleoffset_decompress` dispatch block)
- Test: `crates/clawhdf5-format/src/filters.rs` (new tests in the existing `#[cfg(test)]` block at the bottom)
**Interfaces:**
- Consumes: existing `scaleoffset_decompress(data: &[u8], cd: &[u32], expected_bytes: usize) -> Result<Vec<u8>, FormatError>`.
- Produces: same function, now handling `cd[0] == 1` (H5Z_SO_FLOAT_ESCALE).
**Background:**
- `cd[0]`: scale type — `0` = float D-scale (already done), `1` = float E-scale (this task), `2` = integer (already done).
- E-scale formula: `value = minval + code * 2^E` where `E = cd[1] as i32` (may be negative for sub-unit precision). Compare to D-scale: `value = minval + code / 10^D`.
- The binary layout (minbits, minval, 8 reserved bytes, packed MSB-first codes) is IDENTICAL to D-scale. Only the reconstruction formula differs.
- [x] **Step 1: Write the failing test**
In `crates/clawhdf5-format/src/filters.rs`, inside the existing `#[cfg(test)] mod tests` block, add:
```rust
#[test]
fn scaleoffset_float_escale_basic() {
// f32 [0.0, 4.0, 8.0, 12.0]: minval=0.0f32, E=2 (scale=4.0 = 2^2),
// stored codes [0, 1, 2, 3] in 2 bits each.
// cd: [scale_type=1, scale_factor=2, nelmts=4, unused=1, elem_size=4,
// signed=0, big_endian=0, fill_defined=1, fill_lo=0, fill_hi=0]
let cd = [1u32, 2, 4, 1, 4, 0, 0, 1, 0, 0];
// Layout: minbits(4 LE) = 2, minval_width(1) = 4, minval(4) = 0.0f32,
// reserved(8), packed codes: 0b_00_01_10_11 = 0x1B in 1 byte
let mut data = Vec::new();
data.extend_from_slice(&2u32.to_le_bytes()); // minbits = 2
data.push(4); // minval_width
data.extend_from_slice(&0.0f32.to_le_bytes()); // minval = 0.0
data.extend_from_slice(&[0u8; 8]); // 8 reserved bytes
data.push(0b0001_1011); // codes: 0,1,2,3 packed MSB-first in 2 bits each
let out = scaleoffset_decompress(&data, &cd, 0).unwrap();
let floats: Vec<f32> = out.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.collect();
assert_eq!(floats.len(), 4);
assert!((floats[0] - 0.0f32).abs() < 1e-5, "got {}", floats[0]);
assert!((floats[1] - 4.0f32).abs() < 1e-5, "got {}", floats[1]);
assert!((floats[2] - 8.0f32).abs() < 1e-5, "got {}", floats[2]);
assert!((floats[3] - 12.0f32).abs() < 1e-5, "got {}", floats[3]);
}
#[test]
fn scaleoffset_float_escale_negative_exponent() {
// f32 [0.0, 0.25, 0.5, 0.75]: minval=0.0, E=-2 (scale=0.25 = 2^-2),
// codes [0,1,2,3]. cd[1] stored as u32; we cast to i32 in decoder.
let e: i32 = -2;
let cd = [1u32, e as u32, 4, 1, 4, 0, 0, 1, 0, 0];
let mut data = Vec::new();
data.extend_from_slice(&2u32.to_le_bytes());
data.push(4);
data.extend_from_slice(&0.0f32.to_le_bytes());
data.extend_from_slice(&[0u8; 8]);
data.push(0b0001_1011);
let out = scaleoffset_decompress(&data, &cd, 0).unwrap();
let floats: Vec<f32> = out.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.collect();
assert!((floats[1] - 0.25f32).abs() < 1e-6, "got {}", floats[1]);
assert!((floats[2] - 0.50f32).abs() < 1e-6, "got {}", floats[2]);
assert!((floats[3] - 0.75f32).abs() < 1e-6, "got {}", floats[3]);
}
```
- [x] **Step 2: Run tests to verify they fail**
```bash
cargo test -p clawhdf5-format scaleoffset_float_escale 2>&1 | head -30
```
Expected: FAIL — `"UnsupportedFilter(6)"` or similar.
- [x] **Step 3: Implement E-scale in scaleoffset_decompress**
In `crates/clawhdf5-format/src/filters.rs`, change the dispatch block (around line 96):
```rust
fn scaleoffset_decompress(
data: &[u8],
cd: &[u32],
expected_bytes: usize,
) -> Result<Vec<u8>, FormatError> {
const H5Z_SO_FLOAT_DSCALE: u32 = 0;
const H5Z_SO_FLOAT_ESCALE: u32 = 1;
const H5Z_SO_INT: u32 = 2;
if cd.len() < 8 {
return Err(FormatError::ChunkedReadError(
"scale-offset: missing filter client data".into(),
));
}
let scale_type = cd[0];
let is_float = scale_type == H5Z_SO_FLOAT_DSCALE || scale_type == H5Z_SO_FLOAT_ESCALE;
if scale_type != H5Z_SO_INT && !is_float {
return Err(FormatError::UnsupportedFilter(FILTER_SCALEOFFSET));
}
// ... (rest of the existing parsing logic unchanged until the reconstruction block) ...
```
Then in the float reconstruction block (currently the `if is_float { ... }` branch at line ~192), replace:
```rust
if is_float {
let scale = if scale_type == H5Z_SO_FLOAT_DSCALE {
10f64.powi(cd[1] as i32)
} else {
// E-scale: scale factor is a power of 2; cd[1] interpreted as signed i32
2f64.powi(cd[1] as i32)
};
let minval = read_le_float(minval_bytes, elem_size);
let fill_value = if fill_defined {
let lo = *cd.get(8).unwrap_or(&0) as u64;
let hi = *cd.get(9).unwrap_or(&0) as u64;
bits_to_float(lo | (hi << 32), elem_size)
} else {
0.0
};
let values: Vec<f64> = codes
.iter()
.map(|&code| {
if has_fill_code && code == fill_code {
fill_value
} else if scale_type == H5Z_SO_FLOAT_DSCALE {
minval + code as f64 / scale
} else {
// E-scale: value = minval + code * 2^E
minval + code as f64 * scale
}
})
.collect();
Ok(write_floats(&values, elem_size, big_endian))
} else {
```
- [x] **Step 4: Run tests to verify they pass**
```bash
cargo test -p clawhdf5-format scaleoffset_float_escale 2>&1
```
Expected: both tests PASS.
- [x] **Step 5: Run full test suite**
```bash
cargo test -p clawhdf5-format 2>&1 | tail -10
```
Expected: all tests pass, zero failures.
- [x] **Step 6: Commit**
```bash
git add crates/clawhdf5-format/src/filters.rs
git commit -m "feat: add scale-offset E-scale (float binary-exponent) decompression"
```
---
### Task 2: SZIP feature gate and stub hook
**Files:**
- Modify: `crates/clawhdf5-format/Cargo.toml` (add `szip` feature and `libaec-sys` optional dep)
- Create: `crates/clawhdf5-format/build.rs`
- Modify: `crates/clawhdf5-format/src/filters.rs` (add `szip_decompress` call in `decompress_chunk`)
- Create: `crates/clawhdf5-format/src/filters_szip.rs`
**Interfaces:**
- Produces: `pub(crate) fn szip_decompress(data: &[u8], cd: &[u32], chunk_size: usize) -> Result<Vec<u8>, FormatError>`
- `decompress_chunk` calls it for `FILTER_SZIP` when the `szip` feature is active.
**Background — SZIP parameters from `cd`:**
- `cd[0]` (options mask): bit 2 = NN (nearest-neighbor) preprocessing, bit 4 = EC (entropy coding), bit 5 = LSB order, bit 8 = allow K-13.
- `cd[1]` (pixels per block): 8, 10, 16, or 32.
- `cd[2]` (pixels per scan line): not used for decompression.
- The `libaec` library exposes `aec_decode_init`, `aec_decode`, `aec_decode_end` (struct `aec_stream`).
- [x] **Step 1: Write the failing test**
In `crates/clawhdf5-format/src/filters_szip.rs` (create the file):
```rust
//! SZIP (libaec Adaptive Entropy Coding) decompression.
//!
//! Gated by the `szip` feature which links against the system libaec library.
use crate::error::FormatError;
use crate::filter_pipeline::FILTER_SZIP;
/// Decompress SZIP-compressed data using libaec.
///
/// `cd` is the HDF5 filter client data:
/// cd[0] = options mask (EC flag = 0x04, NN flag = 0x20, LSB = 0x40, allow_k13 = 0x100)
/// cd[1] = pixels per block (8, 10, 16, or 32)
/// cd[2] = pixels per scan line
/// cd[4] = bits per sample (element bit width)
pub fn szip_decompress(
_data: &[u8],
_cd: &[u32],
_chunk_size: usize,
) -> Result<Vec<u8>, FormatError> {
#[cfg(feature = "szip")]
{
szip_decode_impl(_data, _cd, _chunk_size)
}
#[cfg(not(feature = "szip"))]
{
Err(FormatError::UnsupportedFilter(FILTER_SZIP))
}
}
#[cfg(feature = "szip")]
fn szip_decode_impl(
data: &[u8],
cd: &[u32],
chunk_size: usize,
) -> Result<Vec<u8>, FormatError> {
if cd.len() < 5 {
return Err(FormatError::ChunkedReadError("szip: missing client data".into()));
}
let options = cd[0];
let pixels_per_block = cd[1];
let bits_per_sample = cd[4] as usize;
if bits_per_sample == 0 || bits_per_sample > 32 {
return Err(FormatError::ChunkedReadError("szip: invalid bits per sample".into()));
}
// Map HDF5 options to libaec flags
let flags: u32 = {
let mut f = 0u32;
if options & 0x04 != 0 { f |= AEC_DATA_PREPROCESS; } // NN
if options & 0x40 == 0 { f |= AEC_DATA_MSB; } // MSB (not LSB)
if options & 0x100 != 0 { f |= AEC_ALLOW_K13; }
f
};
let out_len = if chunk_size > 0 { chunk_size } else {
return Err(FormatError::ChunkedReadError("szip: unknown output size".into()));
};
let mut out = vec![0u8; out_len];
let result = unsafe {
libaec_sys::aec_buffer_decode(
data.as_ptr(),
data.len(),
out.as_mut_ptr(),
&mut (out_len as libaec_sys::size_t),
bits_per_sample as u32,
pixels_per_block,
flags,
)
};
if result != 0 {
return Err(FormatError::DecompressionError(format!("szip: libaec error {result}")));
}
Ok(out)
}
// libaec flag constants (from aec.h)
#[cfg(feature = "szip")]
const AEC_DATA_PREPROCESS: u32 = 1;
#[cfg(feature = "szip")]
const AEC_DATA_MSB: u32 = 2;
#[cfg(feature = "szip")]
const AEC_ALLOW_K13: u32 = 8;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn szip_disabled_returns_unsupported() {
// When szip feature is disabled, must return UnsupportedFilter(4).
#[cfg(not(feature = "szip"))]
{
let result = szip_decompress(&[], &[4, 8, 10, 0, 8], 64);
assert!(
matches!(result, Err(FormatError::UnsupportedFilter(4))),
"expected UnsupportedFilter(4), got {result:?}"
);
}
#[cfg(feature = "szip")]
{
// When szip IS enabled, an empty buffer should error but not panic.
let _ = szip_decompress(&[], &[4, 8, 10, 0, 8], 64);
}
}
}
```
- [x] **Step 2: Run the new test**
```bash
cargo test -p clawhdf5-format szip_disabled_returns_unsupported 2>&1
```
Expected: the file doesn't compile yet (module not declared). That's the expected failure mode.
- [x] **Step 3: Add Cargo.toml feature and build.rs**
In `crates/clawhdf5-format/Cargo.toml`, add to `[dependencies]`:
```toml
libaec-sys = { version = "0.1", optional = true }
```
Add to `[features]`:
```toml
szip = ["libaec-sys"]
```
Create `crates/clawhdf5-format/build.rs`:
```rust
fn main() {
#[cfg(feature = "szip")]
{
// Try pkg-config first; fall back to empty link flags (system path).
if std::process::Command::new("pkg-config")
.args(["--exists", "libaec"])
.status()
.map(|s| s.success())
.unwrap_or(false)
{
println!("cargo:rustc-link-lib=aec");
if let Ok(dir) = std::process::Command::new("pkg-config")
.args(["--variable=libdir", "libaec"])
.output()
{
let dir = String::from_utf8_lossy(&dir.stdout).trim().to_string();
if !dir.is_empty() {
println!("cargo:rustc-link-search=native={dir}");
}
}
} else {
// Fallback: assume libaec is in the standard library path.
println!("cargo:rustc-link-lib=aec");
}
}
}
```
Note: `libaec-sys` is a crate that provides raw bindings. If that crate doesn't exist on crates.io with that exact name, use `libaec-sys = { git = "..." }` or add `aec-sys` as a local crate (see Task 3 below for the fallback path).
- [x] **Step 4: Declare the module in lib.rs**
In `crates/clawhdf5-format/src/lib.rs`, add:
```rust
mod filters_szip;
```
(Place it alongside the other `mod filters;` declaration.)
- [x] **Step 5: Hook szip_decompress into decompress_chunk**
In `crates/clawhdf5-format/src/filters.rs`, change the dispatch inside `decompress_chunk`:
```rust
// Change this:
other => return Err(FormatError::UnsupportedFilter(other)),
// To this:
FILTER_SZIP => crate::filters_szip::szip_decompress(&data, &filter.client_data, chunk_size)?,
other => return Err(FormatError::UnsupportedFilter(other)),
```
Also add the import at the top of `filters.rs`:
```rust
use crate::filter_pipeline::{
FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_NBIT, FILTER_SCALEOFFSET,
FILTER_SHUFFLE, FILTER_SZIP, FILTER_ZSTD, FilterPipeline,
};
```
(Add `FILTER_SZIP` to the existing import.)
- [x] **Step 6: Run tests without szip feature**
```bash
cargo test -p clawhdf5-format 2>&1 | tail -15
```
Expected: all existing tests pass; `szip_disabled_returns_unsupported` passes.
- [x] **Step 7: Commit**
```bash
git add crates/clawhdf5-format/Cargo.toml \
crates/clawhdf5-format/build.rs \
crates/clawhdf5-format/src/filters_szip.rs \
crates/clawhdf5-format/src/filters.rs \
crates/clawhdf5-format/src/lib.rs
git commit -m "feat: add SZIP filter hook with libaec FFI (feature-gated, disabled by default)"
```
---
### Task 3: libaec-sys bindings crate (if no public crate exists)
> Skip this task if a published `libaec-sys` crate is available on crates.io. Check with `cargo search libaec-sys`.
**Files:**
- Create: `crates/libaec-sys/Cargo.toml`
- Create: `crates/libaec-sys/src/lib.rs`
- Create: `crates/libaec-sys/build.rs`
- Modify: `Cargo.toml` (workspace members)
**Interfaces:**
- Produces: `pub unsafe fn aec_buffer_decode(src: *const u8, src_len: usize, dst: *mut u8, dst_len: *mut usize, bits_per_sample: u32, block_size: u32, flags: u32) -> i32`
- [x] **Step 1: Create the sys crate**
Create `crates/libaec-sys/Cargo.toml`:
```toml
[package]
name = "libaec-sys"
version = "0.1.0"
edition = "2024"
links = "aec"
[build-dependencies]
pkg-config = "0.3"
```
Create `crates/libaec-sys/build.rs`:
```rust
fn main() {
if pkg_config::Config::new()
.atleast_version("1.0")
.probe("libaec")
.is_ok()
{
return;
}
// If pkg-config fails, try linking directly
println!("cargo:rustc-link-lib=aec");
}
```
Create `crates/libaec-sys/src/lib.rs`:
```rust
//! Raw FFI bindings to libaec (Adaptive Entropy Coding library).
//!
//! Provides the `aec_buffer_decode` convenience function for one-shot decompression.
pub type size_t = usize;
// AEC flag constants matching aec.h
pub const AEC_DATA_PREPROCESS: u32 = 1; // NN preprocessing
pub const AEC_DATA_MSB: u32 = 2; // big-endian sample order
pub const AEC_RESTRICTED: u32 = 4; // restricted coding set
pub const AEC_ALLOW_K13: u32 = 8; // allow k=13 option
extern "C" {
/// One-shot decompression. Returns 0 on success.
///
/// # Safety
/// `src` must be valid for `src_len` bytes; `dst` must be valid for `*dst_len` bytes.
pub fn aec_buffer_decode(
src: *const u8,
src_len: size_t,
dst: *mut u8,
dst_len: *mut size_t,
bits_per_sample: u32,
block_size: u32,
flags: u32,
) -> i32;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constants_are_correct() {
assert_eq!(AEC_DATA_PREPROCESS, 1);
assert_eq!(AEC_DATA_MSB, 2);
}
}
```
- [x] **Step 2: Add to workspace**
In the root `Cargo.toml`, add `"crates/libaec-sys"` to `[workspace] members`.
- [x] **Step 3: Update clawhdf5-format dependency**
In `crates/clawhdf5-format/Cargo.toml`, change:
```toml
libaec-sys = { version = "0.1", optional = true }
```
to:
```toml
libaec-sys = { path = "../libaec-sys", version = "0.1", optional = true }
```
- [x] **Step 4: Run tests**
```bash
cargo test -p libaec-sys 2>&1 | tail -10
cargo test -p clawhdf5-format 2>&1 | tail -10
```
Expected: both pass.
- [x] **Step 5: Commit**
```bash
git add crates/libaec-sys/ Cargo.toml crates/clawhdf5-format/Cargo.toml
git commit -m "feat: add libaec-sys workspace crate for SZIP FFI bindings"
```
---
### Task 4: SZIP integration test with libaec installed
**Files:**
- Modify: `crates/clawhdf5-format/src/filters_szip.rs` (add integration test behind `szip` feature)
**Background:** This test only runs when the `szip` feature is enabled AND libaec is installed. It validates that we can round-trip a known dataset (u8 values 0-63, 8 pixels per block, EC mode).
- [x] **Step 1: Add integration test**
In `crates/clawhdf5-format/src/filters_szip.rs`, inside `#[cfg(test)] mod tests`, add:
```rust
#[test]
#[cfg(feature = "szip")]
fn szip_ec_roundtrip_u8() {
// Encode 64 values [0..64] with libaec, then decode with our wrapper.
// This tests the full encode→decode cycle.
use crate::filter_pipeline::FilterDescription;
use crate::filters::{compress_chunk, decompress_chunk};
use crate::filter_pipeline::{FilterPipeline, FILTER_SZIP};
// cd: options=EC(0x04)|MSB(0x00), pixels_per_block=8, ppsl=64, unused=0, bits_per_sample=8
let cd = vec![0x04u32, 8, 64, 0, 8];
// Build a test dataset: 64 bytes incrementing
let original: Vec<u8> = (0u8..64).collect();
// Use aec_buffer_encode to generate compressed data for this test
let compressed = unsafe {
let mut out = vec![0u8; original.len() * 4]; // generous buffer
let mut out_len = out.len();
libaec_sys::aec_buffer_encode(
original.as_ptr(),
original.len(),
out.as_mut_ptr(),
&mut out_len,
8, // bits per sample
8, // block size
libaec_sys::AEC_DATA_MSB,
);
out.truncate(out_len);
out
};
let decoded = szip_decompress(&compressed, &cd, original.len()).unwrap();
assert_eq!(decoded, original);
}
```
Also add `aec_buffer_encode` to `crates/libaec-sys/src/lib.rs`:
```rust
extern "C" {
// ... existing aec_buffer_decode ...
/// One-shot compression. Returns 0 on success.
///
/// # Safety
/// `src` must be valid for `src_len` bytes; `dst` must be valid for `*dst_len` bytes.
pub fn aec_buffer_encode(
src: *const u8,
src_len: size_t,
dst: *mut u8,
dst_len: *mut size_t,
bits_per_sample: u32,
block_size: u32,
flags: u32,
) -> i32;
}
```
- [x] **Step 2: Run the integration test (requires libaec installed)**
```bash
# Install libaec if not present: sudo apt install libaec-dev
cargo test -p clawhdf5-format --features szip szip_ec_roundtrip_u8 2>&1
```
Expected: PASS when libaec is installed.
- [x] **Step 3: Run full suite without szip feature to verify no regressions**
```bash
cargo test -p clawhdf5-format 2>&1 | tail -10
```
Expected: all tests pass.
- [x] **Step 4: Commit**
```bash
git add crates/clawhdf5-format/src/filters_szip.rs crates/libaec-sys/src/lib.rs
git commit -m "feat: add SZIP integration test for libaec roundtrip"
```
---
## Verification
```bash
# Full test suite (no szip)
cargo test -p clawhdf5-format 2>&1 | tail -5
# With szip feature (requires libaec installed)
cargo test -p clawhdf5-format --features szip 2>&1 | tail -5
# Specific E-scale tests
cargo test -p clawhdf5-format scaleoffset_float_escale 2>&1
# Confirm SZIP returns UnsupportedFilter without the feature
cargo test -p clawhdf5-format szip_disabled 2>&1
```
@@ -0,0 +1,846 @@
# Format Write Extensions Implementation Plan
> **Status (2026-08-03):** Implemented. Tasks 1–3 (external links, VDS mapping serialization, VDS `FileWriter` API) shipped in commit `d6c4d4f` (2026-06-30). Tasks 4–5 (superblock v4 read/write) were not part of that commit and were completed separately as part of this cleanup pass (2026-08-03) — see `Superblock::parse_v4`/`serialize` and `FileWriter::with_page_size` in `crates/clawhdf5-format`. This doc was authored 2026-06-29 as the pre-work plan and committed to the repo retroactively; checkboxes below have been marked complete to match current state. Treat this as a historical record, not an open task list.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add three write-side features to clawhdf5-format: (1) external link creation via `GroupBuilder`, (2) external VDS (Virtual Dataset Source) layout writes, and (3) superblock v4 read/write for page-buffering-aware files.
**Architecture:** External links reuse the existing `LinkMessage::serialize()` which already handles `LinkTarget::External` — only the `GroupBuilder` API needs wiring up. External VDS adds `write_vds_layout()` in `file_writer.rs` and `serialize_vds_mappings()` in a new `data_layout_write.rs`. Superblock v4 extends `Superblock::parse` with a new `parse_v4` branch (identical structure to v3 with an extra `page_size` field) and updates `Superblock::serialize` to optionally write v4.
**Tech Stack:** Pure Rust, no new dependencies. All changes in `crates/clawhdf5-format/`.
## Global Constraints
- All code in `crates/clawhdf5-format/`.
- No new Cargo dependencies.
- External links: written as `LinkTarget::External`, readable by h5py (verified in tests).
- VDS: uses data layout version 4, class 3. Global heap at end of file.
- Superblock v4: only adds `page_size: u32` field after the v2/v3 body; checksum placement unchanged.
- Run `cargo test -p clawhdf5-format` after every task.
---
### Task 1: External link write API in GroupBuilder
**Background:** `LinkMessage::serialize()` in `link_message.rs:76–175` already handles `LinkTarget::External { filename, object_path }` (writes link_type byte = 64, then packed filename+path). What's missing is a public API in `file_writer.rs` to create external links from a `GroupBuilder`. Currently `GroupBuilder` only creates datasets and sub-groups via `create_dataset` / `create_group`.
**Files:**
- Modify: `crates/clawhdf5-format/src/file_writer.rs` (add `GroupBuilder::add_external_link`)
- Modify: `crates/clawhdf5-format/src/lib.rs` (re-export `LinkTarget` if not already exported)
- Test: `crates/clawhdf5-format/src/file_writer.rs` (new test in `#[cfg(test)]`)
**Interfaces:**
- Produces: `GroupBuilder::add_external_link(&mut self, name: &str, target_file: &str, target_path: &str) -> &mut Self`
- [x] **Step 1: Write the failing test**
At the bottom of the `#[cfg(test)]` block in `crates/clawhdf5-format/src/file_writer.rs`, add:
```rust
#[test]
fn external_link_write_roundtrip() {
use crate::group_v2::resolve_path_any;
use crate::link_message::{LinkMessage, LinkTarget};
use crate::message_type::MessageType;
use crate::object_header::ObjectHeader;
use crate::signature::find_signature;
use crate::superblock::Superblock;
let mut fw = FileWriter::new();
let mut grp = fw.create_group("links");
grp.add_external_link("remote_data", "other_file.h5", "/sensors/temp");
fw.add_group(grp.finish());
let bytes = fw.finish().unwrap();
let sig = find_signature(&bytes).unwrap();
let sb = Superblock::parse(&bytes, sig).unwrap();
// Navigate to /links group
let links_addr = resolve_path_any(&bytes, &sb, "links").unwrap();
let links_oh = ObjectHeader::parse(
&bytes, links_addr as usize, sb.offset_size, sb.length_size,
).unwrap();
// Find the link message for "remote_data"
let link_msg = links_oh.messages.iter()
.filter(|m| m.msg_type == MessageType::Link)
.find_map(|m| {
let lm = LinkMessage::parse(&m.data, sb.offset_size).ok()?;
if lm.name == "remote_data" { Some(lm) } else { None }
})
.expect("external link message not found");
assert_eq!(
link_msg.link_target,
LinkTarget::External {
filename: "other_file.h5".into(),
object_path: "/sensors/temp".into(),
}
);
}
```
- [x] **Step 2: Run test to verify it fails**
```bash
cargo test -p clawhdf5-format external_link_write_roundtrip 2>&1 | head -20
```
Expected: compile error — `add_external_link` not found.
- [x] **Step 3: Find GroupBuilder in file_writer.rs and add the method**
Locate `GroupBuilder` in `crates/clawhdf5-format/src/file_writer.rs`. It tracks its items as a `Vec` of internal builders. Add a field for external links and the method:
First, locate the `GroupBuilder` struct definition and add a field:
```rust
pub struct GroupBuilder {
name: String,
datasets: Vec<DatasetBuilder>,
groups: Vec<FinishedGroup>,
external_links: Vec<(String, String, String)>, // (name, filename, object_path)
}
```
Update `GroupBuilder::new()` (or equivalent constructor) to initialize `external_links: Vec::new()`.
Add the public method immediately after the existing `create_dataset`/`create_group` methods:
```rust
/// Add a link in this group that points to an object in another HDF5 file.
///
/// `name` is the link name within this group.
/// `target_file` is the relative or absolute path to the target .h5 file.
/// `target_path` is the HDF5 path of the object within the target file.
pub fn add_external_link(
&mut self,
name: &str,
target_file: &str,
target_path: &str,
) -> &mut Self {
self.external_links.push((
name.to_string(),
target_file.to_string(),
target_path.to_string(),
));
self
}
```
- [x] **Step 4: Wire external links into the group serialization**
Find where the `GroupBuilder` emits `LinkMessage` bytes during `finish()` / `build_group()`. For each external link, emit a `LinkMessage` with `LinkTarget::External`:
```rust
use crate::link_message::{LinkMessage, LinkTarget};
use crate::datatype::CharacterSet;
// Inside the loop/block that serializes links:
for (link_name, filename, object_path) in &self.external_links {
let msg = LinkMessage {
name: link_name.clone(),
link_target: LinkTarget::External {
filename: filename.clone(),
object_path: object_path.clone(),
},
creation_order: None,
charset: CharacterSet::Utf8,
};
let msg_bytes = msg.serialize(offset_size);
// Emit as a Link message (MessageType::Link = 0x0006) into the object header
emit_message(&mut oh_buf, MessageType::Link, &msg_bytes);
}
```
(Follow the exact pattern used for hard links and soft links in the same codebase — find where hard-link `LinkMessage` bytes are pushed and add the external links in the same loop.)
- [x] **Step 5: Run the failing test**
```bash
cargo test -p clawhdf5-format external_link_write_roundtrip 2>&1
```
Expected: PASS.
- [x] **Step 6: Run full suite**
```bash
cargo test -p clawhdf5-format 2>&1 | tail -10
```
Expected: all tests pass.
- [x] **Step 7: Commit**
```bash
git add crates/clawhdf5-format/src/file_writer.rs
git commit -m "feat: add GroupBuilder::add_external_link for writing cross-file HDF5 links"
```
---
### Task 2: VDS mapping serialization helper
**Background:** Reading VDS mappings from a global heap object is done by `parse_vds_mappings()` in `data_layout.rs:70–155`. Writing the inverse — serializing a `Vec<VdsMapping>` into the same binary layout — does not exist. This task creates `serialize_vds_mappings()`.
**Files:**
- Create: `crates/clawhdf5-format/src/data_layout_write.rs`
- Modify: `crates/clawhdf5-format/src/lib.rs` (declare module)
**Interfaces:**
- Consumes: `VdsMapping { source_file_name: String, source_dataset_name: String, source_selection: Vec<u8>, virtual_selection: Vec<u8> }` (existing struct from `data_layout.rs`).
- Produces: `pub fn serialize_vds_mappings(mappings: &[VdsMapping], length_size: u8) -> Vec<u8>`
**Binary layout (from `data_layout.rs:72–91` doc comment):**
```
version: u8 (0 = external file, 1 = same-file marker)
nused: length_size bytes (number of mappings)
for each mapping:
if version==0: source_file_name (null-terminated)
else: marker byte (0xFF or similar; same-file means empty filename)
source_dataset_name: null-terminated string
source_selection: length(length_size) + bytes
virtual_selection: length(length_size) + bytes
```
- [x] **Step 1: Write the failing tests**
Create `crates/clawhdf5-format/src/data_layout_write.rs`:
```rust
//! Write-side helpers for VDS (Virtual Dataset Source) mapping serialization.
use crate::data_layout::{parse_vds_mappings, VdsMapping};
use crate::error::FormatError;
/// Serialize a slice of VDS mappings into the global-heap object byte format.
///
/// The output can be stored directly in a global heap object and referenced
/// from a Data Layout v4 class=3 (Virtual) message.
pub fn serialize_vds_mappings(mappings: &[VdsMapping], length_size: u8) -> Vec<u8> {
let mut buf = Vec::new();
// Determine if all sources are same-file (empty source_file_name)
let has_external = mappings.iter().any(|m| !m.source_file_name.is_empty());
let version: u8 = if has_external { 0 } else { 1 };
buf.push(version);
// nused: number of mappings
write_length(&mut buf, mappings.len() as u64, length_size);
for m in mappings {
if version == 0 {
// External: null-terminated filename
buf.extend_from_slice(m.source_file_name.as_bytes());
buf.push(0);
} else {
// Same-file: marker byte (0x00, which parse_vds_mappings treats as empty)
buf.push(0);
}
// source dataset name: null-terminated
buf.extend_from_slice(m.source_dataset_name.as_bytes());
buf.push(0);
// source selection: length + bytes
write_length(&mut buf, m.source_selection.len() as u64, length_size);
buf.extend_from_slice(&m.source_selection);
// virtual selection: length + bytes
write_length(&mut buf, m.virtual_selection.len() as u64, length_size);
buf.extend_from_slice(&m.virtual_selection);
}
buf
}
fn write_length(buf: &mut Vec<u8>, val: u64, size: u8) {
match size {
2 => buf.extend_from_slice(&(val as u16).to_le_bytes()),
4 => buf.extend_from_slice(&(val as u32).to_le_bytes()),
8 => buf.extend_from_slice(&val.to_le_bytes()),
_ => buf.extend_from_slice(&val.to_le_bytes()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn all_sel() -> Vec<u8> {
// Minimal H5S ALL selection bytes: type=3 (ALL), version=1, flags=0, unused*4
let mut v = Vec::new();
v.extend_from_slice(&3u32.to_le_bytes()); // type = H5S_SEL_ALL
v.push(1); // version
v.push(0); // flags
v.extend_from_slice(&[0u8; 4]); // unused
v
}
#[test]
fn roundtrip_same_file_two_mappings() {
let sel = all_sel();
let mappings = vec![
VdsMapping {
source_file_name: String::new(),
source_dataset_name: "/src_a".into(),
source_selection: sel.clone(),
virtual_selection: sel.clone(),
},
VdsMapping {
source_file_name: String::new(),
source_dataset_name: "/src_b".into(),
source_selection: sel.clone(),
virtual_selection: sel.clone(),
},
];
let bytes = serialize_vds_mappings(&mappings, 8);
let parsed = parse_vds_mappings(&bytes, 8).unwrap();
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].source_dataset_name, "/src_a");
assert_eq!(parsed[1].source_dataset_name, "/src_b");
}
#[test]
fn roundtrip_external_file_mapping() {
let sel = all_sel();
let mappings = vec![VdsMapping {
source_file_name: "source.h5".into(),
source_dataset_name: "/data".into(),
source_selection: sel.clone(),
virtual_selection: sel.clone(),
}];
let bytes = serialize_vds_mappings(&mappings, 8);
let parsed = parse_vds_mappings(&bytes, 8).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].source_file_name, "source.h5");
assert_eq!(parsed[0].source_dataset_name, "/data");
}
#[test]
fn empty_mappings_roundtrip() {
let bytes = serialize_vds_mappings(&[], 8);
let parsed = parse_vds_mappings(&bytes, 8).unwrap();
assert!(parsed.is_empty());
}
}
```
- [x] **Step 2: Run the failing tests**
```bash
cargo test -p clawhdf5-format roundtrip_same_file_two_mappings roundtrip_external_file_mapping 2>&1 | head -20
```
Expected: compile errors (module not declared).
- [x] **Step 3: Declare module in lib.rs**
In `crates/clawhdf5-format/src/lib.rs`, add:
```rust
pub mod data_layout_write;
```
- [x] **Step 4: Run tests**
```bash
cargo test -p clawhdf5-format data_layout_write 2>&1
```
Expected: all 3 tests PASS. If `parse_vds_mappings` expects a slightly different format for the version byte or the marker byte, adjust `serialize_vds_mappings` to match what the parser consumes (read `data_layout.rs:92–155` carefully to align).
- [x] **Step 5: Commit**
```bash
git add crates/clawhdf5-format/src/data_layout_write.rs \
crates/clawhdf5-format/src/lib.rs
git commit -m "feat: add serialize_vds_mappings for writing VDS global-heap objects"
```
---
### Task 3: FileWriter API for virtual datasets
**Background:** This task wires `serialize_vds_mappings()` into the `FileWriter` flow so callers can create a virtual dataset. It adds a new `DatasetBuilder` method and the corresponding serialization of a Data Layout v4 class=3 message.
**Files:**
- Modify: `crates/clawhdf5-format/src/file_writer.rs` (add `with_virtual_sources`)
**Interfaces:**
- Produces: `DatasetBuilder::with_virtual_sources(mappings: Vec<VdsMapping>) -> &mut Self`
**Binary — Data Layout v4 class=3 (Virtual):**
```
version(1)=4 class(1)=3
global_heap_address(offset_size) global_heap_index(4)
```
The global heap object holds the `serialize_vds_mappings()` output. The `global_heap_address` is the address of the global heap collection; `global_heap_index` is the 1-based object index within it. Use index=1 for the first (and only) VDS object.
- [x] **Step 1: Write the failing test**
In `crates/clawhdf5-format/src/file_writer.rs` `#[cfg(test)]` block, add:
```rust
#[test]
fn virtual_dataset_write_roundtrip() {
use crate::data_layout::DataLayout;
use crate::data_layout::{VdsMapping, parse_vds_mappings};
use crate::message_type::MessageType;
use crate::object_header::ObjectHeader;
use crate::signature::find_signature;
use crate::superblock::Superblock;
// Minimal ALL-selection bytes (same as in data_layout_write tests)
let sel: Vec<u8> = {
let mut v = Vec::new();
v.extend_from_slice(&3u32.to_le_bytes()); // H5S_SEL_ALL
v.push(1); v.push(0);
v.extend_from_slice(&[0u8; 4]);
v
};
let mappings = vec![VdsMapping {
source_file_name: "src.h5".into(),
source_dataset_name: "/raw".into(),
source_selection: sel.clone(),
virtual_selection: sel.clone(),
}];
let mut fw = FileWriter::new();
fw.create_dataset("virtual_ds")
.with_virtual_sources(mappings);
let bytes = fw.finish().unwrap();
// Parse back
let sig = find_signature(&bytes).unwrap();
let sb = Superblock::parse(&bytes, sig).unwrap();
let root_oh = ObjectHeader::parse(
&bytes, sb.root_group_address as usize, sb.offset_size, sb.length_size,
).unwrap();
// Find the dataset via group traversal, then get its DataLayout message
use crate::group_v2::resolve_path_any;
let ds_addr = resolve_path_any(&bytes, &sb, "virtual_ds").unwrap();
let ds_oh = ObjectHeader::parse(
&bytes, ds_addr as usize, sb.offset_size, sb.length_size,
).unwrap();
let dl_msg = ds_oh.messages.iter()
.find(|m| m.msg_type == MessageType::DataLayout)
.expect("DataLayout message missing");
let layout = DataLayout::parse(&dl_msg.data, sb.offset_size, sb.length_size).unwrap();
assert!(
matches!(layout, DataLayout::Virtual { .. }),
"expected Virtual layout, got {layout:?}"
);
}
```
- [x] **Step 2: Run test to verify it fails**
```bash
cargo test -p clawhdf5-format virtual_dataset_write_roundtrip 2>&1 | head -20
```
Expected: compile error — `with_virtual_sources` not found.
- [x] **Step 3: Add with_virtual_sources to DatasetBuilder**
Find `DatasetBuilder` in `file_writer.rs`. Add a field `virtual_sources: Option<Vec<VdsMapping>>` and the method:
```rust
use crate::data_layout::VdsMapping;
// In DatasetBuilder struct:
virtual_sources: Option<Vec<VdsMapping>>,
// In DatasetBuilder impl:
pub fn with_virtual_sources(&mut self, mappings: Vec<VdsMapping>) -> &mut Self {
self.virtual_sources = Some(mappings);
self
}
```
- [x] **Step 4: Serialize the virtual data layout**
In the `DatasetBuilder::build()` or equivalent finish method, add a branch for virtual datasets:
```rust
use crate::data_layout_write::serialize_vds_mappings;
// Where the DataLayout message bytes are generated:
let layout_bytes = if let Some(mappings) = &self.virtual_sources {
// Serialize VDS mappings into a global heap object
let heap_data = serialize_vds_mappings(mappings, length_size);
let (heap_addr, heap_idx) = write_global_heap_object(output_buf, &heap_data);
// Data Layout v4 class=3 (Virtual): version(1)=4, class(1)=3, addr(offset_size), idx(4)
let mut dl = Vec::new();
dl.push(4u8); // version
dl.push(3u8); // class = Virtual
write_offset_val(&mut dl, heap_addr, offset_size);
dl.extend_from_slice(&(heap_idx as u32).to_le_bytes());
dl
} else {
// existing layout code (contiguous/compact/chunked)
build_existing_layout(...)
};
```
Implement `write_global_heap_object` as a helper that appends a minimal global heap collection to the output buffer and returns `(address, object_index)`:
```rust
/// Append a single-object global heap collection to `buf` and return
/// (collection_address, object_index=1).
fn write_global_heap_object(buf: &mut Vec<u8>, data: &[u8]) -> (u64, usize) {
let addr = buf.len() as u64;
// Global Heap Collection header: sig(4) + version(1) + reserved(3) + collection_size(8)
// Object: index(2) + ref_count(2) + reserved(4) + data_size(8) + data + padding
let obj_size = data.len();
let padded = (obj_size + 7) & !7;
let collection_size = 16 + 16 + padded + 8; // header + one obj header + data + sentinel
buf.extend_from_slice(b"GCOL"); // signature
buf.push(1); // version
buf.extend_from_slice(&[0u8; 3]); // reserved
buf.extend_from_slice(&(collection_size as u64).to_le_bytes());
// Object 1
buf.extend_from_slice(&1u16.to_le_bytes()); // index
buf.extend_from_slice(&1u16.to_le_bytes()); // ref_count
buf.extend_from_slice(&[0u8; 4]); // reserved
buf.extend_from_slice(&(obj_size as u64).to_le_bytes());
buf.extend_from_slice(data);
// Pad to 8-byte boundary
let pad = padded - obj_size;
buf.extend_from_slice(&vec![0u8; pad]);
// Sentinel object (index=0)
buf.extend_from_slice(&[0u8; 8]); // index=0 + ref_count + reserved
buf.extend_from_slice(&0u64.to_le_bytes()); // size=0
(addr, 1)
}
```
- [x] **Step 5: Run the test**
```bash
cargo test -p clawhdf5-format virtual_dataset_write_roundtrip 2>&1
```
Expected: PASS (or iterate on the global heap format until `parse_vds_mappings` reads back the mappings).
- [x] **Step 6: Run full suite**
```bash
cargo test -p clawhdf5-format 2>&1 | tail -10
```
Expected: all tests pass.
- [x] **Step 7: Commit**
```bash
git add crates/clawhdf5-format/src/file_writer.rs
git commit -m "feat: add DatasetBuilder::with_virtual_sources for writing VDS data layout"
```
---
### Task 4: Superblock v4 read support
**Background:** `Superblock::parse()` in `superblock.rs:178–183` returns `Err(FormatError::UnsupportedVersion(v))` for any version ≥ 4. Superblock v4 (introduced with HDF5 2.x page-buffering) shares the same 12-byte header as v2/v3 (`sig + version + offset_size + length_size + consistency_flags`) and the same four address fields, but adds a `page_size: u32` field before the trailing checksum.
**Files:**
- Modify: `crates/clawhdf5-format/src/superblock.rs`
**Interfaces:**
- Consumes/produces: `Superblock` struct — add `pub page_size: Option<u32>` field.
- [x] **Step 1: Add field to Superblock struct**
In `crates/clawhdf5-format/src/superblock.rs`, add to the `Superblock` struct:
```rust
/// Page size for page-buffer mode (v4 only). `None` for v0–v3.
pub page_size: Option<u32>,
```
Update all existing construction sites of `Superblock { ... }` in the file (parse_v0, parse_v1, parse_v2v3) to include `page_size: None`.
- [x] **Step 2: Write the failing test**
In the `#[cfg(test)]` section of `superblock.rs`, add:
```rust
#[test]
fn parse_v4_with_page_size() {
// Superblock v4 = v2/v3 layout + page_size(4) before checksum.
let mut buf = Vec::new();
buf.extend_from_slice(&crate::signature::HDF5_SIGNATURE);
buf.push(4); // version = 4
buf.push(8); // offset_size
buf.push(8); // length_size
buf.push(0); // consistency_flags
// base_address
buf.extend_from_slice(&0u64.to_le_bytes());
// superblock_extension_address = UNDEF
buf.extend_from_slice(&u64::MAX.to_le_bytes());
// eof_address
buf.extend_from_slice(&512u64.to_le_bytes());
// root_group_address
buf.extend_from_slice(&96u64.to_le_bytes());
// page_size (v4 addition before checksum)
buf.extend_from_slice(&4096u32.to_le_bytes());
// checksum (4 bytes; compute with jenkins_lookup3)
let checksum = crate::checksum::jenkins_lookup3(&buf);
buf.extend_from_slice(&checksum.to_le_bytes());
let sb = Superblock::parse(&buf, 0).unwrap();
assert_eq!(sb.version, 4);
assert_eq!(sb.offset_size, 8);
assert_eq!(sb.eof_address, 512);
assert_eq!(sb.page_size, Some(4096));
}
```
- [x] **Step 3: Run test to verify it fails**
```bash
cargo test -p clawhdf5-format parse_v4_with_page_size 2>&1 | head -20
```
Expected: `Err(UnsupportedVersion(4))` — the test fails because v4 isn't handled.
- [x] **Step 4: Add parse_v4 branch**
In `Superblock::parse()`, change:
```rust
2 | 3 => Self::parse_v2v3(d, version),
v => Err(FormatError::UnsupportedVersion(v)),
```
to:
```rust
2 | 3 => Self::parse_v2v3(d, version),
4 => Self::parse_v4(d),
v => Err(FormatError::UnsupportedVersion(v)),
```
Add the implementation:
```rust
fn parse_v4(d: &[u8]) -> Result<Superblock, FormatError> {
// Same as v2/v3 header, then page_size(4), then checksum(4).
ensure_len(d, 12)?;
let offset_size = d[9];
let length_size = d[10];
validate_sizes(offset_size, length_size)?;
let consistency_flags = d[11] as u32;
let os = offset_size as usize;
// 4 addresses + page_size(4) + checksum(4)
let total = 12 + 4 * os + 4 + 4;
ensure_len(d, total)?;
let mut pos = 12;
let base_address = read_offset(d, pos, offset_size)?;
pos += os;
let superblock_extension_address = read_offset(d, pos, offset_size)?;
pos += os;
let eof_address = read_offset(d, pos, offset_size)?;
pos += os;
let root_group_address = read_offset(d, pos, offset_size)?;
pos += os;
let page_size = u32::from_le_bytes([d[pos], d[pos+1], d[pos+2], d[pos+3]]);
pos += 4;
let stored_checksum = u32::from_le_bytes([d[pos], d[pos+1], d[pos+2], d[pos+3]]);
let computed = crate::checksum::jenkins_lookup3(&d[..pos]);
if stored_checksum != computed {
return Err(FormatError::ChecksumMismatch {
expected: stored_checksum,
computed,
});
}
Ok(Superblock {
version: 4,
offset_size,
length_size,
base_address,
eof_address,
root_group_address,
group_leaf_node_k: None,
group_internal_node_k: None,
indexed_storage_internal_node_k: None,
free_space_address: None,
driver_info_address: None,
consistency_flags,
superblock_extension_address: Some(superblock_extension_address),
checksum: Some(stored_checksum),
page_size: Some(page_size),
})
}
```
- [x] **Step 5: Run the test**
```bash
cargo test -p clawhdf5-format parse_v4_with_page_size 2>&1
```
Expected: PASS (verify the checksum field name matches whatever `FormatError` uses — it may be `ChecksumMismatch { expected, computed }` or similar; find it in `error.rs` and match).
- [x] **Step 6: Run full suite**
```bash
cargo test -p clawhdf5-format 2>&1 | tail -10
```
Expected: all tests pass.
- [x] **Step 7: Commit**
```bash
git add crates/clawhdf5-format/src/superblock.rs
git commit -m "feat: parse HDF5 superblock v4 (page-buffer mode) with page_size field"
```
---
### Task 5: Superblock v4 write support
**Background:** The `FileWriter` always writes a v3 superblock (hardcoded in `file_writer.rs:1291–1306`). This task adds an optional `page_size` to `FileWriter` that, when set, emits a v4 superblock.
**Files:**
- Modify: `crates/clawhdf5-format/src/file_writer.rs` (add `page_size` field)
- Modify: `crates/clawhdf5-format/src/superblock.rs` (`Superblock::serialize` for v4)
**Interfaces:**
- Produces: `FileWriter::with_page_size(page_size: u32) -> &mut Self`
- [x] **Step 1: Write the failing test**
In the `#[cfg(test)]` block of `file_writer.rs`, add:
```rust
#[test]
fn file_writer_v4_superblock() {
use crate::signature::find_signature;
use crate::superblock::Superblock;
let mut fw = FileWriter::new();
fw.with_page_size(4096);
fw.create_dataset("data").with_f64_data(&[1.0, 2.0]);
let bytes = fw.finish().unwrap();
let sig = find_signature(&bytes).unwrap();
let sb = Superblock::parse(&bytes, sig).unwrap();
assert_eq!(sb.version, 4, "expected superblock v4");
assert_eq!(sb.page_size, Some(4096));
}
```
- [x] **Step 2: Run test to verify it fails**
```bash
cargo test -p clawhdf5-format file_writer_v4_superblock 2>&1 | head -20
```
Expected: compile error — `with_page_size` not found.
- [x] **Step 3: Add page_size field to FileWriter**
In `FileWriter` struct definition, add `page_size: Option<u32>`.
In `FileWriter::new()`, add `page_size: None`.
Add method:
```rust
pub fn with_page_size(&mut self, page_size: u32) -> &mut Self {
self.page_size = Some(page_size);
self
}
```
- [x] **Step 4: Update Superblock::serialize for v4**
In `crates/clawhdf5-format/src/superblock.rs`, the `serialize()` method currently hardcodes v2/v3 format. Update it to emit v4 when `self.version == 4` and `self.page_size.is_some()`:
```rust
pub fn serialize(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(60);
buf.extend_from_slice(&HDF5_SIGNATURE);
buf.push(self.version);
buf.push(self.offset_size);
buf.push(self.length_size);
buf.push(self.consistency_flags as u8);
Self::write_offset(&mut buf, self.base_address, self.offset_size);
let ext_addr = self.superblock_extension_address.unwrap_or(u64::MAX);
Self::write_offset(&mut buf, ext_addr, self.offset_size);
Self::write_offset(&mut buf, self.eof_address, self.offset_size);
Self::write_offset(&mut buf, self.root_group_address, self.offset_size);
if self.version >= 4 {
let ps = self.page_size.unwrap_or(0);
buf.extend_from_slice(&ps.to_le_bytes());
}
let checksum = crate::checksum::jenkins_lookup3(&buf);
buf.extend_from_slice(&checksum.to_le_bytes());
buf
}
```
- [x] **Step 5: Wire page_size into FileWriter::finish()**
In `file_writer.rs:finish()`, where the `Superblock` is constructed (around line 1291), change:
```rust
let sb = Superblock {
version: if self.page_size.is_some() { 4 } else { 3 },
// ... existing fields ...
page_size: self.page_size,
// ... rest of fields unchanged ...
};
```
- [x] **Step 6: Run the test**
```bash
cargo test -p clawhdf5-format file_writer_v4_superblock 2>&1
```
Expected: PASS.
- [x] **Step 7: Run full suite**
```bash
cargo test -p clawhdf5-format 2>&1 | tail -10
```
Expected: all tests pass (v3 serialize() must be byte-identical to before — add a regression test if needed).
- [x] **Step 8: Commit**
```bash
git add crates/clawhdf5-format/src/file_writer.rs \
crates/clawhdf5-format/src/superblock.rs
git commit -m "feat: write HDF5 superblock v4 when page_size is configured"
```
---
## Verification
```bash
# Run all format tests
cargo test -p clawhdf5-format 2>&1 | tail -10
# Specifically verify new features
cargo test -p clawhdf5-format external_link_write_roundtrip 2>&1
cargo test -p clawhdf5-format data_layout_write 2>&1
cargo test -p clawhdf5-format virtual_dataset_write_roundtrip 2>&1
cargo test -p clawhdf5-format parse_v4_with_page_size 2>&1
cargo test -p clawhdf5-format file_writer_v4_superblock 2>&1
# Regression: v3 superblock still round-trips
cargo test -p clawhdf5-format write_superblock 2>&1
```
@@ -0,0 +1,759 @@
# MPI-IO VOL Backend Implementation Plan
> **Status (2026-08-03):** Implemented — shipped in commit `d6c4d4f` (2026-06-30), with FFI/constant fixes in `cb0b0e9`/`e91f7fc`. This doc was authored 2026-06-29 as the pre-work plan and committed to the repo retroactively on 2026-08-03; checkboxes below have been marked complete to match. Treat this as a historical record, not an open task list.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an `MpiVol` backend to `clawhdf5-io` that implements `VirtualObjectLayer` with `VolCapability::ParallelIO`, enabling collective MPI-IO reads and writes against HDF5 files — the same I/O pattern used by h5bench parallel workloads.
**Architecture:** A new `crates/clawhdf5-io/src/mpi_vol.rs` module implements `VirtualObjectLayer` using the `rsmpi` crate for MPI bindings. Reads distribute file chunks across MPI ranks via `MPI_File_read_at` collective; writes gather chunk contributions from all ranks and commit atomically. The `mpi-io` feature flag keeps MPI an optional dependency — without it, the file does not compile in, maintaining the zero-required-dependency promise.
**Tech Stack:** `rsmpi = "0.8"` (or latest; the safe Rust MPI binding), `mpi-io` feature flag in `clawhdf5-io`.
## Global Constraints
- All changes in `crates/clawhdf5-io/`.
- `mpi-io` feature is disabled by default; `cargo test -p clawhdf5-io` without features must still pass.
- `MpiVol` must not link MPI unless `mpi-io` feature is active.
- Tests that require an actual MPI environment are gated with `#[cfg(feature = "mpi-io")]` and ignored by default CI (no `#[ignore]`; they fail to compile without the feature).
- Run `cargo test -p clawhdf5-io` after every task.
- Run `cargo check -p clawhdf5-io --features mpi-io` to validate the feature-enabled path without needing MPI installed.
---
### Task 1: Add mpi-io feature and MpiVol skeleton
**Files:**
- Modify: `crates/clawhdf5-io/Cargo.toml`
- Create: `crates/clawhdf5-io/src/mpi_vol.rs`
- Modify: `crates/clawhdf5-io/src/lib.rs`
**Interfaces:**
- Produces:
- `pub struct MpiVol` (implements `VirtualObjectLayer`)
- `MpiVol::new(comm: impl Into<MpiComm>) -> Self` — wraps an MPI communicator
- `MpiVol::new_world() -> Self` — convenience for `MPI_COMM_WORLD`
- [x] **Step 1: Write failing tests**
Create `crates/clawhdf5-io/src/mpi_vol.rs`:
```rust
//! MPI-IO VOL connector for parallel HDF5 reads and writes.
//!
//! Enable with the `mpi-io` feature: `cargo build --features mpi-io`.
//!
//! # Parallelism model
//!
//! All ranks open the same file path. Reads are collective: the root rank
//! dispatches chunk byte ranges; each rank fetches its portion via
//! `MPI_File_read_at`. Writes are collective: each rank submits its chunk
//! contribution; the root commits the merged result atomically.
use crate::vol::{VolCapability, VolError, VirtualObjectLayer};
#[cfg(feature = "mpi-io")]
use mpi::traits::*;
/// Rank within the communicator.
type Rank = i32;
/// MPI-IO Virtual Object Layer connector.
///
/// Wraps an MPI communicator for collective HDF5 file I/O.
pub struct MpiVol {
location: Option<String>,
#[cfg(feature = "mpi-io")]
universe: mpi::environment::Universe,
#[cfg(not(feature = "mpi-io"))]
_placeholder: (),
}
impl MpiVol {
/// Create an `MpiVol` using `MPI_COMM_WORLD`.
///
/// Initializes MPI if not already initialized. Call once per process.
#[cfg(feature = "mpi-io")]
pub fn new_world() -> Result<Self, VolError> {
let universe = mpi::initialize()
.ok_or_else(|| VolError::Unsupported("MPI already finalized or init failed".into()))?;
Ok(Self {
location: None,
universe,
})
}
/// Stub for when the feature is disabled.
#[cfg(not(feature = "mpi-io"))]
pub fn new_world() -> Result<Self, VolError> {
Err(VolError::Unsupported(
"MPI-IO support requires the `mpi-io` feature".into(),
))
}
/// Returns the MPI rank within COMM_WORLD (0-based).
///
/// Returns 0 when MPI is not available.
pub fn rank(&self) -> Rank {
#[cfg(feature = "mpi-io")]
{
self.universe.world().rank()
}
#[cfg(not(feature = "mpi-io"))]
{
0
}
}
/// Returns the total number of MPI processes.
///
/// Returns 1 when MPI is not available.
pub fn size(&self) -> Rank {
#[cfg(feature = "mpi-io")]
{
self.universe.world().size()
}
#[cfg(not(feature = "mpi-io"))]
{
1
}
}
}
impl VirtualObjectLayer for MpiVol {
fn name(&self) -> &str {
"mpi-io"
}
fn capabilities(&self) -> Vec<VolCapability> {
vec![
VolCapability::ReadData,
VolCapability::WriteData,
VolCapability::ListObjects,
VolCapability::ChunkedStorage,
VolCapability::ParallelIO,
]
}
fn open(&mut self, location: &str) -> Result<(), VolError> {
self.location = Some(location.to_string());
Ok(())
}
fn close(&mut self) -> Result<(), VolError> {
self.location = None;
Ok(())
}
fn read_dataset(&self, path: &str) -> Result<Vec<u8>, VolError> {
let _loc = self.location.as_deref().ok_or_else(|| {
VolError::Io(std::io::Error::new(std::io::ErrorKind::NotConnected, "file not open"))
})?;
#[cfg(feature = "mpi-io")]
{
mpi_collective_read(self, _loc, path)
}
#[cfg(not(feature = "mpi-io"))]
{
Err(VolError::Unsupported("mpi-io feature not enabled".into()))
}
}
fn write_dataset(
&mut self,
path: &str,
data: &[u8],
shape: &[u64],
dtype: &str,
) -> Result<(), VolError> {
let _loc = self.location.as_deref().ok_or_else(|| {
VolError::Io(std::io::Error::new(std::io::ErrorKind::NotConnected, "file not open"))
})?;
#[cfg(feature = "mpi-io")]
{
mpi_collective_write(self, _loc, path, data, shape, dtype)
}
#[cfg(not(feature = "mpi-io"))]
{
Err(VolError::Unsupported("mpi-io feature not enabled".into()))
}
}
}
/// Collective read: root reads the file, broadcasts the target dataset to all ranks.
#[cfg(feature = "mpi-io")]
fn mpi_collective_read(vol: &MpiVol, location: &str, path: &str) -> Result<Vec<u8>, VolError> {
use mpi::traits::*;
use clawhdf5_format::{
data_layout::DataLayout,
data_read::read_raw_data_full,
dataspace::Dataspace,
datatype::Datatype,
filter_pipeline::FilterPipeline,
group_v2::resolve_path_any,
message_type::MessageType,
object_header::ObjectHeader,
signature::find_signature,
superblock::Superblock,
};
let world = vol.universe.world();
let rank = world.rank();
// All ranks attempt the read; root broadcasts the result.
// For true MPI-IO, use MPI_File_open + MPI_File_read_at_all here.
let raw_data: Vec<u8>;
let mut len_buf = [0usize; 1];
if rank == 0 {
let bytes = std::fs::read(location)
.map_err(|e| VolError::Io(e))?;
let sig = find_signature(&bytes).map_err(|e| VolError::DataError(e.to_string()))?;
let sb = Superblock::parse(&bytes, sig).map_err(|e| VolError::DataError(e.to_string()))?;
let addr = resolve_path_any(&bytes, &sb, path)
.map_err(|e| VolError::NotFound(format!("{path}: {e}")))?;
let oh = ObjectHeader::parse(&bytes, addr as usize, sb.offset_size, sb.length_size)
.map_err(|e| VolError::DataError(e.to_string()))?;
let dt = oh.messages.iter().find(|m| m.msg_type == MessageType::Datatype)
.ok_or_else(|| VolError::DataError("no datatype".into()))?;
let (datatype, _) = Datatype::parse(&dt.data).map_err(|e| VolError::DataError(e.to_string()))?;
let ds = oh.messages.iter().find(|m| m.msg_type == MessageType::Dataspace)
.ok_or_else(|| VolError::DataError("no dataspace".into()))?;
let dataspace = Dataspace::parse(&ds.data, sb.length_size)
.map_err(|e| VolError::DataError(e.to_string()))?;
let dl = oh.messages.iter().find(|m| m.msg_type == MessageType::DataLayout)
.ok_or_else(|| VolError::DataError("no data layout".into()))?;
let layout = DataLayout::parse(&dl.data, sb.offset_size, sb.length_size)
.map_err(|e| VolError::DataError(e.to_string()))?;
let pipeline = oh.messages.iter()
.find(|m| m.msg_type == MessageType::FilterPipeline)
.and_then(|m| FilterPipeline::parse(&m.data).ok());
raw_data = read_raw_data_full(
&bytes, &layout, &dataspace, &datatype, pipeline.as_ref(),
sb.offset_size, sb.length_size,
).map_err(|e| VolError::DataError(e.to_string()))?;
len_buf[0] = raw_data.len();
} else {
raw_data = Vec::new();
}
// Broadcast length then data
world.process_at_rank(0).broadcast_into(&mut len_buf);
let mut result = vec![0u8; len_buf[0]];
if rank == 0 {
result.copy_from_slice(&raw_data);
}
world.process_at_rank(0).broadcast_into(&mut result);
Ok(result)
}
/// Collective write: rank 0 accumulates all contributions and writes atomically.
///
/// In a real parallel workload each rank provides its own data shard for a
/// different hyperslab. Here we demonstrate the pattern: all ranks send their
/// data to rank 0 which stitches and writes.
#[cfg(feature = "mpi-io")]
fn mpi_collective_write(
vol: &MpiVol,
location: &str,
path: &str,
data: &[u8],
shape: &[u64],
dtype: &str,
) -> Result<(), VolError> {
use mpi::traits::*;
use clawhdf5_format::file_writer::FileWriter as FmtWriter;
let world = vol.universe.world();
let size = world.size() as usize;
// Each rank sends its data length to root
let local_len = data.len();
let mut all_lens = if world.rank() == 0 { vec![0usize; size] } else { Vec::new() };
world.process_at_rank(0).gather_into_root(&local_len, &mut all_lens);
// Gather all data at root
let total: usize = if world.rank() == 0 {
all_lens.iter().sum()
} else {
0
};
// Root collects all contributions and writes
if world.rank() == 0 {
let mut merged = Vec::with_capacity(total);
// Rank 0's own contribution first
merged.extend_from_slice(data);
// Receive from ranks 1..size
for r in 1..size as i32 {
let expected = all_lens[r as usize];
let mut buf = vec![0u8; expected];
world.process_at_rank(r).receive_into(&mut buf);
merged.extend_from_slice(&buf);
}
// Write merged data via FileWriter
let mut fw = FmtWriter::new();
match dtype {
"f64" => {
let values: Vec<f64> = merged.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.collect();
fw.create_dataset(path).with_f64_data(&values);
}
"f32" => {
let values: Vec<f32> = merged.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.collect();
fw.create_dataset(path).with_f32_data(&values);
}
_ => {
return Err(VolError::Unsupported(format!("mpi-io write: unsupported dtype {dtype}")));
}
}
let bytes = fw.finish().map_err(|e| VolError::DataError(e.to_string()))?;
std::fs::write(location, &bytes).map_err(VolError::Io)?;
} else {
// Non-root ranks send their data to root
world.process_at_rank(0).send(data);
}
// Barrier: all ranks wait until root finishes writing
world.barrier();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mpi_vol_no_feature_returns_unsupported() {
#[cfg(not(feature = "mpi-io"))]
{
let result = MpiVol::new_world();
assert!(
matches!(result, Err(VolError::Unsupported(_))),
"expected Unsupported error without mpi-io feature"
);
}
#[cfg(feature = "mpi-io")]
{
// With MPI enabled, new_world() may succeed if MPI is installed.
// Just verify it doesn't panic.
let _ = MpiVol::new_world();
}
}
#[test]
fn mpi_vol_capabilities_include_parallel_io() {
// Even without feature, the struct can be inspected via the default stub.
// The capabilities list is compile-time constant so test it directly.
let caps = vec![
VolCapability::ReadData,
VolCapability::WriteData,
VolCapability::ListObjects,
VolCapability::ChunkedStorage,
VolCapability::ParallelIO,
];
assert!(caps.contains(&VolCapability::ParallelIO));
}
#[test]
fn rank_and_size_stub_values() {
#[cfg(not(feature = "mpi-io"))]
{
// The constructor itself returns Err without the feature,
// so we can't instantiate MpiVol here. Verify the error message.
let e = MpiVol::new_world().unwrap_err();
assert!(e.to_string().contains("mpi-io"));
}
}
}
```
- [x] **Step 2: Run tests to verify they fail**
```bash
cargo test -p clawhdf5-io mpi_vol 2>&1 | head -20
```
Expected: compile error (module not declared). That's the expected failure.
- [x] **Step 3: Add Cargo.toml feature and rsmpi dependency**
In `crates/clawhdf5-io/Cargo.toml`, add to `[dependencies]`:
```toml
mpi = { version = "0.8", optional = true }
```
Add to `[features]`:
```toml
mpi-io = ["mpi"]
```
- [x] **Step 4: Declare module in lib.rs**
In `crates/clawhdf5-io/src/lib.rs`, add:
```rust
pub mod mpi_vol;
pub use mpi_vol::MpiVol;
```
- [x] **Step 5: Run tests without mpi-io feature**
```bash
cargo test -p clawhdf5-io 2>&1 | tail -15
```
Expected: `mpi_vol_no_feature_returns_unsupported` and `mpi_vol_capabilities_include_parallel_io` PASS.
- [x] **Step 6: Check compilation with mpi-io feature (requires MPI headers)**
```bash
# Install MPI if needed: sudo apt install libopenmpi-dev
cargo check -p clawhdf5-io --features mpi-io 2>&1 | tail -20
```
Expected: clean compile (warnings OK; errors not OK).
- [x] **Step 7: Commit**
```bash
git add crates/clawhdf5-io/Cargo.toml \
crates/clawhdf5-io/src/mpi_vol.rs \
crates/clawhdf5-io/src/lib.rs
git commit -m "feat: add MpiVol VOL backend with collective MPI-IO (mpi-io feature)"
```
---
### Task 2: MPI-IO collective read integration test
**Background:** This test requires an MPI runtime (`mpirun`). It is gated by the `mpi-io` feature and validates that all MPI ranks receive identical data after a collective read.
**Files:**
- Modify: `crates/clawhdf5-io/src/mpi_vol.rs` (add integration test)
- [x] **Step 1: Add the integration test**
Inside the `#[cfg(test)]` block, add:
```rust
#[test]
#[cfg(feature = "mpi-io")]
fn collective_read_all_ranks_get_same_data() {
use crate::vol::VirtualObjectLayer;
use tempfile::TempDir;
// Write a reference file using FileWriter (no MPI needed)
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("test.h5");
{
use clawhdf5_format::file_writer::FileWriter as FmtWriter;
let mut fw = FmtWriter::new();
fw.create_dataset("temperature")
.with_f64_data(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let bytes = fw.finish().unwrap();
std::fs::write(&path, &bytes).unwrap();
}
// Each rank reads via MpiVol and should get the same bytes
let mut vol = MpiVol::new_world().expect("MPI init failed");
vol.open(path.to_str().unwrap()).unwrap();
let data = vol.read_dataset("temperature").unwrap();
// 5 f64 values = 40 bytes
assert_eq!(data.len(), 40, "rank {} got {} bytes", vol.rank(), data.len());
let values: Vec<f64> = data.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.collect();
assert_eq!(values, vec![1.0, 2.0, 3.0, 4.0, 5.0],
"rank {} got wrong data", vol.rank());
}
```
Add to `Cargo.toml` dev-dependencies:
```toml
tempfile = "3"
```
- [x] **Step 2: Run without MPI feature (should compile-skip)**
```bash
cargo test -p clawhdf5-io 2>&1 | tail -10
```
Expected: all tests pass; `collective_read_all_ranks_get_same_data` is not compiled.
- [x] **Step 3: Run with MPI feature (requires mpirun)**
```bash
# Requires: sudo apt install libopenmpi-dev openmpi-bin
# cargo test compiles, then:
mpirun -np 4 cargo test -p clawhdf5-io --features mpi-io collective_read_all_ranks_get_same_data 2>&1
```
Expected: all 4 ranks PASS.
- [x] **Step 4: Commit**
```bash
git add crates/clawhdf5-io/src/mpi_vol.rs \
crates/clawhdf5-io/Cargo.toml
git commit -m "feat: add MpiVol collective read integration test"
```
---
### Task 3: MPI-IO collective write integration test
**Background:** Validates that N ranks each contribute a shard of a dataset; rank 0 assembles and writes the complete file.
**Files:**
- Modify: `crates/clawhdf5-io/src/mpi_vol.rs`
- [x] **Step 1: Add the integration test**
```rust
#[test]
#[cfg(feature = "mpi-io")]
fn collective_write_assembles_all_shards() {
use crate::vol::VirtualObjectLayer;
use tempfile::TempDir;
use mpi::traits::*;
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("parallel_out.h5");
let mut vol = MpiVol::new_world().expect("MPI init failed");
vol.open(path.to_str().unwrap()).unwrap();
let world = vol.universe.world();
let rank = world.rank() as usize;
// Each rank contributes one f64 value: rank * 10.0
let shard = ((rank as f64) * 10.0f64).to_le_bytes().to_vec();
vol.write_dataset("values", &shard, &[world.size() as u64], "f64")
.unwrap();
// All ranks verify the written file has 4 values (one per rank)
let total_size = world.size() as usize;
if rank == 0 {
let bytes = std::fs::read(&path).unwrap();
use clawhdf5_format::{
data_layout::DataLayout, data_read::read_raw_data_full,
dataspace::Dataspace, datatype::Datatype,
group_v2::resolve_path_any, message_type::MessageType,
object_header::ObjectHeader, signature::find_signature,
superblock::Superblock,
};
let sig = find_signature(&bytes).unwrap();
let sb = Superblock::parse(&bytes, sig).unwrap();
let addr = resolve_path_any(&bytes, &sb, "values").unwrap();
let oh = ObjectHeader::parse(
&bytes, addr as usize, sb.offset_size, sb.length_size,
).unwrap();
let (dt, _) = Datatype::parse(
&oh.messages.iter().find(|m| m.msg_type == MessageType::Datatype).unwrap().data,
).unwrap();
let ds = Dataspace::parse(
&oh.messages.iter().find(|m| m.msg_type == MessageType::Dataspace).unwrap().data,
sb.length_size,
).unwrap();
let dl = DataLayout::parse(
&oh.messages.iter().find(|m| m.msg_type == MessageType::DataLayout).unwrap().data,
sb.offset_size, sb.length_size,
).unwrap();
let raw = read_raw_data_full(
&bytes, &dl, &ds, &dt, None, sb.offset_size, sb.length_size,
).unwrap();
assert_eq!(raw.len(), total_size * 8, "expected {} f64 values", total_size);
let values: Vec<f64> = raw.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.collect();
for (i, &v) in values.iter().enumerate() {
assert!((v - (i as f64 * 10.0)).abs() < 1e-9,
"rank {i} shard wrong: got {v}");
}
}
world.barrier();
}
```
- [x] **Step 2: Run**
```bash
cargo test -p clawhdf5-io 2>&1 | tail -5 # no feature — should pass
mpirun -np 4 cargo test -p clawhdf5-io --features mpi-io collective_write 2>&1
```
- [x] **Step 3: Commit**
```bash
git add crates/clawhdf5-io/src/mpi_vol.rs
git commit -m "feat: add MpiVol collective write integration test (4 ranks)"
```
---
### Task 4: MpiVol parallel benchmark binary
**Background:** Adds a benchmark binary to `clawhdf5-bench` that runs h5bench-equivalent write/read workloads using `MpiVol`. This provides the throughput numbers needed to compare clawhdf5 against standard libhdf5 + h5bench.
**Files:**
- Create: `crates/clawhdf5-bench/src/bin/mpi_io_bench.rs`
- Modify: `crates/clawhdf5-bench/Cargo.toml` (add `mpi-io` feature, `mpi_io_bench` binary)
**Produces:** `cargo run -p clawhdf5-bench --features mpi-io --bin mpi_io_bench -- --size 100000` outputs MB/s throughput numbers comparable to h5bench output.
- [x] **Step 1: Create the binary**
Create `crates/clawhdf5-bench/src/bin/mpi_io_bench.rs`:
```rust
//! h5bench-equivalent MPI-IO performance benchmark.
//!
//! Usage: mpirun -np N cargo run -p clawhdf5-bench --features mpi-io --bin mpi_io_bench -- --size <N>
//!
//! Measures collective write and read throughput in MB/s for f64 arrays.
#[cfg(feature = "mpi-io")]
fn main() {
use clawhdf5_io::mpi_vol::MpiVol;
use clawhdf5_io::vol::VirtualObjectLayer;
use std::time::Instant;
use mpi::traits::*;
let args: Vec<String> = std::env::args().collect();
let n_elements: usize = args.iter()
.position(|a| a == "--size")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse().ok())
.unwrap_or(100_000);
let mut vol = MpiVol::new_world().expect("MPI init failed");
let world = vol.universe.world();
let rank = world.rank() as usize;
let size = world.size() as usize;
let path = format!("/tmp/clawhdf5_mpiio_bench_{n_elements}.h5");
vol.open(&path).unwrap();
// Each rank contributes n_elements/size f64 values
let per_rank = n_elements / size;
let shard: Vec<f64> = (0..per_rank).map(|i| (rank * per_rank + i) as f64).collect();
let shard_bytes: Vec<u8> = shard.iter().flat_map(|v| v.to_le_bytes()).collect();
// Collective write
world.barrier();
let t0 = Instant::now();
vol.write_dataset("data", &shard_bytes, &[n_elements as u64], "f64").unwrap();
world.barrier();
let write_elapsed = t0.elapsed().as_secs_f64();
// Collective read
let t1 = Instant::now();
let _data = vol.read_dataset("data").unwrap();
world.barrier();
let read_elapsed = t1.elapsed().as_secs_f64();
if rank == 0 {
let total_mb = (n_elements * 8) as f64 / 1e6;
println!("=== clawhdf5 MPI-IO Benchmark ===");
println!("Elements : {n_elements}");
println!("Ranks : {size}");
println!("Total : {total_mb:.1} MB");
println!("Write : {:.1} MB/s", total_mb / write_elapsed);
println!("Read : {:.1} MB/s", total_mb / read_elapsed);
}
}
#[cfg(not(feature = "mpi-io"))]
fn main() {
eprintln!("mpi_io_bench requires the `mpi-io` feature.");
eprintln!("Run: mpirun -np N cargo run -p clawhdf5-bench --features mpi-io --bin mpi_io_bench");
std::process::exit(1);
}
```
- [x] **Step 2: Add to Cargo.toml**
In `crates/clawhdf5-bench/Cargo.toml`, add:
```toml
[dependencies]
clawhdf5-io = { path = "../clawhdf5-io", features = [] }
[features]
mpi-io = ["clawhdf5-io/mpi-io", "mpi"]
[dependencies.mpi]
version = "0.8"
optional = true
[[bin]]
name = "mpi_io_bench"
path = "src/bin/mpi_io_bench.rs"
```
- [x] **Step 3: Verify it compiles**
```bash
cargo check -p clawhdf5-bench --features mpi-io 2>&1 | tail -10
```
Expected: no errors.
- [x] **Step 4: Run with 4 ranks**
```bash
mpirun -np 4 cargo run --release -p clawhdf5-bench --features mpi-io --bin mpi_io_bench -- --size 1000000 2>&1
```
Expected output (numbers will vary by hardware):
```
=== clawhdf5 MPI-IO Benchmark ===
Elements : 1000000
Ranks : 4
Total : 8.0 MB
Write : xxx.x MB/s
Read : xxx.x MB/s
```
Record results in `BENCHMARKS.md` under a new `## MPI-IO Parallel I/O` section.
- [x] **Step 5: Commit**
```bash
git add crates/clawhdf5-bench/src/bin/mpi_io_bench.rs \
crates/clawhdf5-bench/Cargo.toml
git commit -m "feat: add mpi_io_bench binary for h5bench-comparable parallel I/O throughput"
```
---
## Verification
```bash
# Without MPI feature — all existing tests still pass
cargo test -p clawhdf5-io 2>&1 | tail -10
# With MPI feature — compile check (requires libopenmpi-dev)
cargo check -p clawhdf5-io --features mpi-io 2>&1 | tail -5
# Integration tests (requires openmpi-bin)
mpirun -np 4 cargo test -p clawhdf5-io --features mpi-io 2>&1 | tail -20
# Benchmark (requires openmpi-bin)
mpirun -np 4 cargo run --release -p clawhdf5-bench --features mpi-io --bin mpi_io_bench -- --size 1000000 2>&1
```
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# CI check: verify rustyhdf5-format compiles under no_std (thumbv7em-none-eabihf).
# CI check: verify clawhdf5-format compiles under no_std (thumbv7em-none-eabihf).
#
# Usage:
# ./scripts/check-nostd.sh
@@ -11,7 +11,7 @@ set -euo pipefail
TARGET="thumbv7em-none-eabihf"
echo "==> Checking no_std build for rustyhdf5-format (target: $TARGET)"
echo "==> Checking no_std build for clawhdf5-format (target: $TARGET)"
# Ensure the target is installed
if ! rustup target list --installed | grep -q "$TARGET"; then
@@ -20,13 +20,13 @@ if ! rustup target list --installed | grep -q "$TARGET"; then
fi
# Build with no default features (no std, no flate2, no sha2)
cargo build --target "$TARGET" -p rustyhdf5-format --no-default-features
cargo build --target "$TARGET" -p clawhdf5-format --no-default-features
echo "==> no_std build succeeded"
# Also verify the default-features (std) build still works
echo "==> Checking default-features build"
cargo build -p rustyhdf5-format
cargo build -p clawhdf5-format
echo "==> default-features build succeeded"
echo "==> All no_std checks passed"
+4 -4
View File
@@ -34,16 +34,16 @@ run_step() {
# 1. Format check
run_step "cargo fmt --check" cargo fmt --check
# 2. Clippy (exclude rustyhdf5-py which needs PyO3/Python)
# 2. Clippy (exclude clawhdf5-py which needs PyO3/Python)
run_step "cargo clippy" cargo clippy \
--workspace \
--exclude rustyhdf5-py \
--exclude clawhdf5-py \
-- -D warnings
# 3. Tests (exclude rustyhdf5-py)
# 3. Tests (exclude clawhdf5-py)
run_step "cargo test" cargo test \
--workspace \
--exclude rustyhdf5-py
--exclude clawhdf5-py
# 4. no_std check
run_step "check-nostd.sh" "$SCRIPT_DIR/check-nostd.sh"