Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b16dc90d6 | ||
|
|
4a5544da1d | ||
|
|
f4c6d43a3f | ||
|
|
e5e087f9ab | ||
|
|
16c9ee0554 | ||
|
|
1d767e3b93 | ||
|
|
a91df3f1c3 | ||
|
|
b41272487a | ||
|
|
0bc7a293ae | ||
|
|
dea02f5214 | ||
|
|
97e65f2adf | ||
|
|
fb58300b3f | ||
|
|
eb196e824f | ||
|
|
367faad7f7 | ||
|
|
0901fb1499 | ||
|
|
e9aeb110b7 |
+50
-5
@@ -88,11 +88,31 @@ back**, because the loss is in the distances rather than in the graph
|
|||||||
|
|
||||||
Re-scoring closes the gap: the store already holds the exact embeddings, so
|
Re-scoring closes the gap: the store already holds the exact embeddings, so
|
||||||
the query path re-scores the candidate pool against them before fusion. That
|
the query path re-scores the candidate pool against them before fusion. That
|
||||||
is done automatically whenever the index is quantised. What it costs is
|
is done automatically whenever the index is quantised.
|
||||||
throughput — about 13% of QPS and 16% of build time at 100 000 x 384. So the
|
|
||||||
setting trades ~13% of query speed for ~36% of the process's memory at equal
|
**On AVX2 this costs nothing — it pays.** The first measurement of this put
|
||||||
recall. It is **off by default**: the right side of that trade depends on
|
the cost at ~13% of QPS and ~16% of build time, but that compared a scalar
|
||||||
whether the deployment is short of memory or short of CPU.
|
int8 loop against `clawhdf5-accel`'s hand-written AVX2 kernels for `f32`:
|
||||||
|
the gap was a missing kernel, not a property of int8. With
|
||||||
|
`clawhdf5_accel::dot_i8` (AVX2: sign-extend to `i16`, then `madd_epi16`),
|
||||||
|
medians of three alternating runs at N = 100 000, same binary:
|
||||||
|
|
||||||
|
| | f32 | int8 | int8 + re-score |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| build | 3197 ms | **1778 ms** | 1826 ms |
|
||||||
|
| QPS at ef = 64 | 13 399 | 29 195 | **21 848** |
|
||||||
|
| recall@10 at ef = 64 | 0.9945 | 0.9625 | **0.9940** |
|
||||||
|
|
||||||
|
So at equal recall the quantised index answers **1.63x as many queries per
|
||||||
|
second**, builds **1.8x faster**, and holds a quarter of the vectors. (Compare
|
||||||
|
only at equal `ef`: with re-scoring the harness raises `ef` to at least the
|
||||||
|
candidate pool, so the `ef = 16` and `ef = 32` rows are not like-for-like.)
|
||||||
|
|
||||||
|
It is still **off by default**, for portability rather than performance: the
|
||||||
|
int8 kernel is AVX2-only, and on aarch64 — including `clawhdf5-android` — it
|
||||||
|
falls back to the scalar loop, where the original trade still applies. A NEON
|
||||||
|
kernel would remove that caveat. On an x86-64 deployment, turning it on is a
|
||||||
|
win on every axis measured.
|
||||||
|
|
||||||
A measurement trap worth recording: the synthetic `clustered` generator in the
|
A measurement trap worth recording: the synthetic `clustered` generator in the
|
||||||
`clawhdf5-ann` tests draws clusters far tighter than any real embedding, so
|
`clawhdf5-ann` tests draws clusters far tighter than any real embedding, so
|
||||||
@@ -103,6 +123,31 @@ vectors, and recall is measured against brute-force ground truth rather than
|
|||||||
against the f32 index, whose own approximation errors a re-scored search is
|
against the f32 index, whose own approximation errors a re-scored search is
|
||||||
entitled to get right.
|
entitled to get right.
|
||||||
|
|
||||||
|
### Opening a store (`read_from_disk`)
|
||||||
|
|
||||||
|
`HDF5Memory::open` memory-mapped the file, copied the whole mapping into a
|
||||||
|
`Vec`, and handed that to `File::from_bytes` — while `File::open` memory-maps
|
||||||
|
the file itself. Dropping the copy takes **store open from 455 ms to 327 ms**
|
||||||
|
at 100 000 x 384 (`--e2e-only --full`; two runs after the change, 326.8 and
|
||||||
|
328.1 ms).
|
||||||
|
|
||||||
|
It does **not** lower the process's peak memory, which is worth stating
|
||||||
|
precisely because it is the obvious thing to assume. The harness now reports a
|
||||||
|
high-water mark alongside the retained figure:
|
||||||
|
|
||||||
|
| N | reopened MiB | peak during open MiB |
|
||||||
|
|---:|---:|---:|
|
||||||
|
| 1 000 | 4 | 5 |
|
||||||
|
| 10 000 | 44 | 61 |
|
||||||
|
| 100 000 | 399 | 562 |
|
||||||
|
|
||||||
|
The peak is set *after* the parse, by the index build, so a buffer allocated
|
||||||
|
and freed during the parse never reaches the high-water mark. Holding a
|
||||||
|
deliberate extra copy of the file across the whole parse leaves the peak
|
||||||
|
unmoved, which is how this was confirmed rather than assumed. What the change
|
||||||
|
saves is the copy itself: a full-file memcpy on every open, and the transient
|
||||||
|
that goes with it.
|
||||||
|
|
||||||
## Read harness
|
## Read harness
|
||||||
|
|
||||||
Produced by `cargo run --release -p clawhdf5-bench --bin read_harness`: a 4096 x
|
Produced by `cargo run --release -p clawhdf5-bench --bin read_harness`: a 4096 x
|
||||||
|
|||||||
+118
@@ -1,5 +1,123 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v2.7.0 (2026-09-20)
|
||||||
|
|
||||||
|
### Upgrade Notes
|
||||||
|
- **Two read-path bugs fixed, one of them silent.** Datasets indexed by an
|
||||||
|
Extensible Array (any dataset with one unlimited dimension) returned data
|
||||||
|
from the wrong chunks past their first few dozen. If you have readings taken
|
||||||
|
from such a dataset with an earlier release, they may be wrong; re-read them.
|
||||||
|
- **A corrupt chunk index is now an error.** Fixed and Extensible Array
|
||||||
|
structures carry checksums that were previously ignored, so damage surfaced
|
||||||
|
as plausible data from the wrong offset. Code that read a damaged file and
|
||||||
|
got numbers will now get `ChecksumMismatch` instead. That is the point.
|
||||||
|
- **Breaking:** `MemoryConfig` gained `hnsw_m`, `hnsw_ef_construction` and
|
||||||
|
`hnsw_ef_search`, so literal constructions need updating;
|
||||||
|
`..Default::default()` does not. All three default to the previous
|
||||||
|
behaviour.
|
||||||
|
|
||||||
|
### Correctness
|
||||||
|
- `clawhdf5-format`: **datasets indexed by an Extensible Array returned wrong
|
||||||
|
data beyond their first few dozen chunks.** One unlimited dimension gives a
|
||||||
|
dataset an Extensible Array chunk index, whose first elements (4 by default)
|
||||||
|
sit inline in the index block and whose rest live in data blocks sized by a
|
||||||
|
formula the reader got wrong. In the default layout everything through the
|
||||||
|
36th chunk happened to line up and the 37th onwards did not: a 400-chunk
|
||||||
|
dataset silently returned wrong values from chunk 37, and datasets past
|
||||||
|
about a thousand chunks failed outright with "invalid Extensible Array data
|
||||||
|
block signature". **Reads were wrong, not
|
||||||
|
merely refused** — the caller got plausible numbers from the wrong chunks.
|
||||||
|
Four separate layout errors, each checked against files written by HDF5 2.0
|
||||||
|
and against the library source:
|
||||||
|
- the number of data blocks in super block `u` is `2^(u/2)`, not `2^u`;
|
||||||
|
- each holds `2^((u+1)/2) * data_blk_min_elmts` elements, which doubles
|
||||||
|
every *other* level rather than every level;
|
||||||
|
- a super block carries a block-offset field before its data block
|
||||||
|
addresses, which was not skipped;
|
||||||
|
- the page-init bitmap belongs to the super block, one bit per page packed
|
||||||
|
across all its data blocks (MSB first), and was being read from inside the
|
||||||
|
data block instead; a paged data block also ends its prefix with a
|
||||||
|
checksum before the first page.
|
||||||
|
Covered now by interop tests at 4, 37, 400, 5 000 and 200 000 chunks (the
|
||||||
|
last large enough for paged data blocks), plus sparse, gzip-filtered and
|
||||||
|
2-D cases. Writing is unaffected; this is a read-path bug.
|
||||||
|
- `clawhdf5-format`: the sibling Fixed Array index (fixed dimensions written
|
||||||
|
with `libver='latest'`) was checked against the same range and is correct,
|
||||||
|
including paged data blocks and sparse datasets — it really does keep its
|
||||||
|
page-init bitmap in the data block, where the Extensible Array does not.
|
||||||
|
It had no real-file coverage above the inline sizes either, so it now has
|
||||||
|
the same tests.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- `clawhdf5-format`: **a crafted file could crash any reader through B-tree v2
|
||||||
|
traversal.** Recursion was bounded only by the depth the file claimed (a
|
||||||
|
`u16`), and child addresses were never checked for sharing. A node listing
|
||||||
|
itself as its own child under a header claiming 65 535 levels — under 100
|
||||||
|
bytes — overflowed the stack and **aborted the process** (SIGABRT, not a
|
||||||
|
catchable error). Levels whose children all point at one shared node below
|
||||||
|
reached it fan-out^depth times: 29.5 million records from ~5 KB, and one
|
||||||
|
more level would exhaust memory. Both are now errors, returned in under a
|
||||||
|
millisecond: depth is capped at 64 (as the fractal heap already was), and
|
||||||
|
traversal stops once it has produced more records than the file has bytes
|
||||||
|
to hold. Every B-tree v2 user goes through this path — dense attributes,
|
||||||
|
v2 groups, shared messages and chunk indexes. Valid files are unaffected,
|
||||||
|
including a depth-2 HDF5 2.0 chunk index with 40 000 records, now covered by
|
||||||
|
an interop test.
|
||||||
|
|
||||||
|
### Integrity
|
||||||
|
- `clawhdf5-format`: **Fixed and Extensible Array chunk indexes now verify
|
||||||
|
their checksums** (the `checksum` feature, on by default). Every structure
|
||||||
|
in both — header, index block, super block, data block and each data block
|
||||||
|
page — carries a Jenkins lookup3 checksum that was parsed past and ignored.
|
||||||
|
The consequence of skipping it is not a missing warning but wrong data: a
|
||||||
|
single flipped bit in a chunk address still parses, still points inside the
|
||||||
|
file, and the reader hands back whatever bytes now sit there as the chunk's
|
||||||
|
contents. Verified in both directions — the checksums accept files written
|
||||||
|
by HDF5 2.0 at 100 to 200 000 chunks, dense, sparse, filtered and paged,
|
||||||
|
and an interop test corrupts an address to confirm the read now fails
|
||||||
|
instead of returning data (it does return data when the check is removed).
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- `clawhdf5-agent`: **opening a store is ~28% faster** (455 ms -> 327 ms at
|
||||||
|
100k x 384). `read_from_disk` memory-mapped the file and then copied the
|
||||||
|
entire mapping into a `Vec` for `File::from_bytes`, when `File::open`
|
||||||
|
memory-maps it directly — so every open paid a full-file memcpy for nothing.
|
||||||
|
Process peak memory is unchanged: the peak falls after the parse, during the
|
||||||
|
index build, so the transient never reached the high-water mark. The
|
||||||
|
footprint harness now reports that peak next to the retained figure, which
|
||||||
|
is how this was checked rather than assumed.
|
||||||
|
- `clawhdf5-accel`: **`dot_i8`, a runtime-dispatched int8 dot product** (AVX2:
|
||||||
|
sign-extend each half to `i16`, then `madd_epi16`; scalar fallback
|
||||||
|
elsewhere). The quantised HNSW index used a scalar loop while the `f32` path
|
||||||
|
it was measured against ran AVX2, so the ~13% throughput cost recorded for
|
||||||
|
`MemoryConfig::quantized_index` was a missing kernel rather than a property
|
||||||
|
of int8. With the kernel, at N = 100 000 x 384 and equal recall, the
|
||||||
|
quantised index answers **1.63x as many queries per second** (21 848 vs
|
||||||
|
13 399 at ef=64, recall 0.9940 vs 0.9945) and builds **1.8x faster** (1778
|
||||||
|
vs 3197 ms) — on top of holding a quarter of the vectors. Medians of three
|
||||||
|
alternating runs. It remains off by default only because the kernel is
|
||||||
|
AVX2-only and aarch64 falls back to the scalar loop. Integer arithmetic, so
|
||||||
|
the SIMD path is tested to agree with scalar bit for bit.
|
||||||
|
|
||||||
|
### Tuning
|
||||||
|
- `clawhdf5-agent`: **the HNSW parameters are configurable** —
|
||||||
|
`MemoryConfig::hnsw_m`, `hnsw_ef_construction` and `hnsw_ef_search`
|
||||||
|
(defaults 16, 64, and 0 meaning "scale with `k`", i.e. today's behaviour).
|
||||||
|
They were constants, so a deployment could not trade recall against memory
|
||||||
|
or query speed at all. All three are persisted with the store. Values are
|
||||||
|
clamped where the index requires it: `clawhdf5-ann` asserts a graph degree
|
||||||
|
of at least 2, so a configured 0 — from a file, or from a caller who took 0
|
||||||
|
to mean "default" — used to abort the process inside the builder. Lowering
|
||||||
|
`ef_search` also no longer narrows the candidate pool that fusion sees.
|
||||||
|
**Breaking:** `MemoryConfig` gained fields, so literal constructions need
|
||||||
|
updating; `..Default::default()` does not.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- `clawhdf5-agent`: `BM25Index::search` claimed to use Block-Max WAND for early
|
||||||
|
termination. It never did; it scores every match exhaustively. It now says
|
||||||
|
so, and why no pruning would help the store: `hybrid_search` uses `scores()`,
|
||||||
|
since fusion normalises over every match.
|
||||||
|
|
||||||
## v2.6.0 (2026-09-20)
|
## v2.6.0 (2026-09-20)
|
||||||
|
|
||||||
### Upgrade Notes
|
### Upgrade Notes
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
|
|||||||
which roughly halves a loaded store's memory (2.72x -> 1.74x the raw vectors
|
which roughly halves a loaded store's memory (2.72x -> 1.74x the raw vectors
|
||||||
at 100K); because quantised distances are approximate and `ef` cannot
|
at 100K); because quantised distances are approximate and `ef` cannot
|
||||||
compensate, the query path then re-scores the candidate pool against the
|
compensate, the query path then re-scores the candidate pool against the
|
||||||
exact embeddings, which holds recall at the f32 index's level and costs
|
exact embeddings, which holds recall at the f32 index's level. On AVX2 it is
|
||||||
~13% of QPS. `hybrid_search` keeps one incremental BM25
|
also 1.63x the QPS and 1.8x the build speed (`clawhdf5_accel::dot_i8`); it
|
||||||
|
stays off by default only because that kernel is AVX2-only and aarch64 falls
|
||||||
|
back to scalar. `hybrid_search` keeps one incremental BM25
|
||||||
index for the life of the store and never writes the store: Hebbian
|
index for the life of the store and never writes the store: Hebbian
|
||||||
activation boosts are persisted by the next checkpoint (or on drop), not per
|
activation boosts are persisted by the next checkpoint (or on drop), not per
|
||||||
query. Measure any search-path change with
|
query. Measure any search-path change with
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ members = [
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||||
|
|||||||
@@ -433,12 +433,19 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
|
|||||||
| `float16` | **yes** | Half-precision embedding storage (2× compression) |
|
| `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 |
|
| `hnsw` | **yes** | HNSW approximate vector index for `hybrid_search` (via `clawhdf5-ann`); disable for an exact linear scan |
|
||||||
|
|
||||||
|
`MemoryConfig::hnsw_m`, `hnsw_ef_construction` and `hnsw_ef_search` tune the
|
||||||
|
vector index (16 / 64 / scale-with-`k` by default) and are stored with the
|
||||||
|
file.
|
||||||
|
|
||||||
`MemoryConfig::quantized_index` (off by default) stores the HNSW index's own
|
`MemoryConfig::quantized_index` (off by default) stores the HNSW index's own
|
||||||
copy of the embeddings as `i8`, roughly halving a loaded store's memory
|
copy of the embeddings as `i8`, roughly halving a loaded store's memory
|
||||||
(2.72x -> 1.74x the raw vectors at 100k x 384). Quantised distances are
|
(2.72x -> 1.74x the raw vectors at 100k x 384). Quantised distances are
|
||||||
approximate, so the query path re-scores the candidate pool against the exact
|
approximate, so the query path re-scores the candidate pool against the exact
|
||||||
embeddings the store already holds — recall matches the `f32` index, at about
|
embeddings the store already holds, which keeps recall at the `f32` index's
|
||||||
13% fewer queries per second. See `BENCHMARKS.md`, "Quantising the index copy".
|
level. On AVX2 it is also **faster** — 1.63x the queries per second and 1.8x
|
||||||
|
the build speed at equal recall — because the int8 kernel is SIMD too. It
|
||||||
|
stays off by default only because that kernel is AVX2-only and aarch64 falls
|
||||||
|
back to a scalar loop. See `BENCHMARKS.md`, "Quantising the index copy".
|
||||||
| `parallel` | no | Rayon parallel search |
|
| `parallel` | no | Rayon parallel search |
|
||||||
| `fast-math` | no | BLAS matrix-vector multiply |
|
| `fast-math` | no | BLAS matrix-vector multiply |
|
||||||
| `accelerate` | no | Apple Accelerate / AMX (macOS) |
|
| `accelerate` | no | Apple Accelerate / AMX (macOS) |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-accel"
|
name = "clawhdf5-accel"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "SIMD-accelerated operations for rustyhdf5"
|
description = "SIMD-accelerated operations for rustyhdf5"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -25,6 +25,55 @@ unsafe fn hsum_256(v: __m256) -> f32 {
|
|||||||
_mm_cvtss_f32(result)
|
_mm_cvtss_f32(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AVX2 dot product of two `i8` slices, widened to `i32`.
|
||||||
|
///
|
||||||
|
/// Each 16-byte half is sign-extended to sixteen `i16` lanes and multiplied
|
||||||
|
/// pairwise with `madd_epi16`, which sums adjacent products straight into
|
||||||
|
/// eight `i32` lanes — the widening that an autovectorised scalar loop does
|
||||||
|
/// in several shuffles is one instruction here. A pair sum is at most
|
||||||
|
/// `2 * 127 * 127`, far inside `i32`.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Caller must verify is_x86_feature_detected!("avx2").
|
||||||
|
// SAFETY: Caller must have verified AVX2 via is_x86_feature_detected!.
|
||||||
|
#[target_feature(enable = "avx2")]
|
||||||
|
pub unsafe fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
|
||||||
|
// SAFETY: Caller guarantees AVX2 is available per the # Safety contract;
|
||||||
|
// every load reads 32 bytes at an index checked against `len` first.
|
||||||
|
unsafe {
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
let len = a.len();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut acc0 = _mm256_setzero_si256();
|
||||||
|
let mut acc1 = _mm256_setzero_si256();
|
||||||
|
|
||||||
|
while i + 32 <= len {
|
||||||
|
let va = _mm256_loadu_si256(a.as_ptr().add(i).cast());
|
||||||
|
let vb = _mm256_loadu_si256(b.as_ptr().add(i).cast());
|
||||||
|
let a_lo = _mm256_cvtepi8_epi16(_mm256_castsi256_si128(va));
|
||||||
|
let b_lo = _mm256_cvtepi8_epi16(_mm256_castsi256_si128(vb));
|
||||||
|
let a_hi = _mm256_cvtepi8_epi16(_mm256_extracti128_si256(va, 1));
|
||||||
|
let b_hi = _mm256_cvtepi8_epi16(_mm256_extracti128_si256(vb, 1));
|
||||||
|
acc0 = _mm256_add_epi32(acc0, _mm256_madd_epi16(a_lo, b_lo));
|
||||||
|
acc1 = _mm256_add_epi32(acc1, _mm256_madd_epi16(a_hi, b_hi));
|
||||||
|
i += 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal sum of the eight i32 lanes.
|
||||||
|
let v = _mm256_add_epi32(acc0, acc1);
|
||||||
|
let s128 = _mm_add_epi32(_mm256_castsi256_si128(v), _mm256_extracti128_si256(v, 1));
|
||||||
|
let s64 = _mm_add_epi32(s128, _mm_unpackhi_epi64(s128, s128));
|
||||||
|
let s32 = _mm_add_epi32(s64, _mm_shuffle_epi32(s64, 0b01));
|
||||||
|
let mut sum = _mm_cvtsi128_si32(s32);
|
||||||
|
|
||||||
|
while i < len {
|
||||||
|
sum += i32::from(a[i]) * i32::from(b[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// AVX2 dot product for f32 slices.
|
/// AVX2 dot product for f32 slices.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
|
|||||||
@@ -122,6 +122,23 @@ pub fn dot_product(a: &[f32], b: &[f32]) -> f32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dot product of two `i8` slices, widened to `i32`.
|
||||||
|
///
|
||||||
|
/// The kernel behind int8-quantised vector search. Uses the AVX2 path
|
||||||
|
/// whenever AVX2 is present — including on AVX-512 machines, where it is
|
||||||
|
/// what the f32 kernels use too on a default build.
|
||||||
|
pub fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
|
||||||
|
match detect_backend() {
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
|
// SAFETY: both variants imply AVX2 was detected at runtime (the
|
||||||
|
// AVX-512 backend is only selected on CPUs that also have AVX2).
|
||||||
|
Backend::Avx2 | Backend::Avx512 if is_x86_feature_detected!("avx2") => unsafe {
|
||||||
|
avx2::dot_i8(a, b)
|
||||||
|
},
|
||||||
|
_ => scalar::dot_i8(a, b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Compute the L2 norm (magnitude) of a vector.
|
/// Compute the L2 norm (magnitude) of a vector.
|
||||||
pub fn vector_norm(v: &[f32]) -> f32 {
|
pub fn vector_norm(v: &[f32]) -> f32 {
|
||||||
dot_product(v, v).sqrt()
|
dot_product(v, v).sqrt()
|
||||||
@@ -713,3 +730,42 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod dot_i8_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn codes(n: usize, seed: u64) -> Vec<i8> {
|
||||||
|
let mut state = seed;
|
||||||
|
(0..n)
|
||||||
|
.map(|_| {
|
||||||
|
state = state
|
||||||
|
.wrapping_mul(6_364_136_223_846_793_005)
|
||||||
|
.wrapping_add(1_442_695_040_888_963_407);
|
||||||
|
// Full range, including the extremes.
|
||||||
|
((state >> 56) as u8) as i8
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatched_kernel_matches_scalar_exactly() {
|
||||||
|
// Integer arithmetic: the SIMD path must agree bit for bit, at every
|
||||||
|
// length — including ones that are not multiples of the 32-byte block,
|
||||||
|
// which exercise the tail.
|
||||||
|
for len in [0, 1, 7, 31, 32, 33, 63, 64, 100, 384, 385, 1536] {
|
||||||
|
let a = codes(len, 1 + len as u64);
|
||||||
|
let b = codes(len, 1000 + len as u64);
|
||||||
|
assert_eq!(dot_i8(&a, &b), scalar::dot_i8(&a, &b), "len {len}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extremes_do_not_overflow() {
|
||||||
|
// -128 * -128 is the largest product; a long run of it must still fit.
|
||||||
|
let a = vec![-128i8; 4096];
|
||||||
|
assert_eq!(dot_i8(&a, &a), 4096 * 128 * 128);
|
||||||
|
let b = vec![127i8; 4096];
|
||||||
|
assert_eq!(dot_i8(&a, &b), -4096 * 128 * 127);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,3 +140,33 @@ fn f16_to_f32_soft(h: u16) -> f32 {
|
|||||||
|
|
||||||
f32::from_bits(f32_bits)
|
f32::from_bits(f32_bits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dot product of two `i8` slices, widened to `i32`.
|
||||||
|
///
|
||||||
|
/// `dim` terms of at most `127 * 127` fit an `i32` for any realistic
|
||||||
|
/// dimension (over 130 000 terms before overflow is possible).
|
||||||
|
pub fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
// Four independent accumulators over 32-lane blocks: the widening product
|
||||||
|
// has to sit in a fixed-length chunk for the vectoriser to see it, and the
|
||||||
|
// separate accumulators keep it off one dependency chain.
|
||||||
|
const LANE: usize = 8;
|
||||||
|
let (a_blocks, a_tail) = a.as_chunks::<{ LANE * 4 }>();
|
||||||
|
let (b_blocks, b_tail) = b.as_chunks::<{ LANE * 4 }>();
|
||||||
|
let mut acc = [0i32; 4];
|
||||||
|
for (x, y) in a_blocks.iter().zip(b_blocks) {
|
||||||
|
for (lane, slot) in acc.iter_mut().enumerate() {
|
||||||
|
let mut sum = 0i32;
|
||||||
|
for k in 0..LANE {
|
||||||
|
sum += i32::from(x[lane * LANE + k]) * i32::from(y[lane * LANE + k]);
|
||||||
|
}
|
||||||
|
*slot += sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tail: i32 = a_tail
|
||||||
|
.iter()
|
||||||
|
.zip(b_tail)
|
||||||
|
.map(|(&x, &y)| i32::from(x) * i32::from(y))
|
||||||
|
.sum();
|
||||||
|
acc[0] + acc[1] + acc[2] + acc[3] + tail
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-agent"
|
name = "clawhdf5-agent"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "HDF5-backed persistent memory store for on-device AI agents"
|
description = "HDF5-backed persistent memory store for on-device AI agents"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -10,12 +10,12 @@ keywords = ["agent", "memory", "hdf5", "vector-search", "embedding"]
|
|||||||
categories = ["database", "science", "algorithms"]
|
categories = ["database", "science", "algorithms"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0", features = ["parallel", "fast-checksum"] }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0", features = ["parallel", "fast-checksum"] }
|
||||||
clawhdf5 = { path = "../clawhdf5", version = "2.6.0" }
|
clawhdf5 = { path = "../clawhdf5", version = "2.7.0" }
|
||||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.6.0", features = ["mmap"] }
|
clawhdf5-io = { path = "../clawhdf5-io", version = "2.7.0", features = ["mmap"] }
|
||||||
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.6.0" }
|
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.7.0" }
|
||||||
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.6.0", optional = true }
|
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.7.0", optional = true }
|
||||||
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.6.0", optional = true, default-features = false }
|
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.7.0", optional = true, default-features = false }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
byteorder = "1"
|
byteorder = "1"
|
||||||
half = { workspace = true, optional = true }
|
half = { workspace = true, optional = true }
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ mod tests {
|
|||||||
wal_enabled: false,
|
wal_enabled: false,
|
||||||
wal_max_entries: 500,
|
wal_max_entries: 500,
|
||||||
quantized_index: false,
|
quantized_index: false,
|
||||||
|
hnsw_m: 16,
|
||||||
|
hnsw_ef_construction: 64,
|
||||||
|
hnsw_ef_search: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,11 @@ impl BM25Index {
|
|||||||
/// Search the index for a query, returning the top `k` results
|
/// Search the index for a query, returning the top `k` results
|
||||||
/// as `(doc_id, score)` pairs sorted by score descending.
|
/// as `(doc_id, score)` pairs sorted by score descending.
|
||||||
///
|
///
|
||||||
/// Uses Block-Max WAND for early termination when remaining documents
|
/// Scores every matching document exhaustively, then keeps the top `k`.
|
||||||
/// cannot beat the current top-k threshold.
|
/// There is no early termination (WAND, MaxScore): the store's hot path
|
||||||
|
/// is [`scores`](Self::scores), because score fusion normalises over the
|
||||||
|
/// whole matching set and so needs every score, which no pruning scheme
|
||||||
|
/// can skip. This method is for BM25-only callers.
|
||||||
pub fn search(&self, query: &str, k: usize) -> Vec<(usize, f32)> {
|
pub fn search(&self, query: &str, k: usize) -> Vec<(usize, f32)> {
|
||||||
if k == 0 {
|
if k == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -561,8 +564,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wand_returns_same_results_as_exhaustive() {
|
fn top_k_search_matches_ranking_every_score() {
|
||||||
// WAND-style search should produce same scores as exhaustive
|
// `search` must agree with ranking the full `scores` set — the
|
||||||
|
// bounded heap is an optimisation over sorting, not an approximation.
|
||||||
let docs: Vec<String> = (0..100)
|
let docs: Vec<String> = (0..100)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
if i % 3 == 0 {
|
if i % 3 == 0 {
|
||||||
|
|||||||
@@ -65,12 +65,6 @@ use cache::MemoryCache;
|
|||||||
use clawhdf5_ann::{DistanceMetric, HnswIndex, Storage};
|
use clawhdf5_ann::{DistanceMetric, HnswIndex, Storage};
|
||||||
use ephemeral::{EphemeralConfig, EphemeralStore};
|
use ephemeral::{EphemeralConfig, EphemeralStore};
|
||||||
|
|
||||||
/// HNSW construction parameters used for the agent's vector index. Cosine is the
|
|
||||||
/// agent's similarity metric, so the index is built with cosine distance.
|
|
||||||
#[cfg(feature = "hnsw")]
|
|
||||||
const HNSW_M: usize = 16;
|
|
||||||
#[cfg(feature = "hnsw")]
|
|
||||||
const HNSW_EF_CONSTRUCTION: usize = 64;
|
|
||||||
// EphemeralEntry and EphemeralStats are part of the crate public API via
|
// EphemeralEntry and EphemeralStats are part of the crate public API via
|
||||||
// the `ephemeral` module; they are not needed directly in lib.rs internals.
|
// the `ephemeral` module; they are not needed directly in lib.rs internals.
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
@@ -150,6 +144,23 @@ pub struct MemoryConfig {
|
|||||||
///
|
///
|
||||||
/// Has no effect without the `hnsw` feature.
|
/// Has no effect without the `hnsw` feature.
|
||||||
pub quantized_index: bool,
|
pub quantized_index: bool,
|
||||||
|
/// HNSW graph degree. Higher means a denser graph: better recall, more
|
||||||
|
/// memory and slower builds. Clamped to at least 2 when the index is
|
||||||
|
/// built, since a graph with fewer connections is not one.
|
||||||
|
///
|
||||||
|
/// Has no effect without the `hnsw` feature.
|
||||||
|
pub hnsw_m: usize,
|
||||||
|
/// Candidate list size while building the HNSW graph. Higher means a
|
||||||
|
/// better graph and a slower build; it does not affect query cost.
|
||||||
|
///
|
||||||
|
/// Has no effect without the `hnsw` feature.
|
||||||
|
pub hnsw_ef_construction: usize,
|
||||||
|
/// Candidate list size for a query, trading throughput for recall. `0`
|
||||||
|
/// keeps the default, which scales with the requested `k`
|
||||||
|
/// (`max(k * 8, 64)`) so that fusion still sees a useful pool.
|
||||||
|
///
|
||||||
|
/// Has no effect without the `hnsw` feature.
|
||||||
|
pub hnsw_ef_search: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryConfig {
|
impl MemoryConfig {
|
||||||
@@ -172,6 +183,9 @@ impl MemoryConfig {
|
|||||||
wal_enabled: true,
|
wal_enabled: true,
|
||||||
wal_max_entries: 500,
|
wal_max_entries: 500,
|
||||||
quantized_index: false,
|
quantized_index: false,
|
||||||
|
hnsw_m: 16,
|
||||||
|
hnsw_ef_construction: 64,
|
||||||
|
hnsw_ef_search: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -826,6 +840,32 @@ impl HDF5Memory {
|
|||||||
// the index length drifts from the cache length (covering any mutation path
|
// the index length drifts from the cache length (covering any mutation path
|
||||||
// that doesn't call a hook, e.g. consolidation pushes).
|
// that doesn't call a hook, e.g. consolidation pushes).
|
||||||
|
|
||||||
|
/// Graph degree for the index, never below the 2 the builder requires:
|
||||||
|
/// a config value of 0 or 1 would otherwise panic inside `clawhdf5-ann`.
|
||||||
|
#[cfg(feature = "hnsw")]
|
||||||
|
fn hnsw_m(&self) -> usize {
|
||||||
|
self.config.hnsw_m.max(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build-time candidate list size, never below the graph degree — a
|
||||||
|
/// smaller one cannot fill a node's connections.
|
||||||
|
#[cfg(feature = "hnsw")]
|
||||||
|
fn hnsw_ef_construction(&self) -> usize {
|
||||||
|
self.config.hnsw_ef_construction.max(self.hnsw_m())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query-time candidate list size for a `k`-result search. `0` means the
|
||||||
|
/// default, which scales with `k`.
|
||||||
|
#[cfg(feature = "hnsw")]
|
||||||
|
pub(crate) fn hnsw_ef_search(&self, k: usize) -> usize {
|
||||||
|
let default = (k * 8).max(64);
|
||||||
|
if self.config.hnsw_ef_search == 0 {
|
||||||
|
default
|
||||||
|
} else {
|
||||||
|
self.config.hnsw_ef_search.max(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How the index should store its copy of the vectors, per the config.
|
/// How the index should store its copy of the vectors, per the config.
|
||||||
#[cfg(feature = "hnsw")]
|
#[cfg(feature = "hnsw")]
|
||||||
fn index_storage(&self) -> Storage {
|
fn index_storage(&self) -> Storage {
|
||||||
@@ -856,8 +896,8 @@ impl HDF5Memory {
|
|||||||
let rows: Vec<Vec<f32>> = self.cache.embeddings.iter().map(<[f32]>::to_vec).collect();
|
let rows: Vec<Vec<f32>> = self.cache.embeddings.iter().map(<[f32]>::to_vec).collect();
|
||||||
let mut index = HnswIndex::build_with(
|
let mut index = HnswIndex::build_with(
|
||||||
&rows,
|
&rows,
|
||||||
HNSW_M,
|
self.hnsw_m(),
|
||||||
HNSW_EF_CONSTRUCTION,
|
self.hnsw_ef_construction(),
|
||||||
DistanceMetric::Cosine,
|
DistanceMetric::Cosine,
|
||||||
self.index_storage(),
|
self.index_storage(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,6 +108,15 @@ pub fn build_hdf5_file_with_meta(
|
|||||||
"quantized_index",
|
"quantized_index",
|
||||||
AttrValue::I64(config.quantized_index.into()),
|
AttrValue::I64(config.quantized_index.into()),
|
||||||
);
|
);
|
||||||
|
meta.set_attr("hnsw_m", AttrValue::I64(config.hnsw_m as i64));
|
||||||
|
meta.set_attr(
|
||||||
|
"hnsw_ef_construction",
|
||||||
|
AttrValue::I64(config.hnsw_ef_construction as i64),
|
||||||
|
);
|
||||||
|
meta.set_attr(
|
||||||
|
"hnsw_ef_search",
|
||||||
|
AttrValue::I64(config.hnsw_ef_search as i64),
|
||||||
|
);
|
||||||
meta.set_attr(
|
meta.set_attr(
|
||||||
"edgehdf5_version",
|
"edgehdf5_version",
|
||||||
AttrValue::String(ZEROCLAW_VERSION.into()),
|
AttrValue::String(ZEROCLAW_VERSION.into()),
|
||||||
@@ -489,6 +498,15 @@ pub fn validate_and_load(
|
|||||||
.and_then(|v| usize::try_from(v).ok())
|
.and_then(|v| usize::try_from(v).ok())
|
||||||
.unwrap_or(500),
|
.unwrap_or(500),
|
||||||
quantized_index: optional_bool_attr(&attrs, "quantized_index", false),
|
quantized_index: optional_bool_attr(&attrs, "quantized_index", false),
|
||||||
|
hnsw_m: optional_i64_attr(&attrs, "hnsw_m")
|
||||||
|
.and_then(|v| usize::try_from(v).ok())
|
||||||
|
.unwrap_or(16),
|
||||||
|
hnsw_ef_construction: optional_i64_attr(&attrs, "hnsw_ef_construction")
|
||||||
|
.and_then(|v| usize::try_from(v).ok())
|
||||||
|
.unwrap_or(64),
|
||||||
|
hnsw_ef_search: optional_i64_attr(&attrs, "hnsw_ef_search")
|
||||||
|
.and_then(|v| usize::try_from(v).ok())
|
||||||
|
.unwrap_or(0),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load /memory group
|
// Load /memory group
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ impl HDF5Memory {
|
|||||||
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
|
// Over-fetch so the merge sees a useful vector pool; cosine
|
||||||
// distance from the index converts back to similarity (1 - d).
|
// distance from the index converts back to similarity (1 - d).
|
||||||
|
// `ef` is configurable, but the pool the fusion stage sees is
|
||||||
|
// not tied to it: a caller lowering `ef` for speed should not
|
||||||
|
// silently narrow what fusion has to work with.
|
||||||
let pool = (k * 8).max(64);
|
let pool = (k * 8).max(64);
|
||||||
let candidates = index.search(query_embedding, pool, pool);
|
let ef = self.hnsw_ef_search(k).max(pool);
|
||||||
|
let candidates = index.search(query_embedding, pool, ef);
|
||||||
// A quantised index returns approximate distances, and no
|
// A quantised index returns approximate distances, and no
|
||||||
// amount of `ef` fixes that — the loss is in the distances,
|
// amount of `ef` fixes that — the loss is in the distances,
|
||||||
// not the graph. Re-score the pool against the cache's exact
|
// not the graph. Re-score the pool against the cache's exact
|
||||||
|
|||||||
@@ -113,13 +113,11 @@ pub type StoreState = (MemoryConfig, MemoryCache, SessionCache, KnowledgeCache);
|
|||||||
/// [`read_from_disk`], plus the checkpoint's [`WalMark`] (if any) so the
|
/// [`read_from_disk`], plus the checkpoint's [`WalMark`] (if any) so the
|
||||||
/// caller can skip WAL entries this file already contains.
|
/// caller can skip WAL entries this file already contains.
|
||||||
pub fn read_from_disk_with_mark(path: &Path) -> Result<(StoreState, Option<WalMark>), MemoryError> {
|
pub fn read_from_disk_with_mark(path: &Path) -> Result<(StoreState, Option<WalMark>), MemoryError> {
|
||||||
let mmap = clawhdf5_io::MmapReader::open(path).map_err(MemoryError::Io)?;
|
// `File::open` memory-maps the file itself (the facade's `mmap` feature is
|
||||||
|
// on by default). Mapping it here and handing over `as_bytes().to_vec()`
|
||||||
// Advise the OS we'll need the whole file for parsing
|
// did the same work and then copied the whole store — a second full copy
|
||||||
mmap.advise_willneed(0, mmap.len());
|
// of the file, live for the whole parse, on top of the mapping.
|
||||||
|
let file = clawhdf5::File::open(path)
|
||||||
// Parse the HDF5 file from the mmap'd bytes
|
|
||||||
let file = clawhdf5::File::from_bytes(mmap.as_bytes().to_vec())
|
|
||||||
.map_err(|e| MemoryError::Hdf5(format!("cannot open {}: {e}", path.display())))?;
|
.map_err(|e| MemoryError::Hdf5(format!("cannot open {}: {e}", path.display())))?;
|
||||||
|
|
||||||
let (mut config, cache, sessions, knowledge) = schema::validate_and_load(&file)?;
|
let (mut config, cache, sessions, knowledge) = schema::validate_and_load(&file)?;
|
||||||
@@ -133,9 +131,7 @@ pub fn read_from_disk_with_mark(path: &Path) -> Result<(StoreState, Option<WalMa
|
|||||||
pub fn read_from_disk_with_meta(
|
pub fn read_from_disk_with_meta(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
) -> Result<(StoreState, schema::CheckpointMeta), MemoryError> {
|
) -> Result<(StoreState, schema::CheckpointMeta), MemoryError> {
|
||||||
let mmap = clawhdf5_io::MmapReader::open(path).map_err(MemoryError::Io)?;
|
let file = clawhdf5::File::open(path)
|
||||||
mmap.advise_willneed(0, mmap.len());
|
|
||||||
let file = clawhdf5::File::from_bytes(mmap.as_bytes().to_vec())
|
|
||||||
.map_err(|e| MemoryError::Hdf5(format!("cannot open {}: {e}", path.display())))?;
|
.map_err(|e| MemoryError::Hdf5(format!("cannot open {}: {e}", path.display())))?;
|
||||||
let (mut config, cache, sessions, knowledge) = schema::validate_and_load(&file)?;
|
let (mut config, cache, sessions, knowledge) = schema::validate_and_load(&file)?;
|
||||||
config.path = path.to_path_buf();
|
config.path = path.to_path_buf();
|
||||||
|
|||||||
@@ -234,3 +234,53 @@ fn quantized_index_setting_survives_a_reopen() {
|
|||||||
let reopened = HDF5Memory::open(&path).unwrap();
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
assert!(reopened.config().quantized_index);
|
assert!(reopened.config().quantized_index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hnsw_parameters_are_configurable_and_persisted() {
|
||||||
|
// The graph degree and both candidate-list sizes used to be constants, so
|
||||||
|
// a deployment could not trade recall against memory or speed at all.
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("mem.h5");
|
||||||
|
let mut config = MemoryConfig::new(path.clone(), "agent", 16);
|
||||||
|
config.hnsw_m = 8;
|
||||||
|
config.hnsw_ef_construction = 32;
|
||||||
|
config.hnsw_ef_search = 128;
|
||||||
|
let mut mem = HDF5Memory::create(config).unwrap();
|
||||||
|
|
||||||
|
let mut seed = 99;
|
||||||
|
let vectors: Vec<Vec<f32>> = (0..300).map(|_| make_vector(&mut seed, 16)).collect();
|
||||||
|
for (i, v) in vectors.iter().enumerate() {
|
||||||
|
mem.save(entry(&format!("c{i}"), v.clone(), "t")).unwrap();
|
||||||
|
}
|
||||||
|
// Still correct with a smaller graph: an exact match must rank first.
|
||||||
|
let top = mem.hybrid_search(&vectors[42], "", 1.0, 0.0, 1);
|
||||||
|
assert_eq!(top[0].index, 42);
|
||||||
|
|
||||||
|
mem.flush_wal().unwrap();
|
||||||
|
drop(mem);
|
||||||
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
|
assert_eq!(reopened.config().hnsw_m, 8);
|
||||||
|
assert_eq!(reopened.config().hnsw_ef_construction, 32);
|
||||||
|
assert_eq!(reopened.config().hnsw_ef_search, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn degenerate_hnsw_parameters_do_not_panic() {
|
||||||
|
// `clawhdf5-ann` asserts m >= 2, so a zero from a config file — or from a
|
||||||
|
// caller who assumed 0 meant "default" — would abort the process inside
|
||||||
|
// the index builder. The store clamps instead.
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let mut config = MemoryConfig::new(dir.path().join("mem.h5"), "agent", 8);
|
||||||
|
config.hnsw_m = 0;
|
||||||
|
config.hnsw_ef_construction = 0;
|
||||||
|
config.hnsw_ef_search = 1;
|
||||||
|
let mut mem = HDF5Memory::create(config).unwrap();
|
||||||
|
|
||||||
|
let mut seed = 5;
|
||||||
|
let vectors: Vec<Vec<f32>> = (0..50).map(|_| make_vector(&mut seed, 8)).collect();
|
||||||
|
for (i, v) in vectors.iter().enumerate() {
|
||||||
|
mem.save(entry(&format!("c{i}"), v.clone(), "t")).unwrap();
|
||||||
|
}
|
||||||
|
let results = mem.hybrid_search(&vectors[7], "", 1.0, 0.0, 5);
|
||||||
|
assert_eq!(results[0].index, 7, "exact match should still rank first");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-android"
|
name = "clawhdf5-android"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Android JNI bridge for edgehdf5-memory HDF5 backend"
|
description = "Android JNI bridge for edgehdf5-memory HDF5 backend"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-ann"
|
name = "clawhdf5-ann"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "HNSW approximate nearest neighbor index stored as HDF5"
|
description = "HNSW approximate nearest neighbor index stored as HDF5"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -10,9 +10,9 @@ keywords = ["hdf5", "ann", "hnsw", "nearest-neighbor"]
|
|||||||
categories = ["algorithms", "science"]
|
categories = ["algorithms", "science"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0" }
|
||||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.6.0" }
|
clawhdf5-io = { path = "../clawhdf5-io", version = "2.7.0" }
|
||||||
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.6.0" }
|
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.7.0" }
|
||||||
rayon = { version = "1", optional = true }
|
rayon = { version = "1", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@@ -358,31 +358,12 @@ enum Query {
|
|||||||
Int8(Vec<i8>, f32),
|
Int8(Vec<i8>, f32),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sum of products, widened so it cannot overflow: `dim` terms of at most
|
/// Sum of products, widened so it cannot overflow. Runtime-dispatched to the
|
||||||
/// `127 * 127`, so `i32` suffices for any realistic dimension.
|
/// same SIMD backend as the f32 kernels, so the two storages are compared on
|
||||||
|
/// equal terms.
|
||||||
|
#[inline]
|
||||||
fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
|
fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
|
||||||
// Four independent accumulators over 32-lane blocks: the widening product
|
clawhdf5_accel::dot_i8(a, b)
|
||||||
// has to sit in a fixed-length chunk for the vectoriser to see it, and the
|
|
||||||
// separate accumulators keep it off one dependency chain.
|
|
||||||
const LANE: usize = 8;
|
|
||||||
let (a_blocks, a_tail) = a.as_chunks::<{ LANE * 4 }>();
|
|
||||||
let (b_blocks, b_tail) = b.as_chunks::<{ LANE * 4 }>();
|
|
||||||
let mut acc = [0i32; 4];
|
|
||||||
for (x, y) in a_blocks.iter().zip(b_blocks) {
|
|
||||||
for (lane, slot) in acc.iter_mut().enumerate() {
|
|
||||||
let mut sum = 0i32;
|
|
||||||
for k in 0..LANE {
|
|
||||||
sum += i32::from(x[lane * LANE + k]) * i32::from(y[lane * LANE + k]);
|
|
||||||
}
|
|
||||||
*slot += sum;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let tail: i32 = a_tail
|
|
||||||
.iter()
|
|
||||||
.zip(b_tail)
|
|
||||||
.map(|(&x, &y)| i32::from(x) * i32::from(y))
|
|
||||||
.sum();
|
|
||||||
acc[0] + acc[1] + acc[2] + acc[3] + tail
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Magic for [`HnswIndex::graph_to_bytes`].
|
/// Magic for [`HnswIndex::graph_to_bytes`].
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-bench"
|
name = "clawhdf5-bench"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Benchmark harnesses for clawhdf5-agent (Track 8)"
|
description = "Benchmark harnesses for clawhdf5-agent (Track 8)"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -230,13 +230,27 @@ struct CountingAllocator;
|
|||||||
|
|
||||||
static LIVE_BYTES: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
|
static LIVE_BYTES: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
|
||||||
|
|
||||||
|
/// High-water mark of [`LIVE_BYTES`] since it was last reset.
|
||||||
|
///
|
||||||
|
/// Live bytes at a checkpoint cannot see a buffer that was allocated and
|
||||||
|
/// freed in between, and that is exactly the shape of a transient copy —
|
||||||
|
/// which still has to fit in memory while it exists.
|
||||||
|
static PEAK_BYTES: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
|
||||||
|
|
||||||
|
fn note_peak(live: i64) {
|
||||||
|
PEAK_BYTES.fetch_max(live, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
// SAFETY: every method forwards to the system allocator with the same layout
|
// SAFETY: every method forwards to the system allocator with the same layout
|
||||||
// it was given, and only adds bookkeeping around it.
|
// it was given, and only adds bookkeeping around it.
|
||||||
unsafe impl std::alloc::GlobalAlloc for CountingAllocator {
|
unsafe impl std::alloc::GlobalAlloc for CountingAllocator {
|
||||||
unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
|
unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
|
||||||
let ptr = unsafe { std::alloc::System.alloc(layout) };
|
let ptr = unsafe { std::alloc::System.alloc(layout) };
|
||||||
if !ptr.is_null() {
|
if !ptr.is_null() {
|
||||||
LIVE_BYTES.fetch_add(layout.size() as i64, std::sync::atomic::Ordering::Relaxed);
|
let live = LIVE_BYTES
|
||||||
|
.fetch_add(layout.size() as i64, std::sync::atomic::Ordering::Relaxed)
|
||||||
|
+ layout.size() as i64;
|
||||||
|
note_peak(live);
|
||||||
}
|
}
|
||||||
ptr
|
ptr
|
||||||
}
|
}
|
||||||
@@ -249,10 +263,9 @@ unsafe impl std::alloc::GlobalAlloc for CountingAllocator {
|
|||||||
unsafe fn realloc(&self, ptr: *mut u8, layout: std::alloc::Layout, new_size: usize) -> *mut u8 {
|
unsafe fn realloc(&self, ptr: *mut u8, layout: std::alloc::Layout, new_size: usize) -> *mut u8 {
|
||||||
let new_ptr = unsafe { std::alloc::System.realloc(ptr, layout, new_size) };
|
let new_ptr = unsafe { std::alloc::System.realloc(ptr, layout, new_size) };
|
||||||
if !new_ptr.is_null() {
|
if !new_ptr.is_null() {
|
||||||
LIVE_BYTES.fetch_add(
|
let delta = new_size as i64 - layout.size() as i64;
|
||||||
new_size as i64 - layout.size() as i64,
|
let live = LIVE_BYTES.fetch_add(delta, std::sync::atomic::Ordering::Relaxed) + delta;
|
||||||
std::sync::atomic::Ordering::Relaxed,
|
note_peak(live);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
new_ptr
|
new_ptr
|
||||||
}
|
}
|
||||||
@@ -266,6 +279,19 @@ fn heap_bytes() -> u64 {
|
|||||||
LIVE_BYTES.load(std::sync::atomic::Ordering::Relaxed).max(0) as u64
|
LIVE_BYTES.load(std::sync::atomic::Ordering::Relaxed).max(0) as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start watching for a new high-water mark from the current live total.
|
||||||
|
fn reset_peak() {
|
||||||
|
PEAK_BYTES.store(
|
||||||
|
LIVE_BYTES.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
std::sync::atomic::Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The highest live total seen since [`reset_peak`].
|
||||||
|
fn peak_bytes() -> u64 {
|
||||||
|
PEAK_BYTES.load(std::sync::atomic::Ordering::Relaxed).max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
fn mib(bytes: u64) -> f64 {
|
fn mib(bytes: u64) -> f64 {
|
||||||
bytes as f64 / (1 << 20) as f64
|
bytes as f64 / (1 << 20) as f64
|
||||||
}
|
}
|
||||||
@@ -580,19 +606,24 @@ fn bench_footprint(n: usize) {
|
|||||||
let path = mem.config().path.clone();
|
let path = mem.config().path.clone();
|
||||||
drop(mem);
|
drop(mem);
|
||||||
let before_open = heap_bytes();
|
let before_open = heap_bytes();
|
||||||
|
reset_peak();
|
||||||
let reopened = HDF5Memory::open(&path).unwrap();
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
let after_open = heap_bytes();
|
let after_open = heap_bytes();
|
||||||
let loaded = after_open.saturating_sub(before_open);
|
let loaded = after_open.saturating_sub(before_open);
|
||||||
|
// Peak over the open, not just what it leaves behind: a buffer allocated
|
||||||
|
// and freed during the parse never shows up in the live total.
|
||||||
|
let peak = peak_bytes().saturating_sub(before_open);
|
||||||
drop(reopened);
|
drop(reopened);
|
||||||
|
|
||||||
let raw = (n * DIM * 4) as u64;
|
let raw = (n * DIM * 4) as u64;
|
||||||
println!(
|
println!(
|
||||||
"| {n} | {:.0} | {:.0} | {:.0} | {:.0} | {:.0} | {:.2}x |",
|
"| {n} | {:.0} | {:.0} | {:.0} | {:.0} | {:.0} | {:.0} | {:.2}x |",
|
||||||
mib(raw),
|
mib(raw),
|
||||||
mib(after_entries.saturating_sub(base)),
|
mib(after_entries.saturating_sub(base)),
|
||||||
mib(after_store.saturating_sub(after_entries)),
|
mib(after_store.saturating_sub(after_entries)),
|
||||||
mib(after_indexes.saturating_sub(after_store)),
|
mib(after_indexes.saturating_sub(after_store)),
|
||||||
mib(loaded),
|
mib(loaded),
|
||||||
|
mib(peak),
|
||||||
loaded as f64 / raw as f64,
|
loaded as f64 / raw as f64,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -644,9 +675,9 @@ fn main() {
|
|||||||
if args.iter().any(|a| a == "--footprint") {
|
if args.iter().any(|a| a == "--footprint") {
|
||||||
println!("\n### Resident memory, {DIM}-dim f32\n");
|
println!("\n### Resident memory, {DIM}-dim f32\n");
|
||||||
println!(
|
println!(
|
||||||
"| N | vectors (raw) | entries MiB | store MiB | indexes MiB | reopened MiB | reopened / raw |"
|
"| N | vectors (raw) | entries MiB | store MiB | indexes MiB | reopened MiB | peak during open MiB | reopened / raw |"
|
||||||
);
|
);
|
||||||
println!("|---:|---:|---:|---:|---:|---:|---:|");
|
println!("|---:|---:|---:|---:|---:|---:|---:|---:|");
|
||||||
for &n in sizes {
|
for &n in sizes {
|
||||||
bench_footprint(n);
|
bench_footprint(n);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-cli"
|
name = "clawhdf5-cli"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "CLI for clawhdf5 agent memory — create, save, search, recall, stats"
|
description = "CLI for clawhdf5 agent memory — create, save, search, recall, stats"
|
||||||
@@ -14,7 +14,7 @@ name = "clawhdf5"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.6.0" }
|
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.7.0" }
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-derive"
|
name = "clawhdf5-derive"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Derive macros for rustyhdf5 HDF5 traits"
|
description = "Derive macros for rustyhdf5 HDF5 traits"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-filters"
|
name = "clawhdf5-filters"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Filter and compression pipeline for clawhdf5"
|
description = "Filter and compression pipeline for clawhdf5"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-format"
|
name = "clawhdf5-format"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Pure-Rust HDF5 binary format parsing and writing — no C dependencies"
|
description = "Pure-Rust HDF5 binary format parsing and writing — no C dependencies"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -25,7 +25,7 @@ pco = { version = "1.0", optional = true }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
criterion = { workspace = true }
|
criterion = { workspace = true }
|
||||||
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.6.0" }
|
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.7.0" }
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "bench"
|
name = "bench"
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
target/
|
target/
|
||||||
|
corpus/
|
||||||
|
artifacts/
|
||||||
|
coverage/
|
||||||
|
|||||||
@@ -1,15 +1,36 @@
|
|||||||
#![no_main]
|
#![no_main]
|
||||||
|
use clawhdf5_format::btree_v2::{BTreeV2Header, collect_btree_v2_records};
|
||||||
use libfuzzer_sys::fuzz_target;
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
fuzz_target!(|data: &[u8]| {
|
fuzz_target!(|data: &[u8]| {
|
||||||
for &offset_size in &[4u8, 8] {
|
for &offset_size in &[4u8, 8] {
|
||||||
for &length_size in &[4u8, 8] {
|
for &length_size in &[4u8, 8] {
|
||||||
let _ = clawhdf5_format::btree_v2::BTreeV2Header::parse(
|
if let Ok(header) = BTreeV2Header::parse(data, 0, offset_size, length_size) {
|
||||||
data,
|
let _ = collect_btree_v2_records(data, &header, offset_size, length_size);
|
||||||
0,
|
}
|
||||||
offset_size,
|
|
||||||
length_size,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parsing a header requires a valid checksum, which random input almost
|
||||||
|
// never has, so the traversal behind it went unfuzzed — and that is where
|
||||||
|
// a node listing itself as its own child overflowed the stack. Take the
|
||||||
|
// header fields straight from the input instead and walk the rest.
|
||||||
|
let Some((fields, file)) = data.split_first_chunk::<20>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let header = BTreeV2Header {
|
||||||
|
tree_type: fields[0],
|
||||||
|
node_size: u32::from_le_bytes([fields[1], fields[2], fields[3], fields[4]]),
|
||||||
|
record_size: u16::from_le_bytes([fields[5], fields[6]]),
|
||||||
|
depth: u16::from_le_bytes([fields[7], fields[8]]),
|
||||||
|
root_node_address: u64::from(u32::from_le_bytes([
|
||||||
|
fields[9], fields[10], fields[11], fields[12],
|
||||||
|
])),
|
||||||
|
num_records_in_root: u16::from_le_bytes([fields[13], fields[14]]),
|
||||||
|
total_records: u64::from(u32::from_le_bytes([
|
||||||
|
fields[15], fields[16], fields[17], fields[18],
|
||||||
|
])),
|
||||||
|
};
|
||||||
|
let offset_size = if fields[19] & 1 == 0 { 4 } else { 8 };
|
||||||
|
let _ = collect_btree_v2_records(file, &header, offset_size, 8);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -172,6 +172,17 @@ fn max_records_leaf(node_size: u32, record_size: u16) -> u64 {
|
|||||||
((node_size - overhead) / record_size as u32) as u64
|
((node_size - overhead) / record_size as u32) as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deepest B-tree v2 accepted. See [`collect_btree_v2_records`].
|
||||||
|
const MAX_DEPTH: u16 = 64;
|
||||||
|
|
||||||
|
/// Take `n` records from the traversal's budget, or refuse the tree.
|
||||||
|
fn spend(budget: &mut usize, n: usize) -> Result<(), FormatError> {
|
||||||
|
*budget = budget
|
||||||
|
.checked_sub(n)
|
||||||
|
.ok_or(FormatError::NestingDepthExceeded)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Collect all records from a B-tree v2 by traversing from the root.
|
/// Collect all records from a B-tree v2 by traversing from the root.
|
||||||
pub fn collect_btree_v2_records(
|
pub fn collect_btree_v2_records(
|
||||||
file_data: &[u8],
|
file_data: &[u8],
|
||||||
@@ -182,6 +193,22 @@ pub fn collect_btree_v2_records(
|
|||||||
if header.total_records == 0 || header.num_records_in_root == 0 {
|
if header.total_records == 0 || header.num_records_in_root == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
// Recursion is one frame per level, and the depth is read from the file:
|
||||||
|
// a crafted header claiming 65 535 levels over a node that is its own
|
||||||
|
// child overflowed the stack. 64 matches the fractal heap's guard, and no
|
||||||
|
// real tree comes close — even at the minimum fan-out of two it would
|
||||||
|
// hold more than 2^64 records.
|
||||||
|
if header.depth > MAX_DEPTH {
|
||||||
|
return Err(FormatError::NestingDepthExceeded);
|
||||||
|
}
|
||||||
|
// A valid tree stores each record once, in its own bytes, so it cannot
|
||||||
|
// hold more records than the file has room for. Children are addresses,
|
||||||
|
// though, and nothing makes them distinct: levels whose children all
|
||||||
|
// point at one shared node below reach it fan-out^depth times, which is
|
||||||
|
// millions of records from a few kilobytes. Counting against what the
|
||||||
|
// file could physically contain bounds that without trusting the
|
||||||
|
// header's own `total_records`.
|
||||||
|
let mut budget = file_data.len() / usize::from(header.record_size.max(1));
|
||||||
|
|
||||||
let max_leaf_nrec = max_records_leaf(header.node_size, header.record_size);
|
let max_leaf_nrec = max_records_leaf(header.node_size, header.record_size);
|
||||||
|
|
||||||
@@ -206,6 +233,7 @@ pub fn collect_btree_v2_records(
|
|||||||
offset_size,
|
offset_size,
|
||||||
length_size,
|
length_size,
|
||||||
max_leaf_nrec,
|
max_leaf_nrec,
|
||||||
|
&mut budget,
|
||||||
&mut records,
|
&mut records,
|
||||||
)?;
|
)?;
|
||||||
Ok(records)
|
Ok(records)
|
||||||
@@ -273,6 +301,7 @@ fn collect_internal_records(
|
|||||||
offset_size: u8,
|
offset_size: u8,
|
||||||
length_size: u8,
|
length_size: u8,
|
||||||
max_leaf_nrec: u64,
|
max_leaf_nrec: u64,
|
||||||
|
budget: &mut usize,
|
||||||
out: &mut Vec<BTreeV2Record>,
|
out: &mut Vec<BTreeV2Record>,
|
||||||
) -> Result<(), FormatError> {
|
) -> Result<(), FormatError> {
|
||||||
// signature(4) + version(1) + type(1) = 6
|
// signature(4) + version(1) + type(1) = 6
|
||||||
@@ -350,6 +379,8 @@ fn collect_internal_records(
|
|||||||
// We collect child[0] records, then record[0], then child[1], etc.
|
// We collect child[0] records, then record[0], then child[1], etc.
|
||||||
for (i, &(child_addr, child_nrec)) in children.iter().enumerate() {
|
for (i, &(child_addr, child_nrec)) in children.iter().enumerate() {
|
||||||
if child_depth == 0 {
|
if child_depth == 0 {
|
||||||
|
// Before parsing, so a refused tree is not also a large allocation.
|
||||||
|
spend(budget, usize::from(child_nrec))?;
|
||||||
let leaf_recs =
|
let leaf_recs =
|
||||||
parse_leaf_records(file_data, child_addr as usize, child_nrec, record_size)?;
|
parse_leaf_records(file_data, child_addr as usize, child_nrec, record_size)?;
|
||||||
out.extend(leaf_recs);
|
out.extend(leaf_recs);
|
||||||
@@ -364,6 +395,7 @@ fn collect_internal_records(
|
|||||||
offset_size,
|
offset_size,
|
||||||
length_size,
|
length_size,
|
||||||
max_leaf_nrec,
|
max_leaf_nrec,
|
||||||
|
budget,
|
||||||
out,
|
out,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
@@ -393,6 +425,7 @@ fn collect_internal_records(
|
|||||||
available: file_data.len(),
|
available: file_data.len(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
spend(budget, 1)?;
|
||||||
out.push(BTreeV2Record {
|
out.push(BTreeV2Record {
|
||||||
data: file_data[rec_start..rec_end].to_vec(),
|
data: file_data[rec_start..rec_end].to_vec(),
|
||||||
});
|
});
|
||||||
@@ -466,6 +499,124 @@ mod tests {
|
|||||||
buf
|
buf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An internal node laid out exactly as `collect_internal_records` will
|
||||||
|
/// read it at `depth`: `records` zeroed records, then `children` pointers,
|
||||||
|
/// all to `child_addr` claiming `child_nrec` records.
|
||||||
|
fn internal_node(
|
||||||
|
depth: u16,
|
||||||
|
node_size: u32,
|
||||||
|
record_size: u16,
|
||||||
|
records: usize,
|
||||||
|
children: usize,
|
||||||
|
child_addr: u64,
|
||||||
|
child_nrec: u64,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let max_leaf = max_records_leaf(node_size, record_size);
|
||||||
|
let nrec_width = bytes_for_max_records(if depth == 1 { max_leaf } else { max_leaf * 2 });
|
||||||
|
let total_width = if depth > 1 {
|
||||||
|
bytes_for_max_records(header_max_total_records(max_leaf, depth - 1))
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let mut buf = b"BTIN".to_vec();
|
||||||
|
buf.extend_from_slice(&[0, 5]);
|
||||||
|
buf.resize(buf.len() + records * record_size as usize, 0);
|
||||||
|
for _ in 0..children {
|
||||||
|
buf.extend_from_slice(&child_addr.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&child_nrec.to_le_bytes()[..nrec_width]);
|
||||||
|
buf.resize(buf.len() + total_width, 0);
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header(depth: u16, root: u64, root_nrec: u16, total: u64) -> BTreeV2Header {
|
||||||
|
BTreeV2Header {
|
||||||
|
tree_type: 5,
|
||||||
|
node_size: 512,
|
||||||
|
record_size: 8,
|
||||||
|
depth,
|
||||||
|
root_node_address: root,
|
||||||
|
num_records_in_root: root_nrec,
|
||||||
|
total_records: total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_node_that_is_its_own_child_is_rejected_not_recursed() {
|
||||||
|
// One internal node whose two children are itself, under a header
|
||||||
|
// claiming the deepest tree a u16 allows. The layout stops depending
|
||||||
|
// on depth once the subtree-total width saturates, so every level
|
||||||
|
// parses cleanly and recursion runs ~65 000 frames deep: before the
|
||||||
|
// cap this overflowed the stack and aborted the process, from a file
|
||||||
|
// of under 100 bytes.
|
||||||
|
let mut data = internal_node(u16::MAX, 512, 8, 1, 2, 0, 1);
|
||||||
|
data.resize(4096, 0);
|
||||||
|
let result = collect_btree_v2_records(&data, &header(u16::MAX, 0, 1, 1), 8, 8);
|
||||||
|
assert!(result.is_err(), "{result:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shared_subtree_cannot_multiply_the_work() {
|
||||||
|
// A chain of distinct levels, each node's children all pointing at the
|
||||||
|
// single node below, ending in a real leaf. Every node parses and
|
||||||
|
// nothing is cyclic, yet the leaf is reached fan-out^depth times: 62
|
||||||
|
// children over 4 levels is ~15 million leaf visits from a few
|
||||||
|
// kilobytes. A valid tree cannot hold more records than the file has
|
||||||
|
// room for, so that bounds the traversal instead.
|
||||||
|
let (node_size, record_size) = (512u32, 8u16);
|
||||||
|
let fanout = 62usize;
|
||||||
|
let depth = 4u16;
|
||||||
|
let leaf = build_leaf_node(5, &[&[0u8; 8][..]]);
|
||||||
|
|
||||||
|
// Lay out root first, then each lower level, then the leaf.
|
||||||
|
let mut nodes: Vec<Vec<u8>> = Vec::new();
|
||||||
|
let mut addrs = Vec::new();
|
||||||
|
let mut at = 0u64;
|
||||||
|
let mut sizes = Vec::new();
|
||||||
|
for d in (1..=depth).rev() {
|
||||||
|
let n = internal_node(d, node_size, record_size, fanout - 1, fanout, 0, 0);
|
||||||
|
sizes.push(n.len());
|
||||||
|
}
|
||||||
|
for size in &sizes {
|
||||||
|
addrs.push(at);
|
||||||
|
at += *size as u64;
|
||||||
|
}
|
||||||
|
let leaf_addr = at;
|
||||||
|
for (i, d) in (1..=depth).rev().enumerate() {
|
||||||
|
let (child, child_nrec) = if d == 1 {
|
||||||
|
(leaf_addr, 1)
|
||||||
|
} else {
|
||||||
|
(addrs[i + 1], fanout as u64 - 1)
|
||||||
|
};
|
||||||
|
nodes.push(internal_node(
|
||||||
|
d,
|
||||||
|
node_size,
|
||||||
|
record_size,
|
||||||
|
fanout - 1,
|
||||||
|
fanout,
|
||||||
|
child,
|
||||||
|
child_nrec,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut data: Vec<u8> = nodes.concat();
|
||||||
|
data.extend_from_slice(&leaf);
|
||||||
|
data.resize(data.len() + 64, 0);
|
||||||
|
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let result =
|
||||||
|
collect_btree_v2_records(&data, &header(depth, 0, fanout as u16 - 1, u64::MAX), 8, 8);
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"expected a refusal, got {} records",
|
||||||
|
result.map_or(0, |r| r.len())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < std::time::Duration::from_secs(2),
|
||||||
|
"took {:?}",
|
||||||
|
started.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_header() {
|
fn parse_header() {
|
||||||
let data = build_btree_v2_header(5, 512, 11, 0, 0x1000, 3, 3, 8, 8);
|
let data = build_btree_v2_header(5, 512, 11, 0, 0x1000, 3, 3, 8, 8);
|
||||||
|
|||||||
@@ -12,6 +12,31 @@ use alloc::{format, vec, vec::Vec};
|
|||||||
use crate::chunked_read::ChunkInfo;
|
use crate::chunked_read::ChunkInfo;
|
||||||
use crate::error::FormatError;
|
use crate::error::FormatError;
|
||||||
|
|
||||||
|
/// Verify the Jenkins lookup3 checksum stored immediately after
|
||||||
|
/// `data[start..end]`, as every Extensible Array structure carries one.
|
||||||
|
///
|
||||||
|
/// A corrupt chunk index yields addresses pointing at the wrong bytes, so a
|
||||||
|
/// mismatch is an error: otherwise the damage surfaces as plausible data read
|
||||||
|
/// from the wrong chunk.
|
||||||
|
#[cfg(feature = "checksum")]
|
||||||
|
fn verify_checksum(data: &[u8], start: usize, end: usize) -> Result<(), FormatError> {
|
||||||
|
ensure_len(data, end, 4)?;
|
||||||
|
let stored = u32::from_le_bytes([data[end], data[end + 1], data[end + 2], data[end + 3]]);
|
||||||
|
let computed = crate::checksum::jenkins_lookup3(&data[start..end]);
|
||||||
|
if computed != stored {
|
||||||
|
return Err(FormatError::ChecksumMismatch {
|
||||||
|
expected: stored,
|
||||||
|
computed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "checksum"))]
|
||||||
|
fn verify_checksum(_data: &[u8], _start: usize, _end: usize) -> Result<(), FormatError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Parsed Extensible Array header (AEHD).
|
/// Parsed Extensible Array header (AEHD).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ExtensibleArrayHeader {
|
pub struct ExtensibleArrayHeader {
|
||||||
@@ -145,6 +170,8 @@ impl ExtensibleArrayHeader {
|
|||||||
pos += ls; // skip nelmts
|
pos += ls; // skip nelmts
|
||||||
pos += ls; // skip max_idx_set (6th stats field)
|
pos += ls; // skip max_idx_set (6th stats field)
|
||||||
let index_block_address = read_offset(d, pos, offset_size)?;
|
let index_block_address = read_offset(d, pos, offset_size)?;
|
||||||
|
pos += offset_size as usize;
|
||||||
|
verify_checksum(file_data, offset, offset + pos)?;
|
||||||
|
|
||||||
Ok(ExtensibleArrayHeader {
|
Ok(ExtensibleArrayHeader {
|
||||||
client_id,
|
client_id,
|
||||||
@@ -270,6 +297,40 @@ fn index_to_chunk_offsets(
|
|||||||
|
|
||||||
/// Collect elements from a data block at the given offset.
|
/// Collect elements from a data block at the given offset.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
/// Layout of super block `u`, per the HDF5 spec: the number of data blocks it
|
||||||
|
/// owns and how many elements each of them holds.
|
||||||
|
///
|
||||||
|
/// `ndblks` and `dblk_nelmts` each double every *other* level, a half-step
|
||||||
|
/// apart, so the blocks grow as 1x16, 1x32, 2x32, 2x64, 4x64 ... for a
|
||||||
|
/// 16-element minimum. Treating either as doubling every level (the previous
|
||||||
|
/// implementation) puts every element after the first data block at the wrong
|
||||||
|
/// index.
|
||||||
|
fn sblk_info(u: usize, data_blk_min_elmts: usize) -> Option<(usize, usize)> {
|
||||||
|
let ndblks = 1usize.checked_shl((u / 2) as u32)?;
|
||||||
|
let dblk_nelmts = 1usize
|
||||||
|
.checked_shl(u.div_ceil(2) as u32)?
|
||||||
|
.checked_mul(data_blk_min_elmts)?;
|
||||||
|
Some((ndblks, dblk_nelmts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Width of the "offset of the block in the array" field carried by super and
|
||||||
|
/// data blocks (`hdr->arr_off_size`).
|
||||||
|
fn arr_off_size(header: &ExtensibleArrayHeader) -> usize {
|
||||||
|
(header.max_nelmts_bits as usize).div_ceil(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Elements per data block page, once a data block is large enough to be paged.
|
||||||
|
fn page_nelmts(header: &ExtensibleArrayHeader) -> Option<usize> {
|
||||||
|
1usize.checked_shl(u32::from(header.max_dblk_nelmts_bits))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the elements of one data block (EADB).
|
||||||
|
///
|
||||||
|
/// `page_init` is the owning super block's page-init bitmap and `first_page`
|
||||||
|
/// this block's first bit in it; both are only consulted when the block is
|
||||||
|
/// paged. The bitmap lives in the super block, not here — a paged data block
|
||||||
|
/// stores only its prefix, then one slot per page.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn read_data_block_elements(
|
fn read_data_block_elements(
|
||||||
file_data: &[u8],
|
file_data: &[u8],
|
||||||
db_offset: usize,
|
db_offset: usize,
|
||||||
@@ -280,117 +341,101 @@ fn read_data_block_elements(
|
|||||||
start_index: usize,
|
start_index: usize,
|
||||||
num_chunks_per_dim: &[u64],
|
num_chunks_per_dim: &[u64],
|
||||||
chunk_dimensions: &[u32],
|
chunk_dimensions: &[u32],
|
||||||
|
page_init: &[u8],
|
||||||
|
first_page: usize,
|
||||||
) -> Result<Vec<ChunkInfo>, FormatError> {
|
) -> Result<Vec<ChunkInfo>, FormatError> {
|
||||||
// AEDB: signature(4) + version(1) + client_id(1) + header_address(offset_size)
|
// EADB: signature(4) + version(1) + client_id(1) + header_address(offset_size)
|
||||||
let db_header_size = 4 + 1 + 1 + offset_size as usize;
|
// + block offset(arr_off_size)
|
||||||
|
let db_header_size = 4 + 1 + 1 + offset_size as usize + arr_off_size(header);
|
||||||
ensure_len(file_data, db_offset, db_header_size)?;
|
ensure_len(file_data, db_offset, db_header_size)?;
|
||||||
|
|
||||||
let d = &file_data[db_offset..];
|
if &file_data[db_offset..db_offset + 4] != b"EADB" {
|
||||||
if &d[0..4] != b"EADB" {
|
|
||||||
return Err(FormatError::ChunkedReadError(
|
return Err(FormatError::ChunkedReadError(
|
||||||
"invalid Extensible Array data block signature".into(),
|
"invalid Extensible Array data block signature".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Skip version(1) + client_id(1) + header_address(offset_size) + block_offset
|
|
||||||
// Block offset is encoded in ceil(max_nelmts_bits/8) bytes
|
|
||||||
let blk_off_size = (header.max_nelmts_bits as usize).div_ceil(8);
|
|
||||||
let mut pos = db_offset + db_header_size + blk_off_size;
|
|
||||||
|
|
||||||
// Check if paged
|
let mut pos = db_offset + db_header_size;
|
||||||
if header.max_nelmts_bits >= usize::BITS as u8 {
|
let page = page_nelmts(header).ok_or_else(|| {
|
||||||
return Err(FormatError::Overflow(
|
FormatError::Overflow("Extensible Array page element count overflows usize".into())
|
||||||
"max_nelmts_bits exceeds usize bit width".into(),
|
})?;
|
||||||
));
|
|
||||||
}
|
|
||||||
let page_nelmts = 1usize << header.max_nelmts_bits;
|
|
||||||
let is_paged = nelmts > page_nelmts;
|
|
||||||
|
|
||||||
let mut chunks = Vec::new();
|
let mut chunks = Vec::new();
|
||||||
|
let read_run = |from: usize,
|
||||||
if !is_paged {
|
count: usize,
|
||||||
for i in 0..nelmts {
|
first_index: usize,
|
||||||
|
chunks: &mut Vec<ChunkInfo>|
|
||||||
|
-> Result<usize, FormatError> {
|
||||||
|
let mut p = from;
|
||||||
|
for i in 0..count {
|
||||||
let (info, consumed) = read_element(
|
let (info, consumed) = read_element(
|
||||||
file_data,
|
file_data,
|
||||||
pos,
|
p,
|
||||||
header.client_id,
|
header.client_id,
|
||||||
header.element_size,
|
header.element_size,
|
||||||
offset_size,
|
offset_size,
|
||||||
chunk_byte_size,
|
chunk_byte_size,
|
||||||
start_index + i,
|
first_index + i,
|
||||||
num_chunks_per_dim,
|
num_chunks_per_dim,
|
||||||
chunk_dimensions,
|
chunk_dimensions,
|
||||||
)?;
|
)?;
|
||||||
if let Some(ci) = info {
|
if let Some(ci) = info {
|
||||||
chunks.push(ci);
|
chunks.push(ci);
|
||||||
}
|
}
|
||||||
pos += consumed;
|
p += consumed;
|
||||||
}
|
}
|
||||||
} else {
|
Ok(p)
|
||||||
// Paged: elements are split into pages of page_nelmts.
|
};
|
||||||
// After the data block header comes a page bitmap, then each page
|
|
||||||
// has page_nelmts elements followed by a 4-byte checksum.
|
|
||||||
let npages = nelmts.div_ceil(page_nelmts);
|
|
||||||
// Page bitmap: ceil(npages / 8) bytes
|
|
||||||
let bitmap_size = npages.div_ceil(8);
|
|
||||||
// Read bitmap
|
|
||||||
if pos + bitmap_size > file_data.len() {
|
|
||||||
return Err(FormatError::UnexpectedEof {
|
|
||||||
expected: pos + bitmap_size,
|
|
||||||
available: file_data.len(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let bitmap = &file_data[pos..pos + bitmap_size];
|
|
||||||
pos += bitmap_size;
|
|
||||||
|
|
||||||
|
if nelmts <= page {
|
||||||
|
// Prefix and elements are covered by one checksum.
|
||||||
let elem_bytes = if header.client_id == 0 {
|
let elem_bytes = if header.client_id == 0 {
|
||||||
offset_size as usize
|
offset_size as usize
|
||||||
} else {
|
} else {
|
||||||
header.element_size as usize
|
header.element_size as usize
|
||||||
};
|
};
|
||||||
|
let end = nelmts
|
||||||
|
.checked_mul(elem_bytes)
|
||||||
|
.and_then(|b| pos.checked_add(b))
|
||||||
|
.ok_or_else(|| FormatError::Overflow("Extensible Array data block span".into()))?;
|
||||||
|
verify_checksum(file_data, db_offset, end)?;
|
||||||
|
read_run(pos, nelmts, start_index, &mut chunks)?;
|
||||||
|
return Ok(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
let mut global_idx = start_index;
|
// Paged: the prefix ends with its own checksum, then one slot per page,
|
||||||
for page_idx in 0..npages {
|
// each holding `page` elements followed by a checksum. Pages whose bit is
|
||||||
let byte_idx = page_idx / 8;
|
// clear were never written; their slot still occupies the file, so stride
|
||||||
let bit_idx = page_idx % 8;
|
// over it rather than reading zeros as addresses.
|
||||||
let page_has_data = (bitmap[byte_idx] >> bit_idx) & 1 != 0;
|
verify_checksum(file_data, db_offset, pos)?;
|
||||||
|
pos += 4;
|
||||||
let elems_this_page = if page_idx == npages - 1 {
|
let elem_bytes = if header.client_id == 0 {
|
||||||
let remainder = nelmts % page_nelmts;
|
offset_size as usize
|
||||||
if remainder == 0 {
|
} else {
|
||||||
page_nelmts
|
header.element_size as usize
|
||||||
} else {
|
};
|
||||||
remainder
|
let page_stride = page
|
||||||
}
|
.checked_mul(elem_bytes)
|
||||||
} else {
|
.and_then(|b| b.checked_add(4))
|
||||||
page_nelmts
|
.ok_or_else(|| FormatError::Overflow("Extensible Array page stride".into()))?;
|
||||||
};
|
let npages = nelmts.div_ceil(page);
|
||||||
|
for p in 0..npages {
|
||||||
if page_has_data {
|
// One bit per page across the whole super block, packed contiguously
|
||||||
for i in 0..elems_this_page {
|
// and MSB-first within each byte, as H5VM_bit_get reads it.
|
||||||
let (info, consumed) = read_element(
|
let bit = first_page + p;
|
||||||
file_data,
|
let initialised = page_init
|
||||||
pos,
|
.get(bit / 8)
|
||||||
header.client_id,
|
.is_some_and(|byte| byte & (0x80 >> (bit % 8)) != 0);
|
||||||
header.element_size,
|
if initialised {
|
||||||
offset_size,
|
let count = core::cmp::min(page, nelmts - p * page);
|
||||||
chunk_byte_size,
|
// Each page carries its own checksum, over a full page's worth of
|
||||||
global_idx + i,
|
// slots even when the last one holds fewer live elements.
|
||||||
num_chunks_per_dim,
|
verify_checksum(file_data, pos, pos + page * elem_bytes)?;
|
||||||
chunk_dimensions,
|
read_run(pos, count, start_index + p * page, &mut chunks)?;
|
||||||
)?;
|
|
||||||
if let Some(ci) = info {
|
|
||||||
chunks.push(ci);
|
|
||||||
}
|
|
||||||
pos += consumed;
|
|
||||||
}
|
|
||||||
// Skip page checksum (4 bytes)
|
|
||||||
pos += 4;
|
|
||||||
} else {
|
|
||||||
// Empty page: skip all elements + checksum
|
|
||||||
pos += elems_this_page * elem_bytes + 4;
|
|
||||||
}
|
|
||||||
global_idx += elems_this_page;
|
|
||||||
}
|
}
|
||||||
|
pos = pos
|
||||||
|
.checked_add(page_stride)
|
||||||
|
.ok_or_else(|| FormatError::Overflow("Extensible Array page offset".into()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(chunks)
|
Ok(chunks)
|
||||||
@@ -427,30 +472,83 @@ pub fn read_extensible_array_chunks(
|
|||||||
let chunk_byte_size: u64 =
|
let chunk_byte_size: u64 =
|
||||||
chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64;
|
chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64;
|
||||||
|
|
||||||
// Parse index block (AEIB)
|
// Parse index block (EAIB): signature(4) + version(1) + client_id(1)
|
||||||
|
// + header address(offset_size), then the inline elements, then the
|
||||||
|
// direct data block addresses, then the super block addresses.
|
||||||
let ib_offset = header.index_block_address as usize;
|
let ib_offset = header.index_block_address as usize;
|
||||||
let ib_header_size = 4 + 1 + 1 + offset_size as usize; // sig + ver + client + hdr_addr
|
let ib_header_size = 4 + 1 + 1 + os;
|
||||||
ensure_len(file_data, ib_offset, ib_header_size)?;
|
ensure_len(file_data, ib_offset, ib_header_size)?;
|
||||||
|
|
||||||
let ib = &file_data[ib_offset..];
|
if &file_data[ib_offset..ib_offset + 4] != b"EAIB" {
|
||||||
if &ib[0..4] != b"EAIB" {
|
|
||||||
return Err(FormatError::ChunkedReadError(
|
return Err(FormatError::ChunkedReadError(
|
||||||
"invalid Extensible Array index block signature".into(),
|
"invalid Extensible Array index block signature".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Skip version(1) + client_id(1) + header_address(offset_size)
|
|
||||||
let mut pos = ib_offset + ib_header_size;
|
let mut pos = ib_offset + ib_header_size;
|
||||||
|
|
||||||
let mut chunks = Vec::new();
|
let mut chunks = Vec::new();
|
||||||
let mut global_index = 0usize;
|
|
||||||
let total_elements = header.num_elements as usize;
|
let total_elements = header.num_elements as usize;
|
||||||
|
|
||||||
// 1. Read inline elements in index block
|
let dmin = header.min_dblk_nelmts as usize;
|
||||||
let n_inline = header.idx_blk_elmts as usize;
|
if dmin == 0 || !dmin.is_power_of_two() {
|
||||||
for i in 0..n_inline {
|
return Err(FormatError::ChunkedReadError(
|
||||||
if global_index + i >= total_elements {
|
"Extensible Array data block minimum is not a power of two".into(),
|
||||||
break;
|
));
|
||||||
|
}
|
||||||
|
// nsblks = 1 + (max_nelmts_bits - log2(data_blk_min_elmts)), and the index
|
||||||
|
// block holds 2 * (sup_blk_min_data_ptrs - 1) data block addresses.
|
||||||
|
let log2_dmin = dmin.trailing_zeros() as usize;
|
||||||
|
let nsblks = 1 + (header.max_nelmts_bits as usize).saturating_sub(log2_dmin);
|
||||||
|
let ndblk_addrs = 2 * (header.super_blk_min_nelmts as usize).saturating_sub(1);
|
||||||
|
|
||||||
|
// The data blocks listed directly in the index block are the first
|
||||||
|
// `ndblk_addrs` in super-block order, each sized by the level it belongs
|
||||||
|
// to; the super block addresses that follow resume at the next level.
|
||||||
|
let mut direct: Vec<usize> = Vec::with_capacity(ndblk_addrs);
|
||||||
|
let mut level = 0usize;
|
||||||
|
while direct.len() < ndblk_addrs {
|
||||||
|
if level >= nsblks {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"Extensible Array index block claims more data blocks than the array has".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
let (ndblks, dblk_nelmts) = sblk_info(level, dmin).ok_or_else(|| {
|
||||||
|
FormatError::Overflow("Extensible Array super block layout overflows usize".into())
|
||||||
|
})?;
|
||||||
|
for _ in 0..ndblks {
|
||||||
|
direct.push(dblk_nelmts);
|
||||||
|
}
|
||||||
|
level += 1;
|
||||||
|
}
|
||||||
|
if direct.len() != ndblk_addrs {
|
||||||
|
// A partial level in the index block is not a layout HDF5 produces,
|
||||||
|
// and guessing where the super blocks resume would misplace elements.
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"Extensible Array index block ends mid super block".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One checksum covers the prefix, every inline element slot, and every
|
||||||
|
// data block and super block address.
|
||||||
|
let elem_bytes = if header.client_id == 0 {
|
||||||
|
os
|
||||||
|
} else {
|
||||||
|
header.element_size as usize
|
||||||
|
};
|
||||||
|
let ib_end = (header.idx_blk_elmts as usize)
|
||||||
|
.checked_mul(elem_bytes)
|
||||||
|
.and_then(|b| pos.checked_add(b))
|
||||||
|
.and_then(|p| {
|
||||||
|
ndblk_addrs
|
||||||
|
.checked_add(nsblks - level)
|
||||||
|
.and_then(|n| n.checked_mul(os).and_then(|b| p.checked_add(b)))
|
||||||
|
})
|
||||||
|
.ok_or_else(|| FormatError::Overflow("Extensible Array index block span".into()))?;
|
||||||
|
verify_checksum(file_data, ib_offset, ib_end)?;
|
||||||
|
|
||||||
|
// 1. Elements stored inline in the index block.
|
||||||
|
let n_inline = (header.idx_blk_elmts as usize).min(total_elements);
|
||||||
|
for i in 0..n_inline {
|
||||||
let (info, consumed) = read_element(
|
let (info, consumed) = read_element(
|
||||||
file_data,
|
file_data,
|
||||||
pos,
|
pos,
|
||||||
@@ -458,7 +556,7 @@ pub fn read_extensible_array_chunks(
|
|||||||
header.element_size,
|
header.element_size,
|
||||||
offset_size,
|
offset_size,
|
||||||
chunk_byte_size,
|
chunk_byte_size,
|
||||||
global_index + i,
|
i,
|
||||||
&num_chunks_per_dim,
|
&num_chunks_per_dim,
|
||||||
chunk_dimensions,
|
chunk_dimensions,
|
||||||
)?;
|
)?;
|
||||||
@@ -467,154 +565,90 @@ pub fn read_extensible_array_chunks(
|
|||||||
}
|
}
|
||||||
pos += consumed;
|
pos += consumed;
|
||||||
}
|
}
|
||||||
global_index += n_inline.min(total_elements);
|
let mut global_index = n_inline;
|
||||||
|
|
||||||
// If all elements were inline, we're done
|
|
||||||
if global_index >= total_elements {
|
if global_index >= total_elements {
|
||||||
return Ok(chunks);
|
return Ok(chunks);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute data block and super block counts
|
// 2. Data blocks listed directly in the index block.
|
||||||
let min_dblk = header.min_dblk_nelmts as usize;
|
for &dblk_nelmts in &direct {
|
||||||
let sblk_min = header.super_blk_min_nelmts as usize;
|
if global_index >= total_elements {
|
||||||
|
return Ok(chunks);
|
||||||
// The first sblk_min super block levels have their data blocks listed directly
|
}
|
||||||
// in the index block. Compute their sizes.
|
ensure_len(file_data, pos, os)?;
|
||||||
let mut n_direct_dblks = 0usize;
|
let addr = read_offset(file_data, pos, offset_size)?;
|
||||||
let mut dblk_sizes: Vec<usize> = Vec::new();
|
pos += os;
|
||||||
{
|
if !is_undefined_addr(addr, offset_size) {
|
||||||
let mut nelmts = min_dblk;
|
if dblk_nelmts > page_nelmts(header).unwrap_or(usize::MAX) {
|
||||||
for sb_level in 0..sblk_min {
|
// Would need a page-init bitmap, which only a super block
|
||||||
if sb_level >= usize::BITS as usize {
|
// carries. HDF5 never pages these small early blocks.
|
||||||
return Err(FormatError::Overflow(
|
return Err(FormatError::ChunkedReadError(
|
||||||
"sb_level exceeds usize bit width".into(),
|
"Extensible Array index block references a paged data block".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let ndblks = 1usize << sb_level;
|
chunks.extend(read_data_block_elements(
|
||||||
for _ in 0..ndblks {
|
file_data,
|
||||||
dblk_sizes.push(nelmts);
|
addr as usize,
|
||||||
n_direct_dblks += 1;
|
dblk_nelmts,
|
||||||
}
|
header,
|
||||||
if sb_level > 0 {
|
offset_size,
|
||||||
nelmts *= 2;
|
chunk_byte_size,
|
||||||
}
|
global_index,
|
||||||
|
&num_chunks_per_dim,
|
||||||
|
chunk_dimensions,
|
||||||
|
&[],
|
||||||
|
0,
|
||||||
|
)?);
|
||||||
}
|
}
|
||||||
|
global_index += dblk_nelmts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read direct data block addresses from index block
|
// 3. Everything else lives in super blocks, one address per remaining
|
||||||
let mut dblk_addrs: Vec<u64> = Vec::with_capacity(n_direct_dblks);
|
// level, starting at the level after the direct data blocks.
|
||||||
for _ in 0..n_direct_dblks {
|
for u in level..nsblks {
|
||||||
if pos + os > file_data.len() {
|
if global_index >= total_elements {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let addr = read_offset(file_data, pos, offset_size)?;
|
ensure_len(file_data, pos, os)?;
|
||||||
dblk_addrs.push(addr);
|
let sb_addr = read_offset(file_data, pos, offset_size)?;
|
||||||
pos += os;
|
pos += os;
|
||||||
}
|
let (ndblks, dblk_nelmts) = sblk_info(u, dmin).ok_or_else(|| {
|
||||||
|
FormatError::Overflow("Extensible Array super block layout overflows usize".into())
|
||||||
// Read elements from direct data blocks
|
})?;
|
||||||
for (i, &addr) in dblk_addrs.iter().enumerate() {
|
if !is_undefined_addr(sb_addr, offset_size) {
|
||||||
if i >= dblk_sizes.len() {
|
chunks.extend(read_super_block(
|
||||||
break;
|
file_data,
|
||||||
|
sb_addr as usize,
|
||||||
|
ndblks,
|
||||||
|
dblk_nelmts,
|
||||||
|
header,
|
||||||
|
offset_size,
|
||||||
|
chunk_byte_size,
|
||||||
|
global_index,
|
||||||
|
&num_chunks_per_dim,
|
||||||
|
chunk_dimensions,
|
||||||
|
)?);
|
||||||
}
|
}
|
||||||
let nelmts = dblk_sizes[i];
|
global_index =
|
||||||
if is_undefined_addr(addr, offset_size) {
|
global_index.saturating_add(ndblks.checked_mul(dblk_nelmts).ok_or_else(|| {
|
||||||
global_index += nelmts;
|
FormatError::Overflow("Extensible Array super block span".into())
|
||||||
continue;
|
})?);
|
||||||
}
|
|
||||||
let block_chunks = read_data_block_elements(
|
|
||||||
file_data,
|
|
||||||
addr as usize,
|
|
||||||
nelmts,
|
|
||||||
header,
|
|
||||||
offset_size,
|
|
||||||
chunk_byte_size,
|
|
||||||
global_index,
|
|
||||||
&num_chunks_per_dim,
|
|
||||||
chunk_dimensions,
|
|
||||||
)?;
|
|
||||||
chunks.extend(block_chunks);
|
|
||||||
global_index += nelmts;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remaining elements are in super blocks
|
|
||||||
let total_in_ib_and_direct: usize = n_inline + dblk_sizes.iter().sum::<usize>();
|
|
||||||
if total_elements <= total_in_ib_and_direct {
|
|
||||||
return Ok(chunks);
|
|
||||||
}
|
|
||||||
let remaining_elements = total_elements - total_in_ib_and_direct;
|
|
||||||
|
|
||||||
// Compute super block layout
|
|
||||||
let mut sb_addrs: Vec<u64> = Vec::new();
|
|
||||||
let mut sb_infos: Vec<(usize, usize)> = Vec::new();
|
|
||||||
{
|
|
||||||
let mut covered = 0usize;
|
|
||||||
let mut sb_level = sblk_min;
|
|
||||||
let mut nelmts_per_dblk = min_dblk;
|
|
||||||
for lev in 0..sblk_min {
|
|
||||||
if lev > 0 {
|
|
||||||
nelmts_per_dblk *= 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while covered < remaining_elements {
|
|
||||||
if sb_level >= usize::BITS as usize {
|
|
||||||
return Err(FormatError::Overflow(
|
|
||||||
"sb_level exceeds usize bit width".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let ndblks = 1usize << sb_level;
|
|
||||||
nelmts_per_dblk *= 2;
|
|
||||||
let total_in_sb = ndblks * nelmts_per_dblk;
|
|
||||||
sb_infos.push((ndblks, nelmts_per_dblk));
|
|
||||||
covered += total_in_sb;
|
|
||||||
sb_level += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read super block addresses from index block
|
|
||||||
for _ in 0..sb_infos.len() {
|
|
||||||
if pos + os > file_data.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let addr = read_offset(file_data, pos, offset_size)?;
|
|
||||||
sb_addrs.push(addr);
|
|
||||||
pos += os;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process each super block
|
|
||||||
for (sb_idx, &sb_addr) in sb_addrs.iter().enumerate() {
|
|
||||||
let (ndblks, nelmts_per_dblk) = sb_infos[sb_idx];
|
|
||||||
if is_undefined_addr(sb_addr, offset_size) {
|
|
||||||
global_index += ndblks * nelmts_per_dblk;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let sb_chunks = read_super_block(
|
|
||||||
file_data,
|
|
||||||
sb_addr as usize,
|
|
||||||
ndblks,
|
|
||||||
nelmts_per_dblk,
|
|
||||||
header,
|
|
||||||
offset_size,
|
|
||||||
chunk_byte_size,
|
|
||||||
global_index,
|
|
||||||
&num_chunks_per_dim,
|
|
||||||
chunk_dimensions,
|
|
||||||
)?;
|
|
||||||
chunks.extend(sb_chunks);
|
|
||||||
global_index += ndblks * nelmts_per_dblk;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(chunks)
|
Ok(chunks)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a super block (AESB) and its data blocks.
|
/// Read a super block (EASB) and the data blocks it owns.
|
||||||
|
///
|
||||||
|
/// On disk: signature(4) + version(1) + client_id(1) + header address
|
||||||
|
/// + block offset + the page-init bitmap for every data block it owns
|
||||||
|
/// + one address per data block + checksum.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn read_super_block(
|
fn read_super_block(
|
||||||
file_data: &[u8],
|
file_data: &[u8],
|
||||||
sb_offset: usize,
|
sb_offset: usize,
|
||||||
ndblks: usize,
|
ndblks: usize,
|
||||||
nelmts_per_dblk: usize,
|
dblk_nelmts: usize,
|
||||||
header: &ExtensibleArrayHeader,
|
header: &ExtensibleArrayHeader,
|
||||||
offset_size: u8,
|
offset_size: u8,
|
||||||
chunk_byte_size: u64,
|
chunk_byte_size: u64,
|
||||||
@@ -623,9 +657,7 @@ fn read_super_block(
|
|||||||
chunk_dimensions: &[u32],
|
chunk_dimensions: &[u32],
|
||||||
) -> Result<Vec<ChunkInfo>, FormatError> {
|
) -> Result<Vec<ChunkInfo>, FormatError> {
|
||||||
let os = offset_size as usize;
|
let os = offset_size as usize;
|
||||||
|
let sb_header_size = 4 + 1 + 1 + os + arr_off_size(header);
|
||||||
// AESB: signature(4) + version(1) + client_id(1) + header_address(offset_size)
|
|
||||||
let sb_header_size = 4 + 1 + 1 + os;
|
|
||||||
ensure_len(file_data, sb_offset, sb_header_size)?;
|
ensure_len(file_data, sb_offset, sb_header_size)?;
|
||||||
|
|
||||||
if &file_data[sb_offset..sb_offset + 4] != b"EASB" {
|
if &file_data[sb_offset..sb_offset + 4] != b"EASB" {
|
||||||
@@ -634,43 +666,57 @@ fn read_super_block(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut pos = sb_offset + sb_header_size;
|
// Page-init bitmap: one bit per page, `npages` bits per data block, packed
|
||||||
|
// contiguously. HDF5 sizes the buffer `ndblks * ceil(npages / 8)`, which
|
||||||
// Read data block addresses
|
// is bigger than the bits need when `npages` is not a multiple of eight.
|
||||||
let mut dblk_addrs: Vec<u64> = Vec::with_capacity(ndblks);
|
// Zero-sized unless this level's data blocks are paged.
|
||||||
for _ in 0..ndblks {
|
let page = page_nelmts(header).ok_or_else(|| {
|
||||||
if pos + os > file_data.len() {
|
FormatError::Overflow("Extensible Array page element count overflows usize".into())
|
||||||
return Err(FormatError::UnexpectedEof {
|
})?;
|
||||||
expected: pos + os,
|
let npages = if dblk_nelmts > page {
|
||||||
available: file_data.len(),
|
dblk_nelmts / page
|
||||||
});
|
} else {
|
||||||
}
|
0
|
||||||
let addr = read_offset(file_data, pos, offset_size)?;
|
};
|
||||||
dblk_addrs.push(addr);
|
let per_dblk_bitmap = npages.div_ceil(8);
|
||||||
pos += os;
|
let bitmap_bytes = per_dblk_bitmap
|
||||||
}
|
.checked_mul(ndblks)
|
||||||
|
.ok_or_else(|| FormatError::Overflow("Extensible Array page bitmap size".into()))?;
|
||||||
|
let bitmap_start = sb_offset + sb_header_size;
|
||||||
|
ensure_len(file_data, bitmap_start, bitmap_bytes)?;
|
||||||
|
let bitmap = &file_data[bitmap_start..bitmap_start + bitmap_bytes];
|
||||||
|
|
||||||
|
let mut pos = bitmap_start + bitmap_bytes;
|
||||||
let mut chunks = Vec::new();
|
let mut chunks = Vec::new();
|
||||||
let mut global_idx = start_index;
|
let mut global_idx = start_index;
|
||||||
|
|
||||||
for &addr in &dblk_addrs {
|
// One checksum covers the prefix, the bitmap and every data block address.
|
||||||
if is_undefined_addr(addr, offset_size) {
|
let sb_end = ndblks
|
||||||
global_idx += nelmts_per_dblk;
|
.checked_mul(os)
|
||||||
continue;
|
.and_then(|b| pos.checked_add(b))
|
||||||
|
.ok_or_else(|| FormatError::Overflow("Extensible Array super block span".into()))?;
|
||||||
|
verify_checksum(file_data, sb_offset, sb_end)?;
|
||||||
|
|
||||||
|
for i in 0..ndblks {
|
||||||
|
ensure_len(file_data, pos, os)?;
|
||||||
|
let addr = read_offset(file_data, pos, offset_size)?;
|
||||||
|
pos += os;
|
||||||
|
if !is_undefined_addr(addr, offset_size) {
|
||||||
|
chunks.extend(read_data_block_elements(
|
||||||
|
file_data,
|
||||||
|
addr as usize,
|
||||||
|
dblk_nelmts,
|
||||||
|
header,
|
||||||
|
offset_size,
|
||||||
|
chunk_byte_size,
|
||||||
|
global_idx,
|
||||||
|
num_chunks_per_dim,
|
||||||
|
chunk_dimensions,
|
||||||
|
bitmap,
|
||||||
|
i * npages,
|
||||||
|
)?);
|
||||||
}
|
}
|
||||||
let block_chunks = read_data_block_elements(
|
global_idx += dblk_nelmts;
|
||||||
file_data,
|
|
||||||
addr as usize,
|
|
||||||
nelmts_per_dblk,
|
|
||||||
header,
|
|
||||||
offset_size,
|
|
||||||
chunk_byte_size,
|
|
||||||
global_idx,
|
|
||||||
num_chunks_per_dim,
|
|
||||||
chunk_dimensions,
|
|
||||||
)?;
|
|
||||||
chunks.extend(block_chunks);
|
|
||||||
global_idx += nelmts_per_dblk;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(chunks)
|
Ok(chunks)
|
||||||
@@ -679,6 +725,14 @@ fn read_super_block(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Stamp the Jenkins checksum a real file would carry over
|
||||||
|
/// `data[start..end]`, writing it at `end`. Hand-built fixtures need this
|
||||||
|
/// now that the reader validates it, exactly as HDF5 writes it.
|
||||||
|
fn stamp_checksum(data: &mut [u8], start: usize, end: usize) {
|
||||||
|
let sum = crate::checksum::jenkins_lookup3(&data[start..end]);
|
||||||
|
data[end..end + 4].copy_from_slice(&sum.to_le_bytes());
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn index_to_offsets_1d() {
|
fn index_to_offsets_1d() {
|
||||||
let num_chunks = vec![5u64];
|
let num_chunks = vec![5u64];
|
||||||
@@ -734,6 +788,7 @@ mod tests {
|
|||||||
buf[44..52].copy_from_slice(&5u64.to_le_bytes()); // stat[4] = num_elements
|
buf[44..52].copy_from_slice(&5u64.to_le_bytes()); // stat[4] = num_elements
|
||||||
buf[52..60].copy_from_slice(&0u64.to_le_bytes()); // stat[5]
|
buf[52..60].copy_from_slice(&0u64.to_le_bytes()); // stat[5]
|
||||||
buf[60..68].copy_from_slice(&0x1000u64.to_le_bytes()); // index_block_address
|
buf[60..68].copy_from_slice(&0x1000u64.to_le_bytes()); // index_block_address
|
||||||
|
stamp_checksum(&mut buf, 0, 68);
|
||||||
|
|
||||||
let hdr = ExtensibleArrayHeader::parse(&buf, 0, os, ls).unwrap();
|
let hdr = ExtensibleArrayHeader::parse(&buf, 0, os, ls).unwrap();
|
||||||
assert_eq!(hdr.client_id, 0);
|
assert_eq!(hdr.client_id, 0);
|
||||||
@@ -819,6 +874,7 @@ mod tests {
|
|||||||
.copy_from_slice(&(num_chunks as u64).to_le_bytes());
|
.copy_from_slice(&(num_chunks as u64).to_le_bytes());
|
||||||
file_data[aehd_offset + 60..aehd_offset + 68]
|
file_data[aehd_offset + 60..aehd_offset + 68]
|
||||||
.copy_from_slice(&(aeib_offset as u64).to_le_bytes());
|
.copy_from_slice(&(aeib_offset as u64).to_le_bytes());
|
||||||
|
stamp_checksum(&mut file_data, aehd_offset, aehd_offset + 68);
|
||||||
// checksum (4 bytes at +68) — not validated
|
// checksum (4 bytes at +68) — not validated
|
||||||
|
|
||||||
// Build AEIB at aeib_offset
|
// Build AEIB at aeib_offset
|
||||||
@@ -836,6 +892,23 @@ mod tests {
|
|||||||
let p = elem_start + i * osv;
|
let p = elem_start + i * osv;
|
||||||
file_data[p..p + osv].copy_from_slice(&addr.to_le_bytes());
|
file_data[p..p + osv].copy_from_slice(&addr.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
// The index block's checksum covers its prefix, every inline element
|
||||||
|
// slot, and every data block and super block address slot:
|
||||||
|
// ndblk_addrs = 2 * (sup_blk_min_data_ptrs - 1), and the super block
|
||||||
|
// pointers make up the rest of nsblks levels.
|
||||||
|
let sup_ptrs = file_data[aehd_offset + 10] as usize;
|
||||||
|
let dmin = file_data[aehd_offset + 9] as usize;
|
||||||
|
let nsblks = 1 + 10 - dmin.trailing_zeros() as usize;
|
||||||
|
let ndblk_addrs = 2 * (sup_ptrs - 1);
|
||||||
|
// Levels consumed by those direct data blocks (1, 1, 2, 2, ... per level).
|
||||||
|
let mut consumed = 0usize;
|
||||||
|
let mut levels = 0usize;
|
||||||
|
while consumed < ndblk_addrs {
|
||||||
|
consumed += 1 << (levels / 2);
|
||||||
|
levels += 1;
|
||||||
|
}
|
||||||
|
let ib_end = elem_start + num_chunks * osv + (ndblk_addrs + nsblks - levels) * osv;
|
||||||
|
stamp_checksum(&mut file_data, aeib_offset, ib_end);
|
||||||
|
|
||||||
let header = ExtensibleArrayHeader::parse(&file_data, aehd_offset, os, ls).unwrap();
|
let header = ExtensibleArrayHeader::parse(&file_data, aehd_offset, os, ls).unwrap();
|
||||||
let ds_dims = vec![40u64]; // 2 chunks × 20 elements
|
let ds_dims = vec![40u64]; // 2 chunks × 20 elements
|
||||||
@@ -885,6 +958,7 @@ mod tests {
|
|||||||
// idx_blk_addr at offset 12 + 6*8 = 60
|
// idx_blk_addr at offset 12 + 6*8 = 60
|
||||||
file_data[aehd_offset + 60..aehd_offset + 68]
|
file_data[aehd_offset + 60..aehd_offset + 68]
|
||||||
.copy_from_slice(&(aeib_offset as u64).to_le_bytes());
|
.copy_from_slice(&(aeib_offset as u64).to_le_bytes());
|
||||||
|
stamp_checksum(&mut file_data, aehd_offset, aehd_offset + 68);
|
||||||
|
|
||||||
// AEIB
|
// AEIB
|
||||||
file_data[aeib_offset..aeib_offset + 4].copy_from_slice(b"EAIB");
|
file_data[aeib_offset..aeib_offset + 4].copy_from_slice(b"EAIB");
|
||||||
@@ -903,42 +977,48 @@ mod tests {
|
|||||||
pos += osv;
|
pos += osv;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct data block addresses: first sb_level=0 has 1 dblk, sb_level=1 has 1 dblk
|
// Direct data block addresses. With sup_blk_min_data_ptrs = 2 the index
|
||||||
// Total direct dblks for sblk_min=2: 2^0 + 2^1 = 1 + 2 = 3 (oops)
|
// block holds 2 * (2 - 1) = 2 of them, which are the data blocks of
|
||||||
// Actually: sblk_min levels. level 0: 2^0=1 dblk, level 1: 2^1=2 dblks => 3 dblks
|
// super block levels 0 and 1: one of `min_dblk_nelmts` elements, then
|
||||||
// But we only have 2 remaining elements.
|
// one of twice that (ndblks = 2^(u/2), dblk_nelmts = 2^((u+1)/2) * min).
|
||||||
// dblk sizes: level 0: 1 dblk of min_dblk=2; level 1: 2 dblks of 2 each (nelmts doubles at level > 0)
|
// Only the first is allocated here; the rest of the array is empty.
|
||||||
// Wait, re-reading the code: at level 0, nelmts=min_dblk=2, 1 dblk.
|
let ndblk_addrs = 2 * (sblk_min as usize - 1);
|
||||||
// At level 1, 1 dblk, nelmts still 2 (doubles only at level > 0... but the code says
|
|
||||||
// `if sb_level > 0 { nelmts *= 2 }` after pushing). Let me re-check.
|
|
||||||
// After push at level 0: nelmts=2. Then if 0>0 false, no double. Push 1 dblk of 2.
|
|
||||||
// Level 1: ndblks=2. Push 2 dblks of 2. Then 1>0 true, nelmts=4.
|
|
||||||
// Total: 3 dblks with sizes [2, 2, 2]. Total = 6.
|
|
||||||
// We only need 2 more elements. So only the first dblk has data.
|
|
||||||
let n_direct_dblks = 3;
|
|
||||||
file_data[pos..pos + osv].copy_from_slice(&(aedb_offset as u64).to_le_bytes());
|
file_data[pos..pos + osv].copy_from_slice(&(aedb_offset as u64).to_le_bytes());
|
||||||
pos += osv;
|
pos += osv;
|
||||||
// 2 more dblk addresses - undefined
|
for _ in 1..ndblk_addrs {
|
||||||
for _ in 1..n_direct_dblks {
|
|
||||||
file_data[pos..pos + osv].copy_from_slice(&u64::MAX.to_le_bytes());
|
file_data[pos..pos + osv].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||||
pos += osv;
|
pos += osv;
|
||||||
}
|
}
|
||||||
|
// Super block addresses fill the remaining levels; all unallocated.
|
||||||
|
let nsblks = 1 + 10 - (min_dblk_nelmts as usize).trailing_zeros() as usize;
|
||||||
|
let mut consumed = 0usize;
|
||||||
|
let mut levels = 0usize;
|
||||||
|
while consumed < ndblk_addrs {
|
||||||
|
consumed += 1 << (levels / 2);
|
||||||
|
levels += 1;
|
||||||
|
}
|
||||||
|
for _ in 0..(nsblks - levels) {
|
||||||
|
file_data[pos..pos + osv].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||||
|
pos += osv;
|
||||||
|
}
|
||||||
|
stamp_checksum(&mut file_data, aeib_offset, pos);
|
||||||
|
|
||||||
// EADB at aedb_offset (min_dblk_nelmts elements)
|
// EADB holding the first data block's `min_dblk_nelmts` elements.
|
||||||
file_data[aedb_offset..aedb_offset + 4].copy_from_slice(b"EADB");
|
file_data[aedb_offset..aedb_offset + 4].copy_from_slice(b"EADB");
|
||||||
file_data[aedb_offset + 4] = 0;
|
file_data[aedb_offset + 4] = 0;
|
||||||
file_data[aedb_offset + 5] = 0;
|
file_data[aedb_offset + 5] = 0;
|
||||||
file_data[aedb_offset + 6..aedb_offset + 14]
|
file_data[aedb_offset + 6..aedb_offset + 14]
|
||||||
.copy_from_slice(&(aehd_offset as u64).to_le_bytes());
|
.copy_from_slice(&(aehd_offset as u64).to_le_bytes());
|
||||||
// block_offset: ceil(max_nelmts_bits/8) = ceil(10/8) = 2 bytes
|
// Block offset field: ceil(max_nelmts_bits / 8) bytes, zero here.
|
||||||
// block_offset = 0 for first data block
|
let blk_off_size = (10usize).div_ceil(8);
|
||||||
let blk_off_size = (10usize).div_ceil(8); // max_nelmts_bits=10
|
let db_elems = aedb_offset + 6 + osv + blk_off_size;
|
||||||
let mut dbpos = aedb_offset + 6 + osv + blk_off_size;
|
let mut dbpos = db_elems;
|
||||||
for i in 0..min_dblk_nelmts as usize {
|
for i in 0..min_dblk_nelmts as usize {
|
||||||
let addr = base_addr + (idx_blk_elmts as u64 + i as u64) * chunk_byte_size;
|
let addr = base_addr + (idx_blk_elmts as u64 + i as u64) * chunk_byte_size;
|
||||||
file_data[dbpos..dbpos + osv].copy_from_slice(&addr.to_le_bytes());
|
file_data[dbpos..dbpos + osv].copy_from_slice(&addr.to_le_bytes());
|
||||||
dbpos += osv;
|
dbpos += osv;
|
||||||
}
|
}
|
||||||
|
stamp_checksum(&mut file_data, aedb_offset, dbpos);
|
||||||
|
|
||||||
let header = ExtensibleArrayHeader::parse(&file_data, aehd_offset, os, ls).unwrap();
|
let header = ExtensibleArrayHeader::parse(&file_data, aehd_offset, os, ls).unwrap();
|
||||||
let ds_dims = vec![40u64];
|
let ds_dims = vec![40u64];
|
||||||
|
|||||||
@@ -9,6 +9,31 @@ use alloc::{format, vec, vec::Vec};
|
|||||||
use crate::chunked_read::ChunkInfo;
|
use crate::chunked_read::ChunkInfo;
|
||||||
use crate::error::FormatError;
|
use crate::error::FormatError;
|
||||||
|
|
||||||
|
/// Verify the Jenkins lookup3 checksum stored immediately after
|
||||||
|
/// `data[start..end]`, as every Fixed Array structure carries one.
|
||||||
|
///
|
||||||
|
/// A corrupt chunk index silently yields addresses pointing at the wrong
|
||||||
|
/// bytes, so a mismatch has to be an error rather than a shrug: without this
|
||||||
|
/// the damage surfaces as plausible-looking data from the wrong chunk.
|
||||||
|
#[cfg(feature = "checksum")]
|
||||||
|
fn verify_checksum(data: &[u8], start: usize, end: usize) -> Result<(), FormatError> {
|
||||||
|
ensure_len(data, end, 4)?;
|
||||||
|
let stored = u32::from_le_bytes([data[end], data[end + 1], data[end + 2], data[end + 3]]);
|
||||||
|
let computed = crate::checksum::jenkins_lookup3(&data[start..end]);
|
||||||
|
if computed != stored {
|
||||||
|
return Err(FormatError::ChecksumMismatch {
|
||||||
|
expected: stored,
|
||||||
|
computed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "checksum"))]
|
||||||
|
fn verify_checksum(_data: &[u8], _start: usize, _end: usize) -> Result<(), FormatError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Parsed Fixed Array header (FAHD).
|
/// Parsed Fixed Array header (FAHD).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FixedArrayHeader {
|
pub struct FixedArrayHeader {
|
||||||
@@ -103,6 +128,8 @@ impl FixedArrayHeader {
|
|||||||
let num_elements = read_length(d, pos, length_size)?;
|
let num_elements = read_length(d, pos, length_size)?;
|
||||||
pos += length_size as usize;
|
pos += length_size as usize;
|
||||||
let data_block_address = read_offset(d, pos, offset_size)?;
|
let data_block_address = read_offset(d, pos, offset_size)?;
|
||||||
|
pos += offset_size as usize;
|
||||||
|
verify_checksum(file_data, offset, offset + pos)?;
|
||||||
|
|
||||||
Ok(FixedArrayHeader {
|
Ok(FixedArrayHeader {
|
||||||
client_id,
|
client_id,
|
||||||
@@ -223,7 +250,8 @@ pub fn read_fixed_array_chunks(
|
|||||||
|
|
||||||
if !is_paged {
|
if !is_paged {
|
||||||
// Non-paged: prefix, then `num_elements` elements packed directly,
|
// Non-paged: prefix, then `num_elements` elements packed directly,
|
||||||
// then a trailing checksum (which we don't validate).
|
// then a checksum over both.
|
||||||
|
verify_checksum(file_data, db_offset, elem_at(elements_start, num_elements)?)?;
|
||||||
for i in 0..num_elements {
|
for i in 0..num_elements {
|
||||||
push_element(i, elem_at(elements_start, i)?, &mut chunks)?;
|
push_element(i, elem_at(elements_start, i)?, &mut chunks)?;
|
||||||
}
|
}
|
||||||
@@ -254,6 +282,9 @@ pub fn read_fixed_array_chunks(
|
|||||||
available: file_data.len(),
|
available: file_data.len(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// The prefix and page bitmap are covered by their own checksum, and each
|
||||||
|
// initialised page by one of its own.
|
||||||
|
verify_checksum(file_data, db_offset, bitmap_start + bitmap_size)?;
|
||||||
|
|
||||||
for p in 0..npages {
|
for p in 0..npages {
|
||||||
let page_first = p * page_nelmts; // < num_elements, cannot overflow
|
let page_first = p * page_nelmts; // < num_elements, cannot overflow
|
||||||
@@ -270,6 +301,7 @@ pub fn read_fixed_array_chunks(
|
|||||||
.checked_mul(page_stride)
|
.checked_mul(page_stride)
|
||||||
.and_then(|o| pages_start.checked_add(o))
|
.and_then(|o| pages_start.checked_add(o))
|
||||||
.ok_or_else(stride_overflow)?;
|
.ok_or_else(stride_overflow)?;
|
||||||
|
verify_checksum(file_data, page_off, elem_at(page_off, page_count)?)?;
|
||||||
for e in 0..page_count {
|
for e in 0..page_count {
|
||||||
push_element(page_first + e, elem_at(page_off, e)?, &mut chunks)?;
|
push_element(page_first + e, elem_at(page_off, e)?, &mut chunks)?;
|
||||||
}
|
}
|
||||||
@@ -374,6 +406,14 @@ fn read_variable_length(data: &[u8], size: usize) -> Result<u64, FormatError> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Stamp the Jenkins checksum a real file would carry over
|
||||||
|
/// `data[start..end]`, writing it at `end`. Fixtures built by hand need
|
||||||
|
/// this now that the reader validates it — as every HDF5 writer does.
|
||||||
|
fn stamp_checksum(data: &mut [u8], start: usize, end: usize) {
|
||||||
|
let sum = crate::checksum::jenkins_lookup3(&data[start..end]);
|
||||||
|
data[end..end + 4].copy_from_slice(&sum.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn index_to_offsets_1d() {
|
fn index_to_offsets_1d() {
|
||||||
let num_chunks = vec![5u64];
|
let num_chunks = vec![5u64];
|
||||||
@@ -439,7 +479,7 @@ mod tests {
|
|||||||
buf[8..16].copy_from_slice(&5u64.to_le_bytes());
|
buf[8..16].copy_from_slice(&5u64.to_le_bytes());
|
||||||
// data_block_address (offset_size=8)
|
// data_block_address (offset_size=8)
|
||||||
buf[16..24].copy_from_slice(&0x1000u64.to_le_bytes());
|
buf[16..24].copy_from_slice(&0x1000u64.to_le_bytes());
|
||||||
// checksum (4 bytes, we don't validate in parse)
|
stamp_checksum(&mut buf, 0, 24);
|
||||||
|
|
||||||
let header = FixedArrayHeader::parse(&buf, 0, 8, 8).unwrap();
|
let header = FixedArrayHeader::parse(&buf, 0, 8, 8).unwrap();
|
||||||
assert_eq!(header.client_id, 1);
|
assert_eq!(header.client_id, 1);
|
||||||
@@ -449,6 +489,54 @@ mod tests {
|
|||||||
assert_eq!(header.data_block_address, 0x1000);
|
assert_eq!(header.data_block_address, 0x1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Corruption anywhere in the index must be an error, not a wrong
|
||||||
|
/// address. Every structure carries a checksum; flipping a bit in each in
|
||||||
|
/// turn must be caught, because the alternative is reading a chunk from
|
||||||
|
/// the wrong offset and returning it as data.
|
||||||
|
#[test]
|
||||||
|
fn corrupting_any_fixed_array_structure_is_detected() {
|
||||||
|
let build = || -> (Vec<u8>, usize) {
|
||||||
|
let (os, fahd, db) = (8usize, 0x100usize, 0x200usize);
|
||||||
|
let mut f = vec![0u8; 0x3000];
|
||||||
|
f[fahd..fahd + 4].copy_from_slice(b"FAHD");
|
||||||
|
f[fahd + 6] = os as u8;
|
||||||
|
f[fahd + 7] = 10;
|
||||||
|
f[fahd + 8..fahd + 16].copy_from_slice(&3u64.to_le_bytes());
|
||||||
|
f[fahd + 16..fahd + 24].copy_from_slice(&(db as u64).to_le_bytes());
|
||||||
|
stamp_checksum(&mut f, fahd, fahd + 24);
|
||||||
|
f[db..db + 4].copy_from_slice(b"FADB");
|
||||||
|
f[db + 6..db + 14].copy_from_slice(&(fahd as u64).to_le_bytes());
|
||||||
|
let elems = db + 6 + os;
|
||||||
|
for i in 0..3usize {
|
||||||
|
let addr = 0x1000u64 + i as u64 * 0x100;
|
||||||
|
f[elems + i * os..elems + (i + 1) * os].copy_from_slice(&addr.to_le_bytes());
|
||||||
|
}
|
||||||
|
stamp_checksum(&mut f, db, elems + 3 * os);
|
||||||
|
(f, fahd)
|
||||||
|
};
|
||||||
|
|
||||||
|
let read = |f: &[u8], fahd: usize| -> Result<Vec<ChunkInfo>, FormatError> {
|
||||||
|
let h = FixedArrayHeader::parse(f, fahd, 8, 8)?;
|
||||||
|
read_fixed_array_chunks(f, &h, &[60], &[20], 8, 8, 8)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (clean, fahd) = build();
|
||||||
|
assert!(read(&clean, fahd).is_ok(), "the intact fixture must read");
|
||||||
|
|
||||||
|
// A byte inside the header, and one inside a data block element.
|
||||||
|
for &at in &[0x108usize, 0x210usize] {
|
||||||
|
let (mut damaged, fahd) = build();
|
||||||
|
damaged[at] ^= 0x01;
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
read(&damaged, fahd),
|
||||||
|
Err(FormatError::ChecksumMismatch { .. })
|
||||||
|
),
|
||||||
|
"corruption at {at:#x} went undetected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_fixed_array_header_invalid_signature() {
|
fn parse_fixed_array_header_invalid_signature() {
|
||||||
let mut buf = vec![0u8; 256];
|
let mut buf = vec![0u8; 256];
|
||||||
@@ -469,6 +557,7 @@ mod tests {
|
|||||||
buf[fahd + 7] = 200; // max_nelmts_bits — absurd, would overflow a shift
|
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 + 8..fahd + 16].copy_from_slice(&3u64.to_le_bytes()); // num_elements
|
||||||
buf[fahd + 16..fahd + 24].copy_from_slice(&0x100u64.to_le_bytes());
|
buf[fahd + 16..fahd + 24].copy_from_slice(&0x100u64.to_le_bytes());
|
||||||
|
stamp_checksum(&mut buf, fahd, fahd + 24);
|
||||||
// FADB so parsing reaches the paged check
|
// FADB so parsing reaches the paged check
|
||||||
let db = 0x100usize;
|
let db = 0x100usize;
|
||||||
buf[db..db + 4].copy_from_slice(b"FADB");
|
buf[db..db + 4].copy_from_slice(b"FADB");
|
||||||
@@ -486,6 +575,8 @@ mod tests {
|
|||||||
buf[fahd + 7] = 10;
|
buf[fahd + 7] = 10;
|
||||||
buf[fahd + 8..fahd + 16].copy_from_slice(&u64::MAX.to_le_bytes()); // absurd count
|
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[fahd + 16..fahd + 24].copy_from_slice(&0x80u64.to_le_bytes());
|
||||||
|
// Valid checksum, so it is the element count that must be rejected.
|
||||||
|
stamp_checksum(&mut buf, fahd, fahd + 24);
|
||||||
buf[0x80..0x84].copy_from_slice(b"FADB");
|
buf[0x80..0x84].copy_from_slice(b"FADB");
|
||||||
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
|
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
|
||||||
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
|
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
|
||||||
@@ -545,6 +636,7 @@ mod tests {
|
|||||||
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_chunks.to_le_bytes());
|
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_chunks.to_le_bytes());
|
||||||
file_data[fahd_offset + 16..fahd_offset + 24]
|
file_data[fahd_offset + 16..fahd_offset + 24]
|
||||||
.copy_from_slice(&(db_offset as u64).to_le_bytes());
|
.copy_from_slice(&(db_offset as u64).to_le_bytes());
|
||||||
|
stamp_checksum(&mut file_data, fahd_offset, fahd_offset + 24);
|
||||||
|
|
||||||
// Build FADB at db_offset
|
// Build FADB at db_offset
|
||||||
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
|
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
|
||||||
@@ -562,6 +654,7 @@ mod tests {
|
|||||||
let pos = elem_start + i * os;
|
let pos = elem_start + i * os;
|
||||||
file_data[pos..pos + os].copy_from_slice(&addr.to_le_bytes());
|
file_data[pos..pos + os].copy_from_slice(&addr.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
stamp_checksum(&mut file_data, db_offset, elem_start + 5 * os);
|
||||||
|
|
||||||
let header =
|
let header =
|
||||||
FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap();
|
FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap();
|
||||||
@@ -611,6 +704,7 @@ mod tests {
|
|||||||
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_chunks.to_le_bytes());
|
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_chunks.to_le_bytes());
|
||||||
file_data[fahd_offset + 16..fahd_offset + 24]
|
file_data[fahd_offset + 16..fahd_offset + 24]
|
||||||
.copy_from_slice(&(db_offset as u64).to_le_bytes());
|
.copy_from_slice(&(db_offset as u64).to_le_bytes());
|
||||||
|
stamp_checksum(&mut file_data, fahd_offset, fahd_offset + 24);
|
||||||
|
|
||||||
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
|
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
|
||||||
file_data[db_offset + 4] = 0;
|
file_data[db_offset + 4] = 0;
|
||||||
@@ -632,6 +726,11 @@ mod tests {
|
|||||||
file_data[pos + os..pos + os + 4].copy_from_slice(&csize.to_le_bytes());
|
file_data[pos + os..pos + os + 4].copy_from_slice(&csize.to_le_bytes());
|
||||||
file_data[pos + os + 4..pos + os + 8].copy_from_slice(&fmask.to_le_bytes());
|
file_data[pos + os + 4..pos + os + 8].copy_from_slice(&fmask.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
stamp_checksum(
|
||||||
|
&mut file_data,
|
||||||
|
db_offset,
|
||||||
|
elem_start + test_chunks.len() * elem_size,
|
||||||
|
);
|
||||||
|
|
||||||
let header =
|
let header =
|
||||||
FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap();
|
FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap();
|
||||||
@@ -696,6 +795,7 @@ mod tests {
|
|||||||
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_elements.to_le_bytes());
|
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_elements.to_le_bytes());
|
||||||
file_data[fahd_offset + 16..fahd_offset + 24]
|
file_data[fahd_offset + 16..fahd_offset + 24]
|
||||||
.copy_from_slice(&(db_offset as u64).to_le_bytes());
|
.copy_from_slice(&(db_offset as u64).to_le_bytes());
|
||||||
|
stamp_checksum(&mut file_data, fahd_offset, fahd_offset + 24);
|
||||||
|
|
||||||
// FADB prefix
|
// FADB prefix
|
||||||
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
|
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
|
||||||
@@ -715,6 +815,9 @@ mod tests {
|
|||||||
let base_addr = 0x1000u64;
|
let base_addr = 0x1000u64;
|
||||||
// Page 0 (elements 0..4) and page 2 (elements 8..11) carry addresses;
|
// 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.
|
// page 1's slot is left zero-filled and must be skipped.
|
||||||
|
// The prefix and bitmap carry one checksum, each initialised page
|
||||||
|
// another — as a real file does.
|
||||||
|
stamp_checksum(&mut file_data, db_offset, bitmap_off + bitmap_size);
|
||||||
for &p in &[0usize, 2usize] {
|
for &p in &[0usize, 2usize] {
|
||||||
let page_off = pages_start + p * page_total;
|
let page_off = pages_start + p * page_total;
|
||||||
let count = core::cmp::min(page_nelmts, num_elements as usize - p * page_nelmts);
|
let count = core::cmp::min(page_nelmts, num_elements as usize - p * page_nelmts);
|
||||||
@@ -724,6 +827,7 @@ mod tests {
|
|||||||
let pos = page_off + e * os;
|
let pos = page_off + e * os;
|
||||||
file_data[pos..pos + os].copy_from_slice(&addr.to_le_bytes());
|
file_data[pos..pos + os].copy_from_slice(&addr.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
stamp_checksum(&mut file_data, page_off, page_off + count * os);
|
||||||
}
|
}
|
||||||
|
|
||||||
let header =
|
let header =
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-gpu"
|
name = "clawhdf5-gpu"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders"
|
description = "GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-io"
|
name = "clawhdf5-io"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "I/O abstraction layer for rustyhdf5"
|
description = "I/O abstraction layer for rustyhdf5"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -10,7 +10,7 @@ keywords = ["hdf5", "io", "science", "data"]
|
|||||||
categories = ["filesystem", "science"]
|
categories = ["filesystem", "science"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0" }
|
||||||
memmap2 = { version = "0.9", optional = true }
|
memmap2 = { version = "0.9", optional = true }
|
||||||
libc = { version = "0.2", optional = true }
|
libc = { version = "0.2", optional = true }
|
||||||
tokio = { version = "1", features = ["fs", "io-util"], optional = true }
|
tokio = { version = "1", features = ["fs", "io-util"], optional = true }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-migrate"
|
name = "clawhdf5-migrate"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "CLI to migrate SQLite agent memory databases to HDF5 format"
|
description = "CLI to migrate SQLite agent memory databases to HDF5 format"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -14,9 +14,9 @@ name = "clawhdf5-migrate"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.6.0" }
|
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.7.0" }
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0" }
|
||||||
clawhdf5 = { path = "../clawhdf5", version = "2.6.0" }
|
clawhdf5 = { path = "../clawhdf5", version = "2.7.0" }
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
half = { workspace = true }
|
half = { workspace = true }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-napi"
|
name = "clawhdf5-napi"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Node.js native addon (napi-rs) exposing clawhdf5-agent to TypeScript/JavaScript"
|
description = "Node.js native addon (napi-rs) exposing clawhdf5-agent to TypeScript/JavaScript"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -10,7 +10,7 @@ repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
|||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.6.0" }
|
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.7.0" }
|
||||||
napi = { version = "2", default-features = false, features = ["napi9"] }
|
napi = { version = "2", default-features = false, features = ["napi9"] }
|
||||||
napi-derive = "2"
|
napi-derive = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-netcdf4"
|
name = "clawhdf5-netcdf4"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "NetCDF-4 read support built on rustyhdf5 — pure Rust, no C dependencies"
|
description = "NetCDF-4 read support built on rustyhdf5 — pure Rust, no C dependencies"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -10,8 +10,8 @@ keywords = ["netcdf", "netcdf4", "hdf5", "science", "climate"]
|
|||||||
categories = ["parser-implementations", "science"]
|
categories = ["parser-implementations", "science"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5 = { path = "../clawhdf5", version = "2.6.0" }
|
clawhdf5 = { path = "../clawhdf5", version = "2.7.0" }
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-py"
|
name = "clawhdf5-py"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
|
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -14,8 +14,8 @@ name = "clawhdf5"
|
|||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5_rs = { path = "../clawhdf5", version = "2.6.0", package = "clawhdf5" }
|
clawhdf5_rs = { path = "../clawhdf5", version = "2.7.0", package = "clawhdf5" }
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0" }
|
||||||
pyo3 = "0.29"
|
pyo3 = "0.29"
|
||||||
numpy = "0.29"
|
numpy = "0.29"
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rustyhdf5"
|
name = "rustyhdf5"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
|
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5"
|
name = "clawhdf5"
|
||||||
version = "2.6.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Pure-Rust HDF5 reader/writer — no C dependencies"
|
description = "Pure-Rust HDF5 reader/writer — no C dependencies"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -10,16 +10,16 @@ keywords = ["hdf5", "science", "data", "binary"]
|
|||||||
categories = ["parser-implementations", "science", "encoding"]
|
categories = ["parser-implementations", "science", "encoding"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0" }
|
||||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.6.0" }
|
clawhdf5-io = { path = "../clawhdf5-io", version = "2.7.0" }
|
||||||
rayon = { version = "1", optional = true }
|
rayon = { version = "1", optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
criterion = { workspace = true }
|
criterion = { workspace = true }
|
||||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.6.0", features = ["mmap"] }
|
clawhdf5-io = { path = "../clawhdf5-io", version = "2.7.0", features = ["mmap"] }
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0", features = ["parallel", "fast-checksum"] }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0", features = ["parallel", "fast-checksum"] }
|
||||||
clawhdf5-filters = { path = "../clawhdf5-filters", version = "2.6.0" }
|
clawhdf5-filters = { path = "../clawhdf5-filters", version = "2.7.0" }
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "mmap_bench"
|
name = "mmap_bench"
|
||||||
|
|||||||
@@ -1047,3 +1047,343 @@ with h5py.File("{path_str}", "r") as f:
|
|||||||
data[start..start + cols as usize]
|
data[start..start + cols as usize]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn h5py_deep_btree_v2_chunk_index_clawhdf5_reads() {
|
||||||
|
// Two unlimited dimensions give a B-tree v2 chunk index, and 2x2 chunks
|
||||||
|
// over 400x400 give 40 000 index records — enough for HDF5 to build a
|
||||||
|
// tree of depth 2. Small h5py files only ever produce depth-0 trees, so
|
||||||
|
// this is the one fixture that walks internal nodes: the path where the
|
||||||
|
// traversal's record budget (the guard against crafted shared-subtree
|
||||||
|
// trees) is spent, which must never refuse a real file.
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("deep_btree.h5");
|
||||||
|
let path_str = path.display().to_string();
|
||||||
|
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
import h5py, numpy as np
|
||||||
|
with h5py.File("{path_str}", "w", libver="latest") as f:
|
||||||
|
d = f.create_dataset("x", shape=(400, 400), maxshape=(None, None),
|
||||||
|
chunks=(2, 2), dtype="i4")
|
||||||
|
d[...] = np.arange(160000, dtype="i4").reshape(400, 400)
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
run_python(&script);
|
||||||
|
|
||||||
|
// The fixture is only meaningful if HDF5 really built internal nodes.
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
let at = bytes
|
||||||
|
.windows(4)
|
||||||
|
.position(|w| w == b"BTHD")
|
||||||
|
.expect("expected a B-tree v2 chunk index");
|
||||||
|
let depth = u16::from_le_bytes([bytes[at + 12], bytes[at + 13]]);
|
||||||
|
assert!(
|
||||||
|
depth >= 1,
|
||||||
|
"fixture tree has depth {depth}; it tests nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
let file = File::open(&path).unwrap();
|
||||||
|
let values = file.dataset("x").unwrap().read_i32().unwrap();
|
||||||
|
assert_eq!(values.len(), 160_000);
|
||||||
|
for (i, &v) in values.iter().enumerate() {
|
||||||
|
assert_eq!(v, i as i32, "element {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn h5py_extensible_array_chunk_index_clawhdf5_reads() {
|
||||||
|
// One unlimited dimension means an Extensible Array chunk index. Only its
|
||||||
|
// first few elements live inline in the index block (4 by default), and
|
||||||
|
// every other fixture here is small enough to stop there — which is how
|
||||||
|
// the data block and super block layouts came to be wrong without a test
|
||||||
|
// noticing. The counts below step over each boundary in turn:
|
||||||
|
// 4 inline elements only
|
||||||
|
// 37 past the first direct data block
|
||||||
|
// 400 into the first super block
|
||||||
|
// 5000 several super block levels
|
||||||
|
// 200000 data blocks large enough to be paged
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
for n in [4usize, 37, 400, 5_000, 200_000] {
|
||||||
|
let path = dir.path().join(format!("ea_{n}.h5"));
|
||||||
|
let path_str = path.display().to_string();
|
||||||
|
run_python(&format!(
|
||||||
|
r#"
|
||||||
|
import h5py, numpy as np
|
||||||
|
with h5py.File("{path_str}", "w", libver="latest") as f:
|
||||||
|
d = f.create_dataset("x", shape=({n},), maxshape=(None,), chunks=(1,), dtype="i4")
|
||||||
|
d[...] = np.arange({n}, dtype="i4")
|
||||||
|
"#
|
||||||
|
));
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
assert!(
|
||||||
|
bytes.windows(4).any(|w| w == b"EAHD"),
|
||||||
|
"n={n}: fixture is not indexed by an Extensible Array"
|
||||||
|
);
|
||||||
|
|
||||||
|
let file = File::open(&path).unwrap();
|
||||||
|
let values = file.dataset("x").unwrap().read_i32().unwrap();
|
||||||
|
assert_eq!(values.len(), n, "n={n}");
|
||||||
|
let wrong = values
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|&(i, &v)| v != i as i32)
|
||||||
|
.count();
|
||||||
|
assert_eq!(wrong, 0, "n={n}: {wrong} of {n} elements read back wrong");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn h5py_sparse_extensible_array_leaves_pages_uninitialised() {
|
||||||
|
// Writing a scattered subset leaves whole pages of a paged data block
|
||||||
|
// never initialised. Those pages still occupy their slot on disk, so the
|
||||||
|
// reader has to skip them by stride and take the fill value instead —
|
||||||
|
// driven by the page-init bitmap, which is packed one bit per page across
|
||||||
|
// the whole super block, MSB first.
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("ea_sparse.h5");
|
||||||
|
let path_str = path.display().to_string();
|
||||||
|
let n = 200_000usize;
|
||||||
|
let step = 997usize;
|
||||||
|
|
||||||
|
run_python(&format!(
|
||||||
|
r#"
|
||||||
|
import h5py
|
||||||
|
with h5py.File("{path_str}", "w", libver="latest") as f:
|
||||||
|
d = f.create_dataset("x", shape=({n},), maxshape=(None,), chunks=(1,),
|
||||||
|
dtype="i4", fillvalue=-1)
|
||||||
|
for i in list(range(0, {n}, {step})) + list(range(0, 40)):
|
||||||
|
d[i] = i
|
||||||
|
"#
|
||||||
|
));
|
||||||
|
|
||||||
|
let file = File::open(&path).unwrap();
|
||||||
|
let values = file.dataset("x").unwrap().read_i32().unwrap();
|
||||||
|
assert_eq!(values.len(), n);
|
||||||
|
let wrong = values
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|&(i, &v)| {
|
||||||
|
let expected = if i % step == 0 || i < 40 {
|
||||||
|
i as i32
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
};
|
||||||
|
v != expected
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert_eq!(wrong, 0, "{wrong} of {n} elements read back wrong");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn h5py_filtered_and_2d_extensible_array_clawhdf5_reads() {
|
||||||
|
// Filtered elements carry a size and filter mask beside the address, and
|
||||||
|
// a second (fixed) dimension changes how a linear index maps back to
|
||||||
|
// chunk offsets. Both run through the same traversal.
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
let gz = dir.path().join("ea_gzip.h5");
|
||||||
|
let gz_str = gz.display().to_string();
|
||||||
|
run_python(&format!(
|
||||||
|
r#"
|
||||||
|
import h5py, numpy as np
|
||||||
|
with h5py.File("{gz_str}", "w", libver="latest") as f:
|
||||||
|
d = f.create_dataset("x", shape=(5000,), maxshape=(None,), chunks=(1,),
|
||||||
|
dtype="i4", compression="gzip", compression_opts=4)
|
||||||
|
d[...] = np.arange(5000, dtype="i4")
|
||||||
|
"#
|
||||||
|
));
|
||||||
|
let values = File::open(&gz)
|
||||||
|
.unwrap()
|
||||||
|
.dataset("x")
|
||||||
|
.unwrap()
|
||||||
|
.read_i32()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(values.len(), 5000);
|
||||||
|
assert_eq!(
|
||||||
|
values
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|&(i, &v)| v != i as i32)
|
||||||
|
.count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
let two_d = dir.path().join("ea_2d.h5");
|
||||||
|
let two_d_str = two_d.display().to_string();
|
||||||
|
run_python(&format!(
|
||||||
|
r#"
|
||||||
|
import h5py, numpy as np
|
||||||
|
with h5py.File("{two_d_str}", "w", libver="latest") as f:
|
||||||
|
d = f.create_dataset("x", shape=(3000, 4), maxshape=(None, 4), chunks=(1, 4), dtype="i4")
|
||||||
|
d[...] = np.arange(12000, dtype="i4").reshape(3000, 4)
|
||||||
|
"#
|
||||||
|
));
|
||||||
|
let values = File::open(&two_d)
|
||||||
|
.unwrap()
|
||||||
|
.dataset("x")
|
||||||
|
.unwrap()
|
||||||
|
.read_i32()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(values.len(), 12_000);
|
||||||
|
assert_eq!(
|
||||||
|
values
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|&(i, &v)| v != i as i32)
|
||||||
|
.count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn h5py_fixed_array_chunk_index_clawhdf5_reads() {
|
||||||
|
// Fixed dimensions plus libver='latest' give a Fixed Array chunk index.
|
||||||
|
// Its data blocks are paged above 2^page_bits elements (1024 by default),
|
||||||
|
// and unlike the Extensible Array it keeps the page-init bitmap in the
|
||||||
|
// data block itself — a difference worth pinning down, since assuming
|
||||||
|
// otherwise is exactly what made the Extensible Array reader wrong. The
|
||||||
|
// sparse case leaves whole pages uninitialised so the bitmap is actually
|
||||||
|
// consulted rather than being all ones.
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
for n in [100usize, 5_000, 200_000] {
|
||||||
|
let path = dir.path().join(format!("fa_{n}.h5"));
|
||||||
|
let path_str = path.display().to_string();
|
||||||
|
run_python(&format!(
|
||||||
|
r#"
|
||||||
|
import h5py, numpy as np
|
||||||
|
with h5py.File("{path_str}", "w", libver="latest") as f:
|
||||||
|
d = f.create_dataset("x", shape=({n},), chunks=(1,), dtype="i4")
|
||||||
|
d[...] = np.arange({n}, dtype="i4")
|
||||||
|
"#
|
||||||
|
));
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
assert!(
|
||||||
|
bytes.windows(4).any(|w| w == b"FAHD"),
|
||||||
|
"n={n}: fixture is not indexed by a Fixed Array"
|
||||||
|
);
|
||||||
|
let values = File::open(&path)
|
||||||
|
.unwrap()
|
||||||
|
.dataset("x")
|
||||||
|
.unwrap()
|
||||||
|
.read_i32()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(values.len(), n, "n={n}");
|
||||||
|
let wrong = values
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|&(i, &v)| v != i as i32)
|
||||||
|
.count();
|
||||||
|
assert_eq!(wrong, 0, "n={n}: {wrong} elements read back wrong");
|
||||||
|
}
|
||||||
|
|
||||||
|
let sparse = dir.path().join("fa_sparse.h5");
|
||||||
|
let sparse_str = sparse.display().to_string();
|
||||||
|
let (n, step) = (200_000usize, 997usize);
|
||||||
|
run_python(&format!(
|
||||||
|
r#"
|
||||||
|
import h5py
|
||||||
|
with h5py.File("{sparse_str}", "w", libver="latest") as f:
|
||||||
|
d = f.create_dataset("x", shape=({n},), chunks=(1,), dtype="i4", fillvalue=-1)
|
||||||
|
for i in list(range(0, {n}, {step})) + list(range(0, 40)):
|
||||||
|
d[i] = i
|
||||||
|
"#
|
||||||
|
));
|
||||||
|
let values = File::open(&sparse)
|
||||||
|
.unwrap()
|
||||||
|
.dataset("x")
|
||||||
|
.unwrap()
|
||||||
|
.read_i32()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(values.len(), n);
|
||||||
|
let wrong = values
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|&(i, &v)| {
|
||||||
|
let expected = if i % step == 0 || i < 40 {
|
||||||
|
i as i32
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
};
|
||||||
|
v != expected
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert_eq!(wrong, 0, "sparse: {wrong} of {n} elements read back wrong");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupting_a_chunk_index_is_an_error_not_wrong_data() {
|
||||||
|
// Every Fixed/Extensible Array structure carries a Jenkins checksum, and
|
||||||
|
// the reader now verifies it. The point is not the checksum itself but
|
||||||
|
// what it prevents: a damaged index otherwise yields addresses pointing
|
||||||
|
// at the wrong bytes, and the caller receives another chunk's data as if
|
||||||
|
// it were the one asked for.
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
for (name, maxshape) in [("fixed", "None"), ("extensible", "(None,)")] {
|
||||||
|
let path = dir.path().join(format!("{name}.h5"));
|
||||||
|
let path_str = path.display().to_string();
|
||||||
|
let shape_arg = if maxshape == "None" {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(", maxshape={maxshape}")
|
||||||
|
};
|
||||||
|
run_python(&format!(
|
||||||
|
r#"
|
||||||
|
import h5py, numpy as np
|
||||||
|
with h5py.File("{path_str}", "w", libver="latest") as f:
|
||||||
|
d = f.create_dataset("x", shape=(400,), chunks=(1,), dtype="i4"{shape_arg})
|
||||||
|
d[...] = np.arange(400, dtype="i4")
|
||||||
|
"#
|
||||||
|
));
|
||||||
|
|
||||||
|
let clean = std::fs::read(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
File::open(&path)
|
||||||
|
.unwrap()
|
||||||
|
.dataset("x")
|
||||||
|
.unwrap()
|
||||||
|
.read_i32()
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
400,
|
||||||
|
"{name}: the intact file must read"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Flip a low bit of a chunk address inside a data block. Structurally
|
||||||
|
// everything still parses — the index still has the right shape and
|
||||||
|
// the address still lands inside the file — so nothing but the
|
||||||
|
// checksum can notice. Without it the read succeeds and hands back
|
||||||
|
// whatever bytes now sit at that address.
|
||||||
|
let sig: &[u8] = if name == "fixed" { b"FADB" } else { b"EADB" };
|
||||||
|
let block = clean
|
||||||
|
.windows(4)
|
||||||
|
.position(|w| w == sig)
|
||||||
|
.unwrap_or_else(|| panic!("{name}: no data block in the fixture"));
|
||||||
|
// Past the prefix (signature, version, client id, header address, and
|
||||||
|
// for the Extensible Array a block offset), into the first address.
|
||||||
|
let at = block + 4 + 1 + 1 + 8 + if name == "fixed" { 0 } else { 4 } + 1;
|
||||||
|
let mut damaged = clean.clone();
|
||||||
|
damaged[at] ^= 0x10;
|
||||||
|
let damaged_path = dir.path().join(format!("{name}_damaged.h5"));
|
||||||
|
std::fs::write(&damaged_path, &damaged).unwrap();
|
||||||
|
|
||||||
|
let result = File::open(&damaged_path)
|
||||||
|
.unwrap()
|
||||||
|
.dataset("x")
|
||||||
|
.and_then(|d| d.read_i32());
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"{name}: corruption produced data instead of an error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -169,3 +169,61 @@ python3 -m venv .venv && .venv/bin/pip install h5py numpy netCDF4
|
|||||||
|
|
||||||
Set `CLAWHDF5_REQUIRE_INTEROP=1` in any automated runner so a missing
|
Set `CLAWHDF5_REQUIRE_INTEROP=1` in any automated runner so a missing
|
||||||
interpreter is a failure rather than a skip.
|
interpreter is a failure rather than a skip.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Crafted B-tree v2 structures crash or exhaust the reader
|
||||||
|
|
||||||
|
**Status:** fixed on `main` (2026-09-20), after v2.6.0. **Every release up to
|
||||||
|
and including v2.6.0 is affected.**
|
||||||
|
|
||||||
|
B-tree v2 traversal (`clawhdf5-format`, `btree_v2::collect_btree_v2_records`)
|
||||||
|
recursed one frame per level with the depth taken from the file, and followed
|
||||||
|
child addresses without checking whether they were shared. Two consequences
|
||||||
|
for anyone reading untrusted files:
|
||||||
|
|
||||||
|
- A node that is its own child, under a header claiming 65 535 levels, overflows
|
||||||
|
the stack and aborts the process. The file is under 100 bytes.
|
||||||
|
- Levels whose children all point at one node below make the traversal visit it
|
||||||
|
fan-out^depth times: ~30 million records from ~5 KB, and memory exhaustion one
|
||||||
|
level deeper.
|
||||||
|
|
||||||
|
B-tree v2 backs dense attribute storage, v2 groups, shared object header
|
||||||
|
messages and chunk indexes, so opening an object that uses any of them is
|
||||||
|
enough. Both are now errors: depth is capped at 64, and traversal stops once it
|
||||||
|
has produced more records than the file could physically hold.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extensible Array chunk indexes read back wrong data past the inline elements
|
||||||
|
|
||||||
|
**Status:** fixed on `main` (2026-09-20), after v2.6.0. **Every release up to
|
||||||
|
and including v2.6.0 is affected.**
|
||||||
|
|
||||||
|
A dataset created with exactly one unlimited dimension (`maxshape=(None, ...)`,
|
||||||
|
the usual append-only/resizable case) is indexed by an Extensible Array. Its
|
||||||
|
index block holds the first `idx_blk_elmts` chunk entries inline — 4 by
|
||||||
|
default — and everything after that lives in data blocks and super blocks whose
|
||||||
|
layout `clawhdf5-format` computed incorrectly.
|
||||||
|
|
||||||
|
Consequences, by dataset size (1 chunk per element):
|
||||||
|
|
||||||
|
| chunks | result before the fix |
|
||||||
|
|---|---|
|
||||||
|
| <= 36 | correct (inline, plus two data blocks that happened to line up) |
|
||||||
|
| 37 | 1 element wrong |
|
||||||
|
| 400 | 364 elements wrong |
|
||||||
|
| >= ~1000 | `invalid Extensible Array data block signature` |
|
||||||
|
|
||||||
|
The dangerous case is the middle one: values were returned from the wrong
|
||||||
|
chunks rather than an error being raised. Any reader that accepted the data at
|
||||||
|
face value saw plausible but incorrect numbers.
|
||||||
|
|
||||||
|
The root causes were the super block sizing formulas (`ndblks` and
|
||||||
|
`dblk_nelmts` each double every *other* level, a half-step apart), a missing
|
||||||
|
block-offset field in the super block, and a page-init bitmap read from the
|
||||||
|
wrong structure. All four are fixed and covered by interop tests against
|
||||||
|
HDF5 2.0 at sizes that cross each boundary, including paged data blocks.
|
||||||
|
|
||||||
|
Files written by this crate are unaffected — this was purely a read-path bug.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@redclaw/clawhdf5",
|
"name": "@redclaw/clawhdf5",
|
||||||
"version": "2.6.0",
|
"version": "2.7.0",
|
||||||
"description": "Node.js bindings for clawhdf5 — HDF5-backed agent memory with hippocampal consolidation",
|
"description": "Node.js bindings for clawhdf5 — HDF5-backed agent memory with hippocampal consolidation",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user