39 Commits
Author SHA1 Message Date
osobhandClaude Fable 5.1 db4a067fe8 Merge release/v2.4.0
CI / test (push) Failing after 2s
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 13:34:47 -07:00
osobhandClaude Fable 5.1 4aa3c5a1ca chore(release): v2.4.0
Bump all workspace crates, the node package and pyproject to 2.4.0, finalize
the changelog and add upgrade notes for the search behaviour changes.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 13:32:47 -07:00
osobhandClaude Fable 5.1 26e06cc5fd Merge feat/hnsw-kernels: unit-vector dot product, reusable visited set, unranked BM25 scores
CI / test (push) Failing after 2s
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 13:32:35 -07:00
osobhandClaude Fable 5.1 390a2e3836 perf(agent): unranked BM25 scores and a top-k merge — same rankings, 4-5x faster
Fusion min-max normalises over every keyword match, so hybrid_search asked
BM25 for a ranked list of the whole corpus: a hash insert per posting, then a
sort of every match, then the merge sorted every candidate again to keep k.

- BM25Index::scores returns every match unsorted, accumulated in a dense array
  (contributions are strictly positive, so zero means untouched). search() is
  built on it with the bounded heap.
- merge_vector_keyword partitions out its top k (select_nth) and orders only
  those, with the same score-then-id order.
- Both hybrid paths use scores().

Rankings are identical (equivalence tests for both changes). p50 0.24 -> 0.07
ms (1K), 2.1 -> 0.49 ms (10K), 23 -> 4.65 ms (100K).

The harness gains --fusion-study, which measured the alternative — capping the
keyword pool — and found it changes the top-10 for most queries (overlap
0.83-0.92, different #1 for 10-35%) for only a 2x saving. Not adopted.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 13:27:31 -07:00
osobhandClaude Fable 5.1 f15bf2eb22 perf(ann): unit-vector dot product and a reusable visited set
- Cosine distance was 1 - dot/(|a||b|), re-deriving both norms on every
  evaluation in the innermost loop of build and search. The index now stores
  unit vectors (prepared at build, insert, graph load and HDF5 load; the query
  once per search) and uses 1 - dot. Zero vectors stay zero, giving distance 1
  as before. Returned distances are unchanged.
- search_layer allocated a HashSet of visited nodes per call. It is now an
  epoch-stamped u32 array in thread-local scratch, reused across calls, so
  search(&self) stays shareable between threads.
- clawhdf5-accel caches the detected SIMD backend in a OnceLock.

Recall is identical. Build 2.75 -> 1.89 s (10K), ~38 -> 21 s (100K); QPS at
ef=64 22.7K -> 39K (10K), 10.4K -> 14K (100K).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 13:21:40 -07:00
osobhandClaude Fable 5.1 09480747aa Merge feat/search-harness: search harness, HNSW recall fix, incremental BM25, persisted vector index
CI / test (push) Failing after 2s
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 13:17:05 -07:00
osobhandClaude Fable 5.1 39bf2bebf4 docs: CLAUDE.md — search path after the hot-path work
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 08:01:06 -07:00
osobhandClaude Fable 5.1 0ee698accd perf(agent): persist the vector index graph; incremental catch-up
open() marked the HNSW index dirty, so the first search of every session
rebuilt it from scratch — 36 s at 100K records with the (better, slower)
heuristic build. First query after open is now 1.7 / 15 / 159 ms at
1K / 10K / 100K; what remains is the one-off keyword index build.

- clawhdf5-ann: HnswIndex::graph_to_bytes / from_graph_bytes serialize the
  graph only (levels, tombstones, adjacency as u32, CRC32). The existing HDF5
  serializer embeds a full copy of every vector, which would double a store
  that already holds them. Loading validates everything — counts, levels vs
  layer count, connection limits, every neighbour id and the layer it must
  exist on — so a damaged graph, or a hostile one with a valid checksum, is an
  error rather than an out-of-bounds walk during search.
- clawhdf5-agent: each checkpoint writes the graph to <store>.h5.ann (synced,
  atomic, before the .h5) and records a fresh generation id in /meta. open()
  loads the sidecar only if its generation matches that checkpoint; missing,
  stale, damaged or mismatched sidecars are ignored and the index rebuilt.
  Records appended through WAL replay join the loaded index incrementally; a
  replayed Update or Tombstone invalidates it. snapshot() copies it. Only an
  index that exactly mirrors the cache is saved; otherwise a stale sidecar is
  removed.
- ensure_hnsw_fresh inserts records appended since the last sync instead of
  rebuilding, so save_batch no longer marks the whole index dirty.
- CheckpointMeta { wal_applied, ann_generation } with *_with_meta build/write/
  read functions; the *_with_mark ones delegate.
- Harness reports the one-off cold index build separately from the first query
  after a reopen.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 08:00:57 -07:00
osobhandClaude Fable 5.1 2bfbb7fb4b perf(agent): persistent incremental BM25 index; no store rewrite per query
hybrid_search rebuilt the BM25 index from scratch (re-tokenising every record)
and rewrote the whole .h5 file on every single query, so a query cost O(store
size) in both CPU and disk I/O. Steady-state p50 per the search harness:
5.5 -> 0.24 ms (1K), 49 -> 2.1 ms (10K), 884 -> 23 ms (100K).

- BM25Index is incremental: add_document / remove_document keep it exactly
  equivalent to a fresh build over the same live documents (property test: 60
  random op sequences compared against BM25Index::build after every step). IDF
  moves to query time since it depends on the live document count. Top-k uses
  a bounded heap, ties break by doc id (results were HashMap-ordered), and the
  "WAND" code that computed a bound and then discarded it is removed.
- HDF5Memory keeps one index for its lifetime, built lazily. Appends are
  picked up by ensure_bm25_fresh whatever path added them; delete and in-place
  update report themselves; compaction drops the index. A test drives every
  mutation and compares against a fresh build.
- A query no longer calls flush(). Activation boosts are marked dirty and
  persisted by the next checkpoint, including a best-effort one on drop so a
  search-only session keeps them (approved behaviour change). Activation
  weights are capped at 16.0; they previously grew without bound.

The archived mission branch's BM25 cache was reviewed and not used: it was
invalidated by every write, so interleaved save/search still rebuilt per
query, and it changed the default fusion weights.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 07:51:21 -07:00
osobhandClaude Fable 5.1 61424d1418 docs(bench): record search harness numbers after the HNSW heuristic
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 07:43:52 -07:00
osobhandClaude Fable 5.1 65d219c409 fix(ann): HNSW neighbour-selection heuristic — recall 0.31 -> 0.98 at 100K
Neighbours were selected as the plain closest-M, for both a new node's links
and back-link pruning. On clustered data every link of a node inside a tight
cluster then goes to that same cluster, so clusters become islands a search
entering elsewhere can never reach: recall@10 was 0.87 / 0.67 / 0.31 at
1K / 10K / 100K (384-dim) and flat in ef. Uniform random data — all the
existing tests used — does not show it.

Implement the HNSW paper's Algorithm 4 with keepPrunedConnections: accept a
candidate only if it is closer to the node than to every neighbour already
accepted, then fill spare slots with the closest rejected ones. Recall@10 at
ef=64 is now 1.00 / 1.00 / 0.98 and rises with ef; uniform data improves
slightly. Build is ~3.5x slower at 10K (extra distance evaluations), to be
recovered by the distance-kernel work. The needless rayon fan-out over <=33
distances in prune_connections is gone.

Tests: a clustered-data recall test for bulk build and incremental insert
(scores 0.43 with the old selection), and a unit test of the selection rule.
Harness gains --uniform and --ann-only; before/after in BENCHMARKS.md.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 07:43:41 -07:00
osobhandClaude Fable 5.1 eb99de1020 bench: search harness — HNSW recall vs speed, end-to-end hybrid_search latency
New clawhdf5-bench binary `search_harness`, the measurement baseline for the
search hot-path work. On deterministic clustered 384-dim data it reports HNSW
build time and, per ef, recall@10 against an exact scan, QPS and p50/p99; and
for HDF5Memory: ingest, checkpoint, open, first-query-after-open and
steady-state hybrid_search latency at 1K/10K (and 100K with --full). Optional
JSON output for tracking.

Baseline recorded in BENCHMARKS.md. It shows two problems: HNSW recall@10 does
not respond to ef and falls from 0.87 (1K) to 0.31 (100K) on clustered data,
and end-to-end hybrid_search is ~1000x slower than its vector stage because
every query rebuilds BM25 and rewrites the .h5 file.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 07:33:20 -07:00
osobhandClaude Fable 5.1 a3ad548f84 Merge release/v2.3.0
CI / test (push) Failing after 13s
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 07:21:16 -07:00
osobhandClaude Fable 5.1 0876796432 chore(release): v2.3.0
Bump all workspace crates, the node package and pyproject to 2.3.0, finalize
the changelog and add upgrade notes for the behaviour changes.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 07:19:11 -07:00
osobhandClaude Fable 5.1 91d46a3813 Merge feat/attr-fidelity: attrs() reports every attribute; unsigned arrays stay unsigned
CI / test (push) Failing after 2s
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 07:18:26 -07:00
osobhandClaude Fable 5.1 97ab658c11 feat: attrs() reports every attribute; unsigned arrays stay unsigned
attrs() silently omitted any attribute whose datatype had no AttrValue variant
— including every Python bool, which h5py stores as an enum — plus complex,
compound and reference attributes, and cast unsigned 64-bit arrays to
I64Array so values above i64::MAX came back negative.

- numpy/h5py-style booleans (an enum of exactly FALSE=0 / TRUE=1 over an
  integer base) decode as I64 / I64Array of 0/1.
- AttrValue::U64Array keeps unsigned arrays unsigned. Behaviour change: an
  unsigned array attribute no longer arrives as I64Array; the netCDF-4 CF
  helpers (_FillValue, valid_range) and the Python bindings handle it.
- AttrValue::Raw { datatype, shape, data } carries any other attribute
  verbatim (also used when a value fails to decode as its declared type), so
  the attribute list is always complete. Decodable with data_read against the
  datatype; Python receives {"dtype", "shape", "data"}.
- Both new variants are writable, so attributes round-trip between files.
  h5py interop tests cover reading 13 attribute kinds and h5py reading back a
  compound and a u64 attribute written by clawhdf5.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 07:05:34 -07:00
osobhandClaude Fable 5.1 5dd95a6cf8 Merge feat/format-robustness: committed datatypes, fill values, soft links, VDS path confinement, WAL/crash tests
CI / test (push) Failing after 1s
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:57:27 -07:00
osobhandClaude Fable 5.1 a0ff8ef32c docs: changelog and known issues for the format robustness work
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:43:54 -07:00
osobhandClaude Fable 5.1 24afcdc70f test(agent): WAL property tests, crash-recovery matrix, WAL fuzz target
- tests/wal_properties.rs — deterministic generator, reproducible by seed:
  everything appended is read back intact (300 cases), and after ANY damage
  to the file (bit flips, truncation, inserted/deleted bytes, duplicated or
  rotated regions, overwritten ranges; 1500 cases) reading never panics and
  yields an exact prefix of what was written — the guarantee the chained CRC
  exists to give. Opening for append then repairs the tail and a new entry
  lands right behind the surviving prefix.
- tests/crash_recovery.rs — builds the on-disk images a process crash can
  leave and reopens each against a model of what was acknowledged: an image
  after every operation (random saves, in-place updates, checkpoints, small
  wal_max_entries), the checkpoint window (new .h5 + not-yet-truncated WAL)
  over several rounds, and the WAL torn at every byte length, which must
  recover the checkpoint plus a prefix of the operations logged since.
- fuzz/fuzz_wal_replay — arbitrary bytes as a WAL: read and open-for-append
  must not panic, and open() must not change what is replayable. Based on the
  target from the clawmates mission branch (4aee2fa), with the repair
  property added. The CI fuzz step now covers both fuzz crates.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:43:34 -07:00
osobhandClaude Fable 5.1 8f62cb44e0 security(clawhdf5): confine virtual-dataset source files to the base directory
The VDS resolver joined the source file name stored in the HDF5 file straight
onto the opened file's directory. That name is untrusted: an absolute path
replaces the base directory outright and `..` components climb out of it, so
a crafted file could make the reader open any path the process can reach.
Only plain relative paths of normal components are accepted now; anything
else resolves to "source not found".

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:39:30 -07:00
osobhandClaude Fable 5.1 e38c8133bc feat(format): follow soft links; explicit errors for external links and external raw data
- Path resolution follows soft links in both old-style (symbol table, cache
  type 2) and new-style (compact and dense Link message) groups: absolute and
  relative targets, links to groups, links through links, with a depth limit
  so a link cycle is NestingDepthExceeded rather than a hang. A dangling link
  reports the target it could not find. Previously every soft link was
  PathNotFound.
- An external link is FormatError::ExternalLinkUnsupported { filename,
  object_path } instead of a misleading PathNotFound.
- Message 0x0007 (External Data Files) is now a known MessageType, and a
  dataset carrying it is FormatError::ExternalDataFilesUnsupported. Such a
  dataset has no data address in this file, so it would otherwise be read as
  "never written" and answered with fill values — wrong data, no error.
- Dense link iteration is shared between hard-link listing and the new
  symbolic-link lookup; entry listing behaviour is unchanged.
- h5py interop test for both libver settings.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:39:01 -07:00
osobhandClaude Fable 5.1 12847c6c66 feat(format): apply fill values to unallocated storage on read
HDF5 allocates lazily: a chunk nobody wrote doesn't exist in the file, and a
dataset nobody wrote has no data address. Such regions must read as the
dataset's fill value. There was no Fill Value message parser at all, so:

- a sparse chunked dataset read its holes as zeros — silently wrong whenever
  the fill value isn't zero (h5py `fillvalue=-1` came back as 0);
- a dataset that was created but never written failed with NoDataAllocated /
  "no address for chunked layout" where h5py returns a filled array.

New clawhdf5_format::fill_value: parses Fill Value messages v1-v3 and the old
0x0004 message (validated against HDF5 2.0 output under default and latest
libver), builds a fully filled dataset when there is no storage, and writes the
fill value into exactly the chunk-grid cells absent from the chunk index —
never mistaking a stored zero for a hole, clipping edge chunks, any rank. It is
skipped entirely for the default (zero) fill value. The chunk index dispatch is
extracted from read_chunked_data into a reusable list_chunks.

The reader, lazy and mmap facades apply it on full reads; selection reads go
through a fill-aware full read when the fill value matters. h5py interop test
compares against h5py's own readback, including a sparse 2-D dataset and a
hyperslab straddling allocated and unallocated chunks.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:35:47 -07:00
osobhandClaude Fable 5.1 81e8294048 fix(format): read datasets and attributes that use committed datatypes
A dataset created from a committed (named) datatype stores only a shared-
message reference to it. The facade parsed those reference bytes as the
datatype itself, producing `Time { size: 0 }` and unreadable data, and an
attribute using a committed datatype was silently dropped.

- shared_message::parse_shared_ref had the encoding wrong: it skipped six
  reserved bytes for version 2 (only version 1 has them) and had the version 3
  types inverted (1 is the SOHM heap, 2 is "committed, in another object
  header"). Verified against h5py 3.16 / HDF5 2.0, which writes
  `02 02 <address>` under both default and latest libver bounds. Resolution
  now dispatches on which field the reference carries.
- New shared_message::message_data resolves a header message through the
  indirection; the reader, lazy and mmap facades use it for datatype,
  dataspace and filter-pipeline messages.
- AttributeMessage honours the v2/v3 flags (bit 0 datatype shared, bit 1
  dataspace shared) via the new parse_in_file, used everywhere file data is
  available. Parsing a shared attribute without file access is now
  FormatError::UnresolvedSharedMessage instead of a garbage datatype.
- h5py interop test covering both libver settings.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:31:17 -07:00
osobhandClaude Fable 5.1 0eca8574f5 Merge feat/durability-integrity: crash-safe checkpoints, single-writer lock, load validation, format hardening
CI / test (push) Failing after 1s
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:25:09 -07:00
osobhandClaude Fable 5.1 005f37e846 docs: changelog and CLAUDE.md for the durability & integrity work
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:14:56 -07:00
osobhandClaude Fable 5.1 bf8bbec87e fix(clawhdf5): surface filter-pipeline parse errors; write files atomically
- Dataset::filter_pipeline() (reader, lazy and mmap variants) swallowed parse
  errors with `.ok()`, so a malformed pipeline message silently became "no
  filters" and the still-compressed chunk bytes were returned as the data. It
  now returns Result<Option<_>>; a present-but-unparseable pipeline is
  Error::Format.
- FileBuilder::write used std::fs::write, which truncates the destination
  first: a crash mid-write destroyed the existing file. It now writes a
  sibling temp file, syncs it, renames it over the target and syncs the
  directory, cleaning the temp file up on failure.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:14:30 -07:00
osobhandClaude Fable 5.1 6e84f31ed6 fix(format): overflow-checked sizes and fallible allocation on chunked reads
Dataspace and chunk dimensions are untrusted 64-bit fields, but the chunked
read paths computed `num_elements() as usize * elem_size` and
`chunk_dims.product() * elem_size` with plain arithmetic and fed the result to
`vec![0u8; n]`. A crafted file could wrap the product (under-sizing the output
buffer that chunks are then copied into) or request an allocation large enough
to abort the process.

- Dataspace::checked_num_elements, checked_byte_len, checked_chunk_byte_len
  and alloc_output (try_reserve_exact) replace the plain products and
  vec![0; n] at every chunked read site, plus the VDS and hyperslab paths.
  Overflow and allocation failure are FormatError::Overflow.
- Dataspace::num_elements saturates instead of wrapping.
- A zero-element dataset returns early, which also keeps the stride products
  in range when another dimension is huge.
- parallel_read.rs: the three `c_addr + size > len` bounds checks used a raw
  add; they now use checked_add like the rest of the crate.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:13:39 -07:00
osobhandClaude Fable 5.1 3ed0489faa fix(agent): deterministic hybrid ranking; don't reinforce zero-score filler
test_hebbian_activation_boost failed intermittently. Root causes, all in the
query path:

- normalize_scores mapped a set of identical scores — including the
  single-candidate case — to 0.0, so a lone perfect match contributed nothing
  to the fused score. Identical positive scores now normalise to 1.0 (all
  equally the best match); identical non-positive scores stay 0.0.
- merge_vector_keyword sorted a HashMap's entries by score alone and then
  truncated, so which ties survived varied from run to run; hybrid_search had
  the same problem in its final sort. Both now break ties by index.
- hybrid_search applied the Hebbian boost to every returned record, including
  the zero-score filler that pads the list when fewer than k records match.
  With random tie-breaking a filler record could collect as many boosts as the
  real hit. Only records with a positive fused score are reinforced now.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:11:38 -07:00
osobhandClaude Fable 5.1 0744d52639 fix(agent): provenance survives compaction; bound alert/session growth; snapshot the WAL
- ProvenanceStore::remap: compaction renumbers cache indices (which are the
  provenance record ids) but nothing renumbered the ledger, so after any
  compaction — including the automatic one in delete() — every surviving
  record's hash was filed under a different record and the next
  save_or_update raised a bogus High "integrity mismatch" alert.
- Pending anomaly alerts are capped (newest 1024 kept). Alerts never block a
  save, and a session over its write limit alerts on every write, so a caller
  that didn't drain them grew the queue without bound.
- WriteAnomalyDetector tracks at most 4096 sessions, forgetting the
  least-active half on overflow instead of leaking one entry per session id
  for the life of the process.
- snapshot() copies the pending WAL next to the .h5 copy, so a snapshot is
  the store as it is now rather than as of the last checkpoint (it used to
  silently omit up to wal_max_entries recent saves).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:08:53 -07:00
osobhandClaude Fable 5.1 99b907be04 feat(agent): single-writer lock, read-only open, recoverable WAL
- HDF5Memory::create/open take an exclusive advisory lock on <store>.h5.lock
  (std File::try_lock, no new dependency). The store lives in memory and is
  rewritten wholesale at each checkpoint, so two handles on one store used to
  silently destroy each other's data; a second writer now gets
  MemoryError::Locked. The OS drops the lock with the descriptor, so a crash
  never leaves a stale lock. Acquisition retries for ~250 ms to absorb a
  previous owner that is mid-teardown; AsyncHDF5Memory::shutdown releases the
  lock once its writer task has stopped.
- HDF5Memory::open_read_only: a lock-free, point-in-time view (checkpoint +
  current WAL contents, replayed in memory) that never writes — it does not
  repair, upgrade or move the WAL, and anything that would persist returns an
  error. The CLI's recall/stats/agents-md/export use it, so a store can be
  inspected while an agent has it open. Tests that reopened a store purely to
  verify on-disk state now use it.
- open() no longer fails on a WAL that cannot possibly be replayed (torn
  header, bad magic): it is moved to <store>.h5.wal.corrupt-<ts>, reported via
  HDF5Memory::quarantined_wal(), and the healthy .h5 opens from its last
  checkpoint. A well-formed header with an unknown version still fails and is
  left untouched — most likely a newer build's WAL, which must not be
  discarded.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:07:21 -07:00
osobhandClaude Fable 5.1 4f2975d7e3 fix(agent): persist behavioural config; make compression actually work
Eight MemoryConfig fields (float16, compression, compression_level,
compact_threshold, hebbian_boost, decay_factor, wal_enabled, wal_max_entries)
were never written to /meta, so reopening a store silently reset them to
defaults — a compressed store was rewritten uncompressed by the first
checkpoint after a reopen, and wal_enabled=false flipped back to true. They
are now stored as /meta attributes; each is optional on load so older files
keep opening with the previous defaults, and non-finite floats are ignored.

Writing the round-trip test exposed that `compression = true` never worked in
a default build: the embeddings dataset called with_zstd() unconditionally but
the agent crate never enabled the zstd feature, so every checkpoint failed
with "unsupported filter: 32015". The default build now compresses with
deflate (always available, pure Rust path); Zstd is opt-in via a new `zstd`
agent feature.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:00:32 -07:00
osobhandClaude Fable 5.1 d4f2d3e7b5 fix(agent): log save_or_update as an Update WAL record
A save_or_update that hit an existing record was logged as a plain Save, so
replaying the WAL appended a duplicate instead of updating in place. It is now
logged as WalEntryType::Update (0x04) carrying the target index, and replay
applies it with cache.update().

The WAL header version goes 3 -> 4 for the benefit of older binaries: they
don't know record type 0x04, would read it as a torn tail and truncate it and
everything after it. An unknown header version makes them refuse the file
instead. The framing is otherwise identical, so v3 files are read by the same
code and upgraded in place on open (the header is outside the CRC chain).

Also drop the redundant WAL truncate that several callers ran straight after
flush(), which already truncates.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 05:58:04 -07:00
osobhandClaude Fable 5.1 6848494647 Merge feat/ci-hardening: CI that actually tests, compound v1/v2 fix, gpu_tests hang fix
CI / test (push) Failing after 1s
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 05:54:34 -07:00
osobhandClaude Fable 5.1 943b9141e3 fix(agent): crash between checkpoint and WAL truncate no longer duplicates entries
flush() writes the new .h5 and only then truncates the WAL. A crash in that
window left a .h5 that already contained the pending entries AND a WAL that
still listed them, and open() replayed the WAL unconditionally — every pending
entry came back twice.

A checkpoint now records a WalMark in /meta (wal_applied_len/wal_applied_crc):
the byte length and chained CRC of the WAL prefix it folded in. On open, if
the WAL's v3 CRC chain passes through exactly that position, the entries up to
it are skipped; otherwise (the normal case: the WAL was truncated) everything
is replayed. No WAL format change; files without the attributes behave as
before. WalFile tracks its chain length alongside running_crc and resumes both
on reopen.

Also make the checkpoint and snapshot durable as a unit: sync the temp file
before the rename and the parent directory after it, so a power loss can't
leave an empty or partial .h5 under the final name. This is per-checkpoint
cost only; individual WAL appends remain unsynced by design.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 05:42:10 -07:00
osobhandClaude Fable 5.1 a9f78ca5a1 fix(agent): validate per-record dataset lengths when loading a store
The norms guard was the tautology `n.len() == n.len()`, so a norms dataset
of any length was trusted and corrupted every cosine score; other per-record
datasets were not length-checked at all, so a truncated file loaded and then
panicked on the first index. Mismatches are now MemoryError::Schema, stored
norms are used only when they match the record count, and embedding_dim == 0
with records present is rejected instead of panicking in chunks(0).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 05:37:21 -07:00
osobhandClaude Fable 5.1 a3f7c6fe89 style: cargo fmt --all
Formatting only. cargo fmt --check was already failing on main (accel SIMD
kernels, agent, format, migrate, bench); CI now enforces it.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 05:36:23 -07:00
osobhandClaude Fable 5.1 bbe1baa208 ci: lint all targets, run interop suites for real, compile benches
- clippy --all-targets plus a clawhdf5-format feature matrix (parallel, lz4,
  zstd, pcodec, fast-checksum); fix the accumulated lint backlog in test,
  bench and feature-gated code (no behaviour changes).
- Install python3 + h5py/numpy/netCDF4/xarray in the CI container and set
  CLAWHDF5_REQUIRE_INTEROP=1, which makes a missing interop dependency a test
  failure. Every h5py/netCDF4 interop test used to skip silently in CI. Run
  the #[ignore]d writer_h5py_tests suite explicitly.
- cargo bench --no-run so benches can't rot; fix bench.rs and memory_bench.rs,
  which no longer compiled against the current strategy/consolidation APIs.
- Optional fuzz smoke run via CLAWHDF5_FUZZ_SECONDS.
- CHANGELOG and docs/known-issues.md updated.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 05:36:22 -07:00
osobhandClaude Fable 5.1 706189c3ef fix(gpu): stop gpu_tests hanging under the parallel test runner
Every test created its own wgpu instance and device (with adapter-maximum
limits) concurrently, which could wedge the driver and hang the suite
indefinitely. Tests now hold a process-wide lock while they own a device, and
GpuAccelerator readback waits are bounded at 30s so a stuck driver surfaces as
GpuError::BufferMap instead of blocking forever.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 05:36:22 -07:00
osobhandClaude Fable 5.1 926dc457e0 fix(format): parse compound datatype versions 1 and 2 correctly
Compound datasets written with default libver bounds (datatype message
version 1, i.e. plain h5py.File(path, 'w')) could not be read: the v1 member
layout has 28 bytes of legacy array fields after the byte offset
(dimensionality 1, reserved 3, permutation 4, reserved 4, four sizes 16) and
the parser skipped 24, so every following member was read 4 bytes off. v2 was
also wrong: it keeps the 8-byte name padding and has no array fields.

Found by adding a default-libver axis to the h5py-generated-file tests (HDF5
2.0 raised the default low bound to 1.8, so "default" files are a distinct
format path from libver='latest'). Adds byte-level v1/v2 regression tests, a
truncation test, and fuzz corpus seeds for v1 compound and native complex.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 05:36:22 -07:00
99 changed files with 6593 additions and 926 deletions
+14
View File
@@ -22,5 +22,19 @@ jobs:
run: rustup component add rustfmt clippy
- name: Install thumbv7em-none-eabihf target
run: rustup target add thumbv7em-none-eabihf
- name: Install Python interop dependencies
# The interop suites used to skip silently when python3/h5py were
# missing, so they never ran in CI. Install them and make a missing
# dependency a failure (CLAWHDF5_REQUIRE_INTEROP below).
run: |
apt-get update
apt-get install -y --no-install-recommends python3 python3-venv
python3 -m venv /opt/interop
/opt/interop/bin/pip install --no-cache-dir h5py numpy netCDF4 xarray
echo "/opt/interop/bin" >> "$GITHUB_PATH"
- name: Show interop library versions
run: python3 -c "import h5py, netCDF4; print('h5py', h5py.__version__, 'HDF5', h5py.version.hdf5_version, 'netCDF4', netCDF4.__version__)"
- name: Run CI script
env:
CLAWHDF5_REQUIRE_INTEROP: "1"
run: bash scripts/ci-test.sh
+215
View File
@@ -28,6 +28,221 @@
---
## Search harness baseline (v2.3.0)
Produced by `cargo run --release -p clawhdf5-bench --bin search_harness -- --full`
on deterministic **clustered** synthetic data (384-dim, unit-normalised; points =
cluster centre + noise — uniform random vectors are nearly equidistant in high
dimension and say nothing about embeddings). Recall is measured against an exact
brute-force scan, 200 queries. This is the *before* picture for the search
hot-path work; every change to that path should be justified by a re-run.
Two things stand out:
* **HNSW recall does not respond to `ef`** and degrades sharply with size
(0.87 → 0.67 → 0.31 recall@10 at 1K / 10K / 100K). Latency plateaus at the same
point, i.e. the search exhausts the nodes it can reach: on clustered data the
graph is poorly connected. The index selects neighbours by plain top-M
distance rather than the HNSW paper's diversity heuristic.
* **End-to-end `hybrid_search` is ~1000x slower than its vector stage** (49 ms
vs ~0.03 ms at 10K; 884 ms at 100K). Each query rebuilds the BM25 index from
scratch and rewrites the whole `.h5` file. The first query after `open()`
additionally rebuilds the HNSW index (10.5 s at 100K).
### HNSW, N = 1000, dim = 384, M = 16, ef_construction = 64
build: 72.4 ms (13818 vectors/s) · exact scan: 3854 QPS, p50 258 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.8710 | 59484 | 16 | 31 |
| 32 | 0.8730 | 46302 | 21 | 25 |
| 64 | 0.8730 | 31683 | 31 | 44 |
| 128 | 0.8730 | 24715 | 40 | 49 |
| 256 | 0.8730 | 24788 | 40 | 50 |
### HNSW, N = 10000, dim = 384, M = 16, ef_construction = 64
build: 802.5 ms (12461 vectors/s) · exact scan: 418 QPS, p50 2363 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.6695 | 44031 | 19 | 51 |
| 32 | 0.6705 | 45066 | 22 | 30 |
| 64 | 0.6705 | 32746 | 30 | 41 |
| 128 | 0.6705 | 27542 | 36 | 51 |
| 256 | 0.6705 | 27754 | 36 | 49 |
### HNSW, N = 100000, dim = 384, M = 16, ef_construction = 64
build: 9752.6 ms (10254 vectors/s) · exact scan: 40 QPS, p50 24648 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.3085 | 18046 | 57 | 84 |
| 32 | 0.3110 | 21621 | 43 | 75 |
| 64 | 0.3130 | 20015 | 49 | 70 |
| 128 | 0.3135 | 15822 | 63 | 99 |
| 256 | 0.3135 | 15308 | 66 | 124 |
### End to end: `HDF5Memory::hybrid_search` (k = 10, weights 0.7 / 0.3)
| N | ingest ms | checkpoint ms | open ms | first query ms | p50 ms | p99 ms | QPS |
|---:|---:|---:|---:|---:|---:|---:|---:|
| 1000 | 11 | 3.9 | 0.9 | 68.1 | 5.48 | 5.57 | 182.5 |
| 10000 | 114 | 32.2 | 10.9 | 845.0 | 48.56 | 78.65 | 19.8 |
| 100000 | 1486 | 713.0 | 354.5 | 10486.5 | 883.51 | 975.23 | 1.1 |
wrote /tmp/claude-1000/-home-osobh-projects-clawhdf5/422f755e-dd25-4c35-8613-5439087e3aaa/scratchpad/baseline_full.json
### After: HNSW neighbour-selection heuristic
Same harness, same data, after replacing closest-M neighbour selection with the
HNSW paper's diversity heuristic (Algorithm 4, keeping pruned connections) for
both new links and back-link pruning. Recall@10 at `ef = 64`: **0.87 → 1.00**
(1K), **0.67 → 1.00** (10K), **0.31 → 0.98** (100K), and it now rises with
`ef` as it should. The cost is a slower build (extra distance evaluations per
insert: ~3.5x at 10K); the distance-kernel work that follows targets that.
### HNSW, N = 1000, dim = 384, M = 16, ef_construction = 64
build: 221.3 ms (4519 vectors/s) · exact scan: 3851 QPS, p50 258 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9990 | 54760 | 18 | 29 |
| 32 | 1.0000 | 40422 | 24 | 44 |
| 64 | 1.0000 | 27744 | 36 | 51 |
| 128 | 1.0000 | 13164 | 74 | 106 |
| 256 | 1.0000 | 6879 | 144 | 175 |
### HNSW, N = 10000, dim = 384, M = 16, ef_construction = 64
build: 2733.5 ms (3658 vectors/s) · exact scan: 423 QPS, p50 2362 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9975 | 31321 | 27 | 61 |
| 32 | 1.0000 | 32427 | 29 | 48 |
| 64 | 1.0000 | 22738 | 42 | 62 |
| 128 | 1.0000 | 10055 | 99 | 129 |
| 256 | 1.0000 | 4649 | 214 | 266 |
### HNSW, N = 100000, dim = 384, M = 16, ef_construction = 64
build: 36472.8 ms (2742 vectors/s) · exact scan: 40 QPS, p50 24644 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9235 | 11394 | 82 | 194 |
| 32 | 0.9675 | 12788 | 73 | 161 |
| 64 | 0.9840 | 10406 | 91 | 186 |
| 128 | 0.9990 | 7633 | 126 | 248 |
| 256 | 0.9990 | 2823 | 352 | 510 |
### After: persistent keyword index, no store rewrite per query
`hybrid_search` used to rebuild the BM25 index from scratch (re-tokenising every
record) and rewrite the whole `.h5` file on **every query**. The index is now
kept for the life of the store and updated incrementally, and activation boosts
are persisted by the next checkpoint instead of inside the query. Steady-state
p50: **5.5 → 0.24 ms** (1K), **49 → 2.1 ms** (10K), **884 → 23 ms** (100K).
The first query after `open()` is slower than before (it pays for the better —
slower — HNSW build plus the one-off keyword index build); persisting the HNSW
index removes that.
### End to end: `HDF5Memory::hybrid_search` (k = 10, weights 0.7 / 0.3)
| N | ingest ms | checkpoint ms | open ms | first query ms | p50 ms | p99 ms | QPS |
|---:|---:|---:|---:|---:|---:|---:|---:|
| 1000 | 11 | 3.8 | 0.9 | 195.9 | 0.24 | 0.27 | 4130.4 |
| 10000 | 104 | 31.1 | 10.9 | 2627.1 | 2.09 | 2.11 | 479.5 |
| 100000 | 1436 | 684.7 | 278.0 | 36308.1 | 22.90 | 25.46 | 43.5 |
### After: vector index persisted with the checkpoint
The HNSW graph (not the vectors, which the store already holds) is saved to
`<store>.h5.ann` at each checkpoint and reloaded by `open()`, tied to that
checkpoint by a generation id. The index is now built once per store (the *cold
index build* column — the first query ever), not once per session. First query
after `open()`: **196 → 1.7 ms** (1K), **2627 → 15 ms** (10K),
**36308 → 159 ms** (100K); what remains is the one-off keyword index build.
Batch saves no longer force a full rebuild either: appended records join the
index incrementally.
| N | ingest ms | cold index build ms | checkpoint ms | open ms | first query after open ms | p50 ms | p99 ms | QPS |
|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 1000 | 14 | 220 | 6.1 | 1.2 | 1.7 | 0.24 | 0.27 | 4049.9 |
| 10000 | 120 | 2916 | 33.1 | 14.0 | 15.4 | 2.15 | 3.30 | 421.2 |
| 100000 | 1591 | 40515 | 747.3 | 324.7 | 158.9 | 23.07 | 30.42 | 41.3 |
### After: unit-vector dot product, reusable visited set
Cosine distance recomputed both vector norms on every evaluation; the index now
stores unit vectors and uses a plain dot product. The per-call `HashSet` of
visited nodes became a reusable epoch-stamped array. Recall is unchanged.
Build: **2.75 -> 1.89 s** (10K), **~38 -> 21 s** (100K). QPS at `ef = 64`:
**22.7K -> 39K** (10K), **10.4K -> 14K** (100K).
### HNSW, N = 1000, dim = 384, M = 16, ef_construction = 64
build: 113.4 ms (8821 vectors/s) · exact scan: 4375 QPS, p50 225 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9990 | 144379 | 7 | 15 |
| 32 | 1.0000 | 110654 | 9 | 17 |
| 64 | 1.0000 | 80446 | 12 | 25 |
| 128 | 1.0000 | 38220 | 26 | 36 |
| 256 | 1.0000 | 20041 | 50 | 62 |
### HNSW, N = 10000, dim = 384, M = 16, ef_construction = 64
build: 1519.4 ms (6581 vectors/s) · exact scan: 422 QPS, p50 2368 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9975 | 54608 | 15 | 45 |
| 32 | 1.0000 | 66009 | 14 | 24 |
| 64 | 1.0000 | 49854 | 19 | 31 |
| 128 | 1.0000 | 22403 | 45 | 57 |
| 256 | 1.0000 | 10096 | 100 | 120 |
### HNSW, N = 100000, dim = 384, M = 16, ef_construction = 64
build: 21084.6 ms (4743 vectors/s) · exact scan: 39 QPS, p50 24739 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9235 | 15139 | 61 | 154 |
| 32 | 0.9675 | 18181 | 53 | 121 |
| 64 | 0.9840 | 13980 | 70 | 139 |
| 128 | 0.9990 | 10959 | 86 | 174 |
| 256 | 0.9990 | 3731 | 254 | 697 |
### After: unranked keyword scores, top-k merge (rankings unchanged)
A fusion study (`search_harness --fusion-study`) showed that capping the
keyword candidate pool is **not** a safe optimisation: against the current
full-corpus normalisation the final top-10 overlap is only 0.83-0.92 and the
first result changes for 10-35% of queries, for only a 2x saving. So the fusion
semantics were left alone and the same answer made cheaper: fusion needs every
keyword score but not their ranking, so BM25 now returns them unsorted from a
dense accumulator (it hashed every posting and then sorted every match), and
the merge selects its top k instead of sorting every candidate. Steady-state
p50: **0.24 -> 0.07 ms** (1K), **2.1 -> 0.49 ms** (10K), **23 -> 4.65 ms**
(100K) — **79x / 100x / 190x** faster than the v2.3.0 baseline, with identical
results.
### End to end: `HDF5Memory::hybrid_search` (k = 10, weights 0.7 / 0.3)
| N | ingest ms | cold index build ms | checkpoint ms | open ms | first query after open ms | p50 ms | p99 ms | QPS |
|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 1000 | 11 | 112 | 4.0 | 1.1 | 1.4 | 0.07 | 0.08 | 14077.9 |
| 10000 | 104 | 1487 | 33.8 | 13.7 | 13.9 | 0.49 | 0.51 | 2020.9 |
| 100000 | 1376 | 20285 | 728.9 | 353.1 | 142.2 | 4.65 | 4.78 | 214.7 |
## Vector Search Latency
Brute-force cosine similarity over 384-dimensional embeddings (OpenAI text-embedding-3-small size).
+226
View File
@@ -1,5 +1,231 @@
# Changelog
## v2.4.0 (2026-09-19)
### Upgrade Notes
- **Search results improve on upgrade.** The HNSW index now reaches true
neighbours it previously could not (recall@10 0.31 -> 0.98 at 100K records on
clustered data), so `hybrid_search` rankings change for the better. The agent
rebuilds its index from the store automatically; a standalone `HnswIndex`
persisted with `to_hdf5_bytes` keeps its old graph until rebuilt.
- **`hybrid_search` no longer writes the store.** Hebbian activation boosts are
persisted by the next checkpoint (any flushing write, `flush_wal`, or when
the `HDF5Memory` is dropped) instead of inside every query; a crash before
then forgets only the boosts since the last checkpoint. Activation weights
are now capped at 16.
- A new sidecar file, `<store>.h5.ann`, holds the vector index graph. It is
derived data: safe to delete (the index is rebuilt), copied by `snapshot()`,
and worth including when copying a store by hand to avoid a rebuild.
- `BM25Index` no longer caches IDF and gained `add_document`,
`remove_document`, `pad_to`, `scores`, `len` and `is_empty`; results are now
deterministic (ties break by record id).
### Search
- `clawhdf5-ann`: **HNSW recall fix.** Neighbours were chosen as the plain
closest-M, which on clustered data (what embeddings look like) turns each
cluster into an island: recall@10 was 0.87 / 0.67 / 0.31 at 1K / 10K / 100K
vectors and did not improve with `ef`. The index now uses the HNSW paper's
diversity heuristic (Algorithm 4 with kept pruned connections) when linking a
new node and when pruning back-links: recall@10 at `ef = 64` is 1.00 / 1.00 /
0.98 and responds to `ef`. Builds are slower (~3.5x at 10K). Existing
persisted indexes keep their old graph until rebuilt; the agent rebuilds its
index from the cache, so stores pick this up automatically.
- `clawhdf5-agent`: **`hybrid_search` is 23-39x faster in steady state** (p50
5.5 -> 0.24 ms at 1K records, 49 -> 2.1 ms at 10K, 884 -> 23 ms at 100K).
Every query used to rebuild the BM25 index from scratch and rewrite the whole
`.h5` file. The keyword index now lives for the life of the store and is
updated incrementally (add / remove / in-place update, exactly equivalent to
a fresh build - property-tested), and a query no longer writes the store.
**Behaviour change:** Hebbian activation boosts are persisted by the next
checkpoint (any flushing write, `flush_wal`, or drop) rather than
immediately; a crash in between forgets only the boosts since the last
checkpoint. Activation weights are now capped (16.0) - they grew without
bound.
- `clawhdf5-agent`: **the vector index is persisted**, so `open()` no longer
rebuilds it on the first search (first query after open: 2627 -> 15 ms at 10K
records, 36 s -> 159 ms at 100K). The HNSW graph — not the vectors, which the
store already holds — is written to `<store>.h5.ann` at each checkpoint and
tied to it by a generation id in `/meta`; a missing, stale, damaged or
structurally invalid sidecar is ignored and the index rebuilt. Records
replayed from the WAL join the loaded index incrementally; a replayed update
or delete invalidates it. `snapshot()` copies it. Batch saves no longer force
a full index rebuild.
- `clawhdf5-ann`: faster HNSW build and search with identical recall. The
cosine metric stores unit vectors and compares them with a plain dot product
(it re-derived both norms on every distance evaluation), and the per-call
`HashSet` of visited nodes is a reusable epoch-stamped array. Build 2.75 ->
1.89 s at 10K and ~38 -> 21 s at 100K; QPS at `ef = 64` 22.7K -> 39K at 10K.
Distances returned by `search` are unchanged (1 - cosine). Indexes loaded
from older HDF5 files are normalised on load.
- `clawhdf5-accel`: the SIMD backend is detected once per process instead of
on every kernel call.
- `clawhdf5-ann`: `HnswIndex::graph_to_bytes` / `from_graph_bytes` — graph-only
serialization (checksummed, every neighbour id and level validated on load).
- `clawhdf5-agent`: a further 4-5x on `hybrid_search` with **identical
rankings** (p50 now 0.07 / 0.49 / 4.65 ms at 1K / 10K / 100K — 79x / 100x /
190x faster than v2.3.0). Fusion needs every keyword score but not their
ranking: new `BM25Index::scores` returns them unsorted from a dense
accumulator (it hashed every posting, then sorted every match), and
`merge_vector_keyword` selects its top k instead of sorting every candidate.
Capping the keyword candidate pool was measured and rejected: it changes the
top-10 for most queries (`search_harness --fusion-study`).
- `clawhdf5-agent`: BM25 results are deterministic (ties break by record id),
top-k uses a bounded heap, and the "WAND early termination" that computed a
bound and then ignored it is gone. IDF is computed per query.
- `clawhdf5-bench`: new `search_harness` binary — HNSW recall@10 / QPS / latency
per `ef` against an exact scan, and end-to-end `hybrid_search` timings, on
deterministic clustered (or `--uniform`) data. Baseline in `BENCHMARKS.md`.
## v2.3.0 (2026-09-19)
### Upgrade Notes
- **A memory store now has a single writer.** `HDF5Memory::create`/`open` take
an exclusive lock (`<store>.h5.lock`); a second open of the same store — in
the same or another process — returns `MemoryError::Locked`. Code that opened
a second handle just to read should use `HDF5Memory::open_read_only`.
- **Unsigned array attributes arrive as `AttrValue::U64Array`**, not
`I64Array`, and `attrs()` may now return `AttrValue::Raw`. Exhaustive matches
on `AttrValue` need the two new arms.
- **WAL header version 3 → 4.** v3 files are read and upgraded in place, but a
store written by 2.3.0 with a pending WAL cannot be opened by 2.2.0 or
earlier (it is refused, not corrupted). Checkpoint first
(`flush_wal`) if you need to downgrade.
- `MemoryConfig::compression` now uses deflate unless the agent's new `zstd`
feature is enabled; it previously failed outright in a default build.
- `MemoryError` gained `Locked`; `FormatError` gained `UnresolvedSharedMessage`,
`ExternalDataFilesUnsupported` and `ExternalLinkUnsupported`; `MessageType`
gained `ExternalDataFiles`.
### Bug Fixes
- `clawhdf5-format`: compound datatypes written with **default libver bounds**
(datatype message version 1 — what plain `h5py.File(path, 'w')` produces)
were mis-parsed. The v1 member layout carries 28 bytes of legacy array
fields after the byte offset (the parser skipped 24), and v2 pads member
names to 8 bytes and has no array fields at all (the parser did neither), so
every member after the first byte offset was read from the wrong position —
typically surfacing as `Overflow("compound member ...")` on read. Found by
adding a default-libver axis to the h5py interop tests; byte-level regression
tests for v1 and v2 added.
- `clawhdf5-gpu`: `gpu_tests` could hang forever under the default parallel
test runner — every test created its own wgpu instance and device at once.
Tests now serialise GPU access, and GPU→CPU readback waits are bounded
(30 s) so a wedged driver returns `GpuError::BufferMap` instead of blocking.
- `clawhdf5-agent`: `benches/bench.rs` and `benches/memory_bench.rs` no longer
compiled against the current `strategy`/`consolidation` APIs.
### HDF5 Compatibility
- `clawhdf5-format`/`clawhdf5`: datasets and attributes that use a **committed
(named) datatype** now read correctly. They store a shared-message reference;
the facade parsed the reference bytes as the datatype (`Time { size: 0 }`,
unreadable data) and silently dropped such attributes. The shared-reference
parser itself was wrong for real files: version 2 has no reserved bytes, and
the version 3 types were inverted (1 = SOHM heap, 2 = committed).
- **Fill values are applied on read.** There was no Fill Value message parser:
the holes of a sparse chunked dataset read as zeros even when the fill value
was not zero (silently wrong data), and a dataset that was created but never
written failed with `NoDataAllocated` where h5py returns a filled array.
Messages v1v3 and the old 0x0004 form are parsed; the fill value is written
into exactly the chunk-grid cells missing from the chunk index.
- **Soft links are followed** during path resolution, in old- and new-style
groups (absolute/relative targets, links to groups, links through links),
with a depth limit so a link cycle is an error rather than a hang. A dangling
link reports the target it could not find.
- Things the reader does not follow are now explicit errors instead of wrong
answers: an external link is `ExternalLinkUnsupported { filename,
object_path }` (was `PathNotFound`), and a dataset whose raw data lives in
external files (message 0x0007, now a known `MessageType`) is
`ExternalDataFilesUnsupported` (it would otherwise read as fill values).
- **`attrs()` no longer drops attributes.** Any attribute whose datatype had
no `AttrValue` variant was omitted with no error — including every Python
`bool` (h5py stores `attrs["flag"] = True` as an enum), complex numbers,
compound values and object references. Now:
- numpy/h5py-style booleans (an enum of exactly `FALSE`=0 / `TRUE`=1) decode
as `I64` / `I64Array` of 0/1;
- new `AttrValue::U64Array` keeps unsigned arrays unsigned (they were cast to
`I64Array`, so values above `i64::MAX` came back negative). **Behaviour
change:** code matching `I64Array` for an unsigned attribute must also
match `U64Array` (the netCDF-4 CF helpers and Python bindings do);
- new `AttrValue::Raw { datatype, shape, data }` carries everything else
verbatim, decodable with `clawhdf5_format::data_read` against `datatype`.
Both new variants are writable, so an attribute can be copied between files
unchanged. Python receives `Raw` as `{"dtype", "shape", "data"}`.
- All of the above are covered by h5py interop tests under both default and
`libver='latest'` bounds, compared against h5py's own readback.
### Security
- `clawhdf5`: virtual-dataset source file names are untrusted input but were
joined straight onto the opened file's directory, so a crafted file could
make the reader open any path the process can reach (absolute path, or `..`
components). Only plain relative paths inside that directory are accepted.
### Durability & Integrity
- `clawhdf5-agent`: a crash between writing a checkpoint and truncating the WAL
no longer **duplicates every pending entry** on the next open. Each
checkpoint records a `WalMark` (byte length + chained CRC of the WAL prefix it
folded in) in `/meta`; `open()` skips exactly that prefix when it is still
present. No WAL format change for this; older files behave as before.
- `clawhdf5-agent`: checkpoints and snapshots are durable as a unit — the temp
file is synced before the rename and the directory after it. Individual WAL
appends remain unsynced by design (documented in `CLAUDE.md`).
- `clawhdf5-agent`: `save_or_update` hits are logged as a new `Update` WAL
record, so replay updates in place instead of appending a duplicate. WAL
header version 3 → 4 (so older builds refuse the file rather than truncating
a record they can't parse); v3 files are read and upgraded in place.
- `clawhdf5-agent`: loading validates every per-record dataset length (a
truncated store is now `MemoryError::Schema`, not a later panic), fixes the
`n.len() == n.len()` tautology that trusted a norms dataset of any length,
and rejects `embedding_dim == 0` with records present.
- `clawhdf5-agent`: eight behavioural `MemoryConfig` fields are now persisted in
`/meta`. Previously they reset to defaults on every open — a compressed store
was rewritten uncompressed, `wal_enabled = false` flipped back to `true`.
- `clawhdf5-agent`: `compression = true` never worked in a default build (it
requested Zstd without enabling the feature, so every checkpoint failed with
`unsupported filter: 32015`). Default builds now use deflate; Zstd is the new
opt-in `zstd` feature.
- `clawhdf5-agent`: **single-writer lock** (`<store>.h5.lock`,
`MemoryError::Locked`) — two handles on one store used to silently destroy
each other's data. New `HDF5Memory::open_read_only` gives a lock-free,
never-writing view; the CLI's read-only subcommands use it.
- `clawhdf5-agent`: an unreadable WAL (torn header / bad magic) is quarantined
(`HDF5Memory::quarantined_wal()`) instead of blocking `open()` of a healthy
store. A WAL from an unknown newer version still fails and is left intact.
- `clawhdf5-agent`: provenance records are renumbered on compaction (they
weren't, so every later `save_or_update` raised a false High integrity
alert); pending anomaly alerts and tracked sessions are bounded;
`snapshot()` includes entries still in the WAL.
- `clawhdf5-agent`: hybrid ranking is deterministic (index tie-breaks instead
of `HashMap` order); a set of identical positive scores — including a single
candidate — normalises to 1.0 rather than 0.0; the Hebbian boost no longer
reinforces zero-score filler results.
- `clawhdf5-format`: chunked/VDS/hyperslab reads size their buffers with
overflow-checked arithmetic and fallible allocation, so crafted dimensions
are `FormatError::Overflow` instead of a wrapped size or a process abort;
`parallel_read` bounds checks use `checked_add`.
- `clawhdf5`: a malformed filter-pipeline message is an error instead of being
treated as "no filters" (which returned compressed bytes as data);
`FileBuilder::write` is atomic and synced instead of truncating the
destination first.
### CI / Testing
- CI now lints every target (`cargo clippy --all-targets`) plus
`clawhdf5-format`'s optional features, compiles all benches, and tests the
format feature matrix. Previously test/bench code and feature-gated modules
were never linted; the accumulated clippy backlog is fixed.
- CI installs python3 + h5py/numpy/netCDF4/xarray and sets
`CLAWHDF5_REQUIRE_INTEROP=1`, which turns a missing interop dependency into a
test **failure**. Until now every h5py/netCDF4 interop test silently skipped
in CI, which is how the HDF5 2.0 compound bug fixed in v2.2.0 reached a user.
The `#[ignore]`d `writer_h5py_tests` suite is run explicitly.
- h5py-generated-file tests now cover default libver bounds as well as
`libver='latest'` (HDF5 2.0 raised the default low bound to 1.8).
- `clawhdf5-agent`: WAL property tests (round trip; after any corruption the
entries read back are an exact prefix of what was written — 1500 seeded
cases), a crash-recovery matrix (an on-disk image after every operation, the
checkpoint window, and the WAL torn at every byte length, each reopened and
checked against a model), and a WAL fuzz target.
- Optional fuzz smoke run (`CLAWHDF5_FUZZ_SECONDS=N scripts/ci-test.sh`); new
datatype corpus seeds for v1 compound and native complex messages.
## v2.2.0 (2026-09-18)
### Security
+29
View File
@@ -33,6 +33,16 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
the approximate `clawhdf5-ann` index for the vector stage (the index mirrors
the cache and self-heals on drift). Build the agent with
`--no-default-features --features float16` to force the exact linear cosine scan.
The index uses the HNSW paper's diversity heuristic for neighbour selection
(plain closest-M capped recall on clustered data: 0.31 recall@10 at 100K). Its
graph is saved to `<store>.h5.ann` at each checkpoint and reloaded by `open()`
(tied to the checkpoint by a generation id; stale/damaged sidecars are
ignored and the index rebuilt). `hybrid_search` keeps one incremental BM25
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
query. Measure any search-path change with
`cargo run --release -p clawhdf5-bench --bin search_harness` (baselines in
`BENCHMARKS.md`).
- WAL (write-ahead log) for crash-safe persistence, with a chained CRC32
trailer per entry (each entry's CRC folds in the previous entry's CRC) so a
corrupted, reordered, duplicated, or spliced entry stops replay cleanly
@@ -40,6 +50,25 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
format (v2) is still fully readable; the oldest no-CRC format (v1) is only
reachable through the one-time migration path in `HDF5Memory::open`, not
through the public `WalFile::read_entries`.
**What the WAL guarantees:** integrity, ordering, and recovery from a
*process* crash at any point — including between a checkpoint and the WAL
truncate (each checkpoint records a `WalMark` in `/meta`, and `open()` skips
the WAL prefix the `.h5` already contains, so entries are never applied
twice). Checkpoints and snapshots are made durable as a unit (temp file
synced, renamed, directory synced). **What it does not guarantee:**
individual WAL appends are *not* fsynced (a deliberate latency trade-off), so
saves made since the last checkpoint can be lost on power failure or kernel
panic. Current header version is 4 (adds the `Update` record used by
`save_or_update`); v3 files are read and upgraded in place.
- A store has a **single writer**: `HDF5Memory::create`/`open` hold an exclusive
advisory lock on `<store>.h5.lock` and a second opener gets
`MemoryError::Locked`. Use `HDF5Memory::open_read_only` for a lock-free,
never-writing point-in-time view (the CLI's `recall`/`stats`/`agents-md`/
`export` do). An unreadable WAL (torn header, bad magic) is quarantined to
`<store>.h5.wal.corrupt-<ts>` rather than blocking `open()`; a WAL with an
unknown *newer* version still fails and is left untouched.
- `MemoryConfig::compression` uses deflate by default; enable the agent's
`zstd` feature to compress embeddings with Zstd instead (links libzstd).
- `Dataset::verify_provenance()` (clawhdf5 facade, `provenance` feature, on by
default) recomputes a dataset's SHA-256 and compares it against the
`_provenance_sha256` attribute written automatically on save when
+1 -1
View File
@@ -21,7 +21,7 @@ members = [
resolver = "2"
[workspace.package]
version = "2.2.0"
version = "2.4.0"
edition = "2024"
license = "MIT"
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-accel"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "SIMD-accelerated operations for rustyhdf5"
license = "MIT"
+5 -1
View File
@@ -111,7 +111,11 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
}
let denom = (norm_a * norm_b).sqrt();
if denom < f32::EPSILON { 0.0 } else { dot / denom }
if denom < f32::EPSILON {
0.0
} else {
dot / denom
}
}
}
+5 -1
View File
@@ -89,7 +89,11 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
}
let denom = (norm_a * norm_b).sqrt();
if denom < f32::EPSILON { 0.0 } else { dot / denom }
if denom < f32::EPSILON {
0.0
} else {
dot / denom
}
}
}
+7 -1
View File
@@ -61,8 +61,14 @@ pub enum Backend {
Scalar,
}
/// Detect the best available SIMD backend at runtime.
/// The best available SIMD backend, detected once per process. Every kernel
/// dispatches through this, so it sits in the innermost loop of every search.
pub fn detect_backend() -> Backend {
static BACKEND: std::sync::OnceLock<Backend> = std::sync::OnceLock::new();
*BACKEND.get_or_init(detect_backend_uncached)
}
fn detect_backend_uncached() -> Backend {
#[cfg(target_arch = "aarch64")]
{
return Backend::Neon; // Always available on aarch64
+5 -1
View File
@@ -94,7 +94,11 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
}
let denom = (norm_a * norm_b).sqrt();
if denom < f32::EPSILON { 0.0 } else { dot / denom }
if denom < f32::EPSILON {
0.0
} else {
dot / denom
}
}
/// NEON L2 distance.
+5 -1
View File
@@ -21,7 +21,11 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
norm_b += y * y;
}
let denom = (norm_a * norm_b).sqrt();
if denom < f32::EPSILON { 0.0 } else { dot / denom }
if denom < f32::EPSILON {
0.0
} else {
dot / denom
}
}
pub fn batch_cosine(query: &[f32], vectors: &[&[f32]], results: &mut [(usize, f32)]) {
+10 -7
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-agent"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "HDF5-backed persistent memory store for on-device AI agents"
license = "MIT"
@@ -10,12 +10,12 @@ keywords = ["agent", "memory", "hdf5", "vector-search", "embedding"]
categories = ["database", "science", "algorithms"]
[dependencies]
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0", features = ["parallel", "fast-checksum"] }
clawhdf5 = { path = "../clawhdf5", version = "2.2.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.2.0", features = ["mmap"] }
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.2.0" }
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.2.0", optional = true }
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.2.0", optional = true, default-features = false }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.4.0", features = ["parallel", "fast-checksum"] }
clawhdf5 = { path = "../clawhdf5", version = "2.4.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.4.0", features = ["mmap"] }
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.4.0" }
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.4.0", optional = true }
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.4.0", optional = true, default-features = false }
serde = { workspace = true }
byteorder = "1"
half = { workspace = true, optional = true }
@@ -48,6 +48,9 @@ harness = false
default = ["float16", "hnsw"]
float16 = ["half"]
parallel = ["rayon"]
# Compress embeddings with Zstd instead of deflate when
# `MemoryConfig::compression` is on. Off by default: it links libzstd (C).
zstd = ["clawhdf5/zstd"]
# HNSW approximate-nearest-neighbour acceleration for the vector stage of
# hybrid_search. On by default; the index is rebuilt from the cache on demand
# and stays self-consistent with the persisted memory store. Disable with
+16 -3
View File
@@ -483,7 +483,7 @@ fn rayon_benches(c: &mut Criterion) {
use rayon::prelude::*;
let query_norm = vector_search::compute_norm(&query);
let num_cores = rayon::current_num_threads().max(1);
let chunk_size = (n + num_cores - 1) / num_cores;
let chunk_size = n.div_ceil(num_cores);
let mut results: Vec<(usize, f32)> = vectors
.par_chunks(chunk_size)
.enumerate()
@@ -537,7 +537,7 @@ fn rayon_benches(c: &mut Criterion) {
use rayon::prelude::*;
let query_norm = vector_search::compute_norm(&query);
let num_cores = rayon::current_num_threads().max(1);
let chunk_size = (n + num_cores - 1) / num_cores;
let chunk_size = n.div_ceil(num_cores);
let mut results: Vec<(usize, f32)> = vectors
.par_chunks(chunk_size)
.enumerate()
@@ -766,12 +766,22 @@ fn adaptive_benches(c: &mut Criterion) {
.map(|v| vector_search::compute_norm(v))
.collect();
let tombstones = vec![0u8; n];
let flat: Vec<f32> = vectors.iter().flatten().copied().collect();
c.bench_function("adaptive_search_10k", |b| {
let hw = HardwareCapabilities::detect();
let strat = strategy::auto_select_strategy(n, &hw);
b.iter(|| {
strategy::search_with_metrics(&query, &vectors, &norms, &tombstones, 10, strat, None)
strategy::search_with_metrics(
&query,
&vectors,
&flat,
&norms,
&tombstones,
10,
strat,
None,
)
});
});
@@ -781,6 +791,7 @@ fn adaptive_benches(c: &mut Criterion) {
strategy::search_with_metrics(
&query,
&vectors,
&flat,
&norms,
&tombstones,
10,
@@ -795,6 +806,7 @@ fn adaptive_benches(c: &mut Criterion) {
strategy::search_with_metrics(
&query,
&vectors,
&flat,
&norms,
&tombstones,
10,
@@ -809,6 +821,7 @@ fn adaptive_benches(c: &mut Criterion) {
strategy::search_with_metrics(
&query,
&vectors,
&flat,
&norms,
&tombstones,
10,
+19 -6
View File
@@ -1,6 +1,7 @@
use clawhdf5_agent::bm25::BM25Index;
use clawhdf5_agent::consolidation::{
ConsolidationConfig, ConsolidationEngine, ImportanceScorer, ImportanceWeights, MemorySource,
UntrustedSource,
};
use clawhdf5_agent::hybrid::{hybrid_search, rrf_hybrid_search};
use clawhdf5_agent::knowledge::KnowledgeCache;
@@ -285,7 +286,12 @@ fn consolidation_benches(c: &mut Criterion) {
for i in 0..n {
let embedding = make_vec(&mut rng, DIM);
let chunk = format!("memory record {i} with some content");
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
engine.add_memory(
chunk,
embedding,
UntrustedSource::User,
now + i as f64,
);
}
engine
},
@@ -307,9 +313,10 @@ fn consolidation_benches(c: &mut Criterion) {
for i in 0..50usize {
let embedding = make_vec(&mut rng, DIM);
let chunk = format!("existing record {i}");
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
engine.add_memory(chunk, embedding, UntrustedSource::User, now + i as f64);
}
let records = engine.records().to_vec();
let record_refs: Vec<&_> = records.iter().collect();
let weights = ImportanceWeights::default();
let query_embedding = make_vec(&mut rng, DIM);
let sample_text =
@@ -317,7 +324,7 @@ fn consolidation_benches(c: &mut Criterion) {
group.bench_function("bench_importance_scoring", |b| {
b.iter(|| {
let surprise = ImportanceScorer::score_surprise(&query_embedding, &records);
let surprise = ImportanceScorer::score_surprise(&query_embedding, &record_refs);
let correction = ImportanceScorer::score_correction(&MemorySource::Correction);
let length = ImportanceScorer::score_length(sample_text);
ImportanceScorer::score_combined(surprise, correction, length, &weights)
@@ -354,7 +361,7 @@ fn temporal_benches(c: &mut Criterion) {
// Insert benchmark: measure time to insert 10k timestamps one by one
group.bench_function("bench_temporal_insert_10k", |b| {
b.iter_batched(
|| TemporalIndex::new(),
TemporalIndex::new,
|mut idx| {
for i in 0..N {
// Shuffle insertion order slightly using a simple offset pattern
@@ -442,7 +449,8 @@ fn large_consolidation_benches(c: &mut Criterion) {
let mut group = c.benchmark_group("consolidation_large");
group.sample_size(10);
for (label, n) in [("10k", 10_000usize)] {
{
let (label, n) = ("10k", 10_000usize);
group.bench_with_input(
BenchmarkId::new("bench_consolidation_cycle", label),
&n,
@@ -459,7 +467,12 @@ fn large_consolidation_benches(c: &mut Criterion) {
for i in 0..n {
let embedding = make_vec(&mut rng, DIM);
let chunk = format!("memory record {i} with content");
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
engine.add_memory(
chunk,
embedding,
UntrustedSource::User,
now + i as f64,
);
}
engine
},
+3
View File
@@ -0,0 +1,3 @@
target/
artifacts/
coverage/
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "clawhdf5-agent-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
tempfile = "3"
[dependencies.clawhdf5-agent]
path = ".."
[workspace]
members = ["."]
[[bin]]
name = "fuzz_wal_replay"
path = "fuzz_targets/fuzz_wal_replay.rs"
doc = false
@@ -0,0 +1,36 @@
#![no_main]
//! Arbitrary bytes as a WAL file. Reading, and opening for append (which scans
//! the chain and truncates an unverifiable tail), must never panic, hang, or
//! allocate without bound — and after `open` repairs the file, everything
//! `read_entries` returned before must still be returned.
//!
//! The deterministic counterpart that runs in ordinary CI is
//! `tests/wal_properties.rs`; this target explores inputs it cannot reach.
use std::io::Write as _;
use clawhdf5_agent::wal::WalFile;
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let Ok(mut tmp) = tempfile::NamedTempFile::new() else {
return;
};
if tmp.write_all(data).and_then(|()| tmp.flush()).is_err() {
return;
}
let before = WalFile::read_entries(tmp.path()).map(|e| e.len());
// Only the chained formats (header versions 3 and 4) are repaired in
// place. `open` deliberately recreates a legacy-format file from scratch:
// `HDF5Memory::open` has already replayed its entries by then.
let chained = matches!(data.get(4), Some(3 | 4));
let opened = WalFile::open(tmp.path());
if !chained {
return;
}
if let (Ok(before), Ok(wal)) = (before, opened) {
drop(wal);
let after = WalFile::read_entries(tmp.path()).map(|e| e.len());
assert_eq!(after.ok(), Some(before), "open() changed what is replayable");
}
});
+35 -3
View File
@@ -161,6 +161,9 @@ pub struct WriteEvent {
// WriteAnomalyDetector
// ---------------------------------------------------------------------------
/// Upper bound on distinct session ids the detector tracks at once.
const MAX_TRACKED_SESSIONS: usize = 4096;
/// Tracks write events and raises alerts for suspicious behaviour.
#[derive(Debug)]
pub struct WriteAnomalyDetector {
@@ -189,6 +192,23 @@ impl WriteAnomalyDetector {
if event.timestamp > self.last_timestamp {
self.last_timestamp = event.timestamp;
}
// Bound the per-session map: a long-lived process sees an unbounded
// number of distinct session ids. When it overflows, forget the
// sessions with the fewest writes (they are furthest from the limit
// this map exists to enforce); the current one is re-added below.
if self.session_counts.len() >= MAX_TRACKED_SESSIONS
&& !self.session_counts.contains_key(&event.session_id)
{
let mut counts: Vec<u32> = self.session_counts.values().copied().collect();
let keep_from = counts.len() / 2;
counts.select_nth_unstable(keep_from);
let threshold = counts[keep_from];
self.session_counts.retain(|_, c| *c >= threshold);
if self.session_counts.len() >= MAX_TRACKED_SESSIONS {
// Every session had the same count: drop them all.
self.session_counts.clear();
}
}
*self
.session_counts
.entry(event.session_id.clone())
@@ -437,7 +457,11 @@ mod tests {
fn rate_anomaly_names_offending_session() {
let mut det = WriteAnomalyDetector::new(cfg());
for i in 0..11 {
det.record_write(event(1.0 + i as f64 * 0.1, "flood-session", MemorySource::User));
det.record_write(event(
1.0 + i as f64 * 0.1,
"flood-session",
MemorySource::User,
));
}
let alert = det.check_rate_anomaly().unwrap();
assert!(
@@ -454,11 +478,19 @@ mod tests {
let mut det = WriteAnomalyDetector::new(cfg());
// 5 sessions with 1 write each (below any per-session limit)...
for i in 0..5 {
det.record_write(event(1.0 + i as f64 * 0.1, "minor-session", MemorySource::User));
det.record_write(event(
1.0 + i as f64 * 0.1,
"minor-session",
MemorySource::User,
));
}
// ...plus one session responsible for the majority of the flood.
for i in 0..8 {
det.record_write(event(2.0 + i as f64 * 0.1, "major-session", MemorySource::User));
det.record_write(event(
2.0 + i as f64 * 0.1,
"major-session",
MemorySource::User,
));
}
let alert = det.check_rate_anomaly().unwrap();
assert!(
@@ -408,6 +408,10 @@ impl AsyncHDF5Memory {
let (tx, rx) = oneshot::channel();
let _ = self.write_tx.send(WriteCmd::Shutdown(tx)).await;
let _ = rx.await;
// The writer task has stopped, so nothing can write through this
// handle any more: release the single-writer lock now rather than at
// drop, so the store can be reopened while `self` is still in scope.
self.inner.lock().await.release_store_lock();
Ok(())
}
}
+253 -114
View File
@@ -3,10 +3,15 @@
//! Provides a standard BM25 (Okapi BM25) implementation with an in-memory
//! inverted index. Tombstoned documents are excluded from indexing and search.
//!
//! Optimizations:
//! - Cached IDF scores (don't recompute per query)
//! - Sorted posting lists by doc_id for cache-friendly access
//! - Block-Max WAND early termination
//! The index is **incremental**: [`BM25Index::add_document`] and
//! [`BM25Index::remove_document`] keep it exactly equivalent to one built from
//! scratch over the same live documents, so a store can maintain one index for
//! its lifetime instead of re-tokenising the whole corpus per query. To make
//! that possible IDF is computed at query time (it depends on the live
//! document count) rather than cached at build time.
//!
//! - Posting lists sorted by doc id
//! - Bounded-heap top-k; results ordered by score, then doc id (deterministic)
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap};
@@ -41,10 +46,11 @@ const DEFAULT_B: f32 = 0.75;
pub struct BM25Index {
/// Inverted index: token -> sorted list of (doc_id, term_frequency).
inverted: HashMap<String, Vec<(usize, u32)>>,
/// Cached IDF scores per token.
idf_cache: HashMap<String, f32>,
/// Number of tokens in each document (0 for tombstoned docs).
doc_lengths: Vec<u32>,
/// Sum of `doc_lengths` over live documents (keeps `avg_dl` exact under
/// incremental updates).
total_length: u64,
/// Average document length across non-tombstoned docs.
avg_dl: f32,
/// Number of non-tombstoned documents.
@@ -60,8 +66,8 @@ impl BM25Index {
pub fn build(documents: &[String], tombstones: &[u8]) -> Self {
let mut index = Self {
inverted: HashMap::new(),
idf_cache: HashMap::new(),
doc_lengths: vec![0; documents.len()],
total_length: 0,
avg_dl: 0.0,
num_docs: 0,
k1: DEFAULT_K1,
@@ -77,107 +83,160 @@ impl BM25Index {
/// Uses Block-Max WAND for early termination when remaining documents
/// cannot beat the current top-k threshold.
pub fn search(&self, query: &str, k: usize) -> Vec<(usize, f32)> {
if self.num_docs == 0 || k == 0 {
if k == 0 {
return Vec::new();
}
let tokens = tokenize(query);
if tokens.is_empty() {
return Vec::new();
}
// Collect posting lists and cached IDF scores for query tokens
type QueryTerm<'a> = (&'a str, f32, &'a [(usize, u32)]);
let mut query_terms: Vec<QueryTerm<'_>> = Vec::new();
for token in &tokens {
if let (Some(postings), Some(&idf)) = (
self.inverted.get(token.as_str()),
self.idf_cache.get(token.as_str()),
) {
query_terms.push((token, idf, postings));
// Top-k with a bounded min-heap: O(matches * log k) instead of sorting
// every match. Ties break towards the lower doc id so results are
// deterministic.
let mut heap: BinaryHeap<Reverse<(HeapScore, Reverse<usize>)>> =
BinaryHeap::with_capacity(k.min(1024) + 1);
for (doc_id, score) in self.scores(query) {
heap.push(Reverse((HeapScore(score), Reverse(doc_id))));
if heap.len() > k {
heap.pop();
}
}
if query_terms.is_empty() {
return Vec::new();
}
// Accumulate BM25 scores per document using WAND-style scoring
let mut scores: HashMap<usize, f32> = HashMap::new();
// Compute maximum possible contribution per term for WAND
let max_tf_score: Vec<f32> = query_terms
.iter()
.map(|(_, idf, _)| {
// Upper bound: max TF contribution when tf is high and dl is short
let max_tf_num = 10.0 * (self.k1 + 1.0);
let max_tf_den = 10.0 + self.k1 * (1.0 - self.b);
idf * max_tf_num / max_tf_den
})
let mut results: Vec<(usize, f32)> = heap
.into_iter()
.map(|Reverse((HeapScore(score), Reverse(doc_id)))| (doc_id, score))
.collect();
results.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
results
}
let total_max_contribution: f32 = max_tf_score.iter().sum();
// Threshold for WAND early termination. `top_k_heap` is a min-heap of
// size k (worst-of-the-top-k at the head) so it can be maintained in
// O(log k) per update instead of re-sorting the whole buffer.
let mut threshold = 0.0f32;
let mut top_k_heap: BinaryHeap<Reverse<HeapScore>> = BinaryHeap::with_capacity(k);
for (term_idx, (_, idf, postings)) in query_terms.iter().enumerate() {
for &(doc_id, freq) in *postings {
/// The BM25 score of **every** matching document, in doc-id order, unsorted
/// by score. Score fusion normalises over the whole matching set, so it
/// needs all of these but not their ranking; producing a ranked list of
/// every match (`search(query, corpus_len)`) spent most of its time sorting.
pub fn scores(&self, query: &str) -> Vec<(usize, f32)> {
if self.num_docs == 0 {
return Vec::new();
}
// Term-at-a-time accumulation into a dense array: a common term has a
// posting per document, and hashing each one dominated query time.
// IDF is computed here rather than cached at build time: it depends on
// the live document count, which changes with every incremental
// add/remove, and costs one `ln` per query term.
let mut acc = vec![0.0f32; self.doc_lengths.len()];
let mut matched = false;
for token in tokenize(query) {
let Some(postings) = self.inverted.get(token.as_str()) else {
continue;
};
matched = true;
let df = postings.len() as f32;
let idf = ((self.num_docs as f32 - df + 0.5) / (df + 0.5) + 1.0).ln();
for &(doc_id, freq) in postings {
let dl = self.doc_lengths[doc_id] as f32;
let freq_f = freq as f32;
let tf = (freq_f * (self.k1 + 1.0))
/ (freq_f + self.k1 * (1.0 - self.b + self.b * dl / self.avg_dl));
let contribution = idf * tf;
let entry = scores.entry(doc_id).or_insert(0.0);
*entry += contribution;
// WAND check: if this doc's current partial score + remaining
// max terms can't beat threshold, we can skip (but we still
// accumulate since we process term-at-a-time)
if term_idx == query_terms.len() - 1 {
// Last term: check if this doc beats threshold
let final_score = *entry;
if top_k_heap.len() >= k {
if final_score > threshold {
// Replace the current worst-of-top-k.
top_k_heap.pop();
top_k_heap.push(Reverse(HeapScore(final_score)));
threshold = top_k_heap.peek().map(|Reverse(s)| s.0).unwrap_or(0.0);
acc[doc_id] += idf * tf;
}
}
if !matched {
return Vec::new();
}
// Every contribution is strictly positive (idf = ln(1 + x), x > 0), so
// a zero entry is a document no query term touched.
acc.into_iter()
.enumerate()
.filter(|&(_, score)| score > 0.0)
.collect()
}
/// Number of document slots (live or not) the index covers. Ids are
/// positions in the document list it mirrors.
pub fn len(&self) -> usize {
self.doc_lengths.len()
}
/// `true` when the index covers no document slots.
pub fn is_empty(&self) -> bool {
self.doc_lengths.is_empty()
}
/// Index `text` as document `doc_id`, which must be the next free id
/// (`self.len()`) or an existing slot that is currently empty (removed or
/// tombstoned). After any sequence of `add_document` / `remove_document`
/// calls the index scores exactly as one freshly built from the same live
/// documents.
pub fn add_document(&mut self, doc_id: usize, text: &str) {
if doc_id >= self.doc_lengths.len() {
self.doc_lengths.resize(doc_id + 1, 0);
}
debug_assert_eq!(self.doc_lengths[doc_id], 0, "slot {doc_id} is occupied");
let tokens = tokenize(text);
let mut term_freqs: HashMap<&str, u32> = HashMap::new();
for token in &tokens {
*term_freqs.entry(token).or_insert(0) += 1;
}
for (token, freq) in term_freqs {
let postings = self.inverted.entry(token.to_string()).or_default();
// Posting lists stay sorted by doc id; appends are the common case.
match postings.last() {
Some(&(last, _)) if last >= doc_id => {
let at = postings.partition_point(|&(id, _)| id < doc_id);
postings.insert(at, (doc_id, freq));
}
_ => postings.push((doc_id, freq)),
}
}
self.doc_lengths[doc_id] = tokens.len() as u32;
self.total_length += tokens.len() as u64;
self.num_docs += 1;
self.refresh_avg_dl();
}
/// Extend the index to cover `len` document slots, leaving new ones empty.
/// Used for slots that hold no live document (tombstoned records).
pub fn pad_to(&mut self, len: usize) {
if len > self.doc_lengths.len() {
self.doc_lengths.resize(len, 0);
}
}
/// Remove document `doc_id`, whose indexed text was `text`. The text is
/// needed to find its postings; pass exactly what was added.
pub fn remove_document(&mut self, doc_id: usize, text: &str) {
let tokens = tokenize(text);
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for token in &tokens {
if !seen.insert(token) {
continue;
}
if let Some(postings) = self.inverted.get_mut(token.as_str()) {
if let Ok(at) = postings.binary_search_by_key(&doc_id, |&(id, _)| id) {
postings.remove(at);
}
if postings.is_empty() {
self.inverted.remove(token.as_str());
}
}
}
if let Some(len) = self.doc_lengths.get_mut(doc_id) {
self.total_length = self.total_length.saturating_sub(u64::from(*len));
*len = 0;
}
self.num_docs = self.num_docs.saturating_sub(1);
self.refresh_avg_dl();
}
fn refresh_avg_dl(&mut self) {
self.avg_dl = if self.num_docs > 0 {
self.total_length as f32 / self.num_docs as f32
} else {
top_k_heap.push(Reverse(HeapScore(final_score)));
if top_k_heap.len() == k {
threshold = top_k_heap.peek().map(|Reverse(s)| s.0).unwrap_or(0.0);
}
}
}
}
// After processing each term, check if remaining terms can
// possibly produce results above threshold
let remaining_max: f32 = max_tf_score[term_idx + 1..].iter().sum();
if remaining_max < threshold && total_max_contribution > 0.0 {
// Early termination: remaining terms can't produce new top-k
// entries on their own. But existing partial scores may still
// be updated, so we continue (WAND is approximate here).
let _ = remaining_max; // hint to compiler
}
}
let mut results: Vec<(usize, f32)> = scores.into_iter().collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
results.truncate(k);
results
0.0
};
}
/// Rebuild the index from scratch (e.g., after compaction).
pub fn rebuild(&mut self, documents: &[String], tombstones: &[u8]) {
self.inverted.clear();
self.idf_cache.clear();
self.doc_lengths = vec![0; documents.len()];
self.total_length = 0;
self.avg_dl = 0.0;
self.num_docs = 0;
self.index_documents(documents, tombstones);
@@ -214,23 +273,13 @@ impl BM25Index {
}
self.num_docs = count;
self.avg_dl = if count > 0 {
total_length as f32 / count as f32
} else {
0.0
};
self.total_length = total_length;
self.refresh_avg_dl();
// Sort posting lists by doc_id for cache-friendly access
for postings in self.inverted.values_mut() {
postings.sort_by_key(|&(doc_id, _)| doc_id);
}
// Pre-compute and cache IDF scores
for (token, postings) in &self.inverted {
let df = postings.len() as f32;
let idf = ((self.num_docs as f32 - df + 0.5) / (df + 0.5) + 1.0).ln();
self.idf_cache.insert(token.clone(), idf);
}
}
}
@@ -386,24 +435,21 @@ mod tests {
}
#[test]
fn cached_idf_consistent_with_computed() {
fn score_matches_the_bm25_formula() {
let docs = vec![
"rust programming".to_string(),
"rust systems".to_string(),
"python scripting".to_string(),
];
let tombstones = vec![0, 0, 0];
let index = BM25Index::build(&docs, &tombstones);
let index = BM25Index::build(&docs, &[0, 0, 0]);
// IDF for "rust" (appears in 2 of 3 docs)
let idf_rust = index.idf_cache.get("rust").unwrap();
let expected_idf = ((3.0f32 - 2.0 + 0.5) / (2.0 + 0.5) + 1.0).ln();
assert!(
(idf_rust - expected_idf).abs() < 1e-6,
"cached IDF mismatch: {} vs {}",
idf_rust,
expected_idf
);
// "python": df = 1 of N = 3. Every doc has the average length (2) and
// tf = 1, so the tf factor is exactly 1 and the score is the IDF.
let results = index.search("python", 3);
let expected_idf = ((3.0f32 - 1.0 + 0.5) / (1.0 + 0.5) + 1.0).ln();
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 2);
assert!((results[0].1 - expected_idf).abs() < 1e-6, "{results:?}");
}
#[test]
@@ -467,4 +513,97 @@ mod tests {
);
}
}
/// Documents drawn from a small vocabulary so terms collide heavily.
fn random_doc(state: &mut u64) -> String {
const VOCAB: &[&str] = &[
"alpha", "beta", "gamma", "delta", "eps", "zeta", "eta", "x1",
];
let mut next = || {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(*state >> 33) as usize
};
let len = 1 + next() % 9;
(0..len)
.map(|_| VOCAB[next() % VOCAB.len()])
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn incremental_updates_match_a_fresh_build_exactly() {
for seed in 0..60u64 {
let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
let mut docs: Vec<String> = Vec::new();
let mut tombstones: Vec<u8> = Vec::new();
let mut index = BM25Index::build(&docs, &tombstones);
for step in 0..80 {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
let live: Vec<usize> = (0..docs.len()).filter(|&i| tombstones[i] == 0).collect();
match (state >> 40) % 4 {
0 if !live.is_empty() => {
// delete
let id = live[(state >> 20) as usize % live.len()];
index.remove_document(id, &docs[id]);
tombstones[id] = 1;
}
1 if !live.is_empty() => {
// update in place
let id = live[(state >> 20) as usize % live.len()];
let new_text = random_doc(&mut state);
index.remove_document(id, &docs[id]);
index.add_document(id, &new_text);
docs[id] = new_text;
}
_ => {
let text = random_doc(&mut state);
index.add_document(docs.len(), &text);
docs.push(text);
tombstones.push(0);
}
}
let fresh = BM25Index::build(&docs, &tombstones);
for query in ["alpha", "beta gamma", "x1 zeta alpha delta", "missing"] {
let got = index.search(query, 5);
let want = fresh.search(query, 5);
assert_eq!(got.len(), want.len(), "seed {seed} step {step} {query:?}");
for (g, w) in got.iter().zip(&want) {
assert_eq!(
g.0, w.0,
"seed {seed} step {step} {query:?}: {got:?} vs {want:?}"
);
assert!(
(g.1 - w.1).abs() < 1e-5,
"seed {seed} step {step} {query:?}"
);
}
}
}
}
}
#[test]
fn scores_is_the_unranked_form_of_a_full_search() {
let mut state = 99u64;
let docs: Vec<String> = (0..200).map(|_| random_doc(&mut state)).collect();
let tombstones: Vec<u8> = (0..200).map(|i| u8::from(i % 7 == 0)).collect();
let index = BM25Index::build(&docs, &tombstones);
for query in ["alpha", "beta gamma x1", "missing", ""] {
let mut all = index.scores(query);
all.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
assert_eq!(all, index.search(query, docs.len()), "{query:?}");
assert!(all.iter().all(|(id, _)| tombstones[*id] == 0));
}
}
#[test]
fn ties_break_towards_the_lower_doc_id() {
let docs: Vec<String> = (0..6).map(|_| "same text".to_string()).collect();
let index = BM25Index::build(&docs, &[0; 6]);
let ids: Vec<usize> = index.search("same", 3).into_iter().map(|r| r.0).collect();
assert_eq!(ids, [0, 1, 2]);
}
}
+20 -14
View File
@@ -563,7 +563,7 @@ mod tests {
#[test]
fn test_importance_scorer_surprise_identical() {
let emb = unit_vec(4, 0);
let existing = vec![MemoryRecord {
let existing = [MemoryRecord {
id: 0,
chunk: "existing".to_string(),
embedding: emb.clone(),
@@ -603,23 +603,20 @@ mod tests {
fn test_importance_scorer_length() {
assert!((ImportanceScorer::score_length("")).abs() < f32::EPSILON);
// 50 words → 0.5
let fifty_words = std::iter::repeat("word")
.take(50)
let fifty_words = std::iter::repeat_n("word", 50)
.collect::<Vec<_>>()
.join(" ");
let s50 = ImportanceScorer::score_length(&fifty_words);
assert!((s50 - 0.5).abs() < 1e-5, "expected 0.5, got {s50}");
// 100 words → 1.0
let hundred_words = std::iter::repeat("word")
.take(100)
let hundred_words = std::iter::repeat_n("word", 100)
.collect::<Vec<_>>()
.join(" ");
assert_eq!(ImportanceScorer::score_length(&hundred_words), 1.0);
// 200 words → still 1.0 (clamped)
let two_hundred = std::iter::repeat("word")
.take(200)
let two_hundred = std::iter::repeat_n("word", 200)
.collect::<Vec<_>>()
.join(" ");
assert_eq!(ImportanceScorer::score_length(&two_hundred), 1.0);
@@ -693,9 +690,11 @@ mod tests {
// ---------------------------------------------------------------------------
#[test]
fn test_consolidate_eviction_working() {
let mut cfg = ConsolidationConfig::default();
cfg.working_capacity = 3;
cfg.working_to_episodic_threshold = 2.0; // never promote in this test
let cfg = ConsolidationConfig {
working_capacity: 3,
working_to_episodic_threshold: 2.0, // never promote in this test
..Default::default()
};
let mut engine = ConsolidationEngine::new(cfg);
// Add 5 records; all have very low importance so none get promoted.
@@ -800,7 +799,12 @@ mod tests {
#[test]
fn test_access_memory_reactivation() {
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
let id = engine.add_memory("chunk".to_string(), unit_vec(4, 0), UntrustedSource::User, 0.0);
let id = engine.add_memory(
"chunk".to_string(),
unit_vec(4, 0),
UntrustedSource::User,
0.0,
);
engine.access_memory(id, 5000.0);
let rec = engine.get_by_id(id).unwrap();
@@ -853,9 +857,11 @@ mod tests {
// ---------------------------------------------------------------------------
#[test]
fn test_consolidate_episodic_eviction() {
let mut cfg = ConsolidationConfig::default();
cfg.episodic_capacity = 3;
cfg.working_to_episodic_threshold = 2.0; // never auto-promote from Working
let cfg = ConsolidationConfig {
episodic_capacity: 3,
working_to_episodic_threshold: 2.0, // never auto-promote from Working
..Default::default()
};
let mut engine = ConsolidationEngine::new(cfg);
// Seed 5 records directly in Episodic.
+14 -8
View File
@@ -777,8 +777,10 @@ mod tests {
#[test]
fn test_tech_disabled() {
let mut config = ExtractorConfig::default();
config.extract_technology = false;
let config = ExtractorConfig {
extract_technology: false,
..Default::default()
};
let e = EntityExtractor::new(config);
let entities = e.extract("We use Rust and Docker.");
assert!(
@@ -847,8 +849,10 @@ mod tests {
#[test]
fn test_date_disabled() {
let mut config = ExtractorConfig::default();
config.extract_dates = false;
let config = ExtractorConfig {
extract_dates: false,
..Default::default()
};
let e = EntityExtractor::new(config);
let entities = e.extract("Released on 2024-03-19.");
assert!(
@@ -981,8 +985,10 @@ mod tests {
#[test]
fn test_confidence_filter() {
let mut config = ExtractorConfig::default();
config.min_confidence = 0.95;
let config = ExtractorConfig {
min_confidence: 0.95,
..Default::default()
};
let e = EntityExtractor::new(config);
// Only dates (0.95) and techs (0.9) should survive; 0.9 < 0.95 filters techs.
let entities = e.extract("We use Rust since 2024-01-01.");
@@ -1002,7 +1008,7 @@ mod tests {
fn test_batch_dedup() {
let e = default_extractor();
let texts = ["We use Rust.", "Rust is fast.", "Also Rust for safety."];
let entities = e.extract_batch(&texts.iter().map(|s| *s).collect::<Vec<_>>());
let entities = e.extract_batch(&texts);
let rust_count = entities.iter().filter(|x| x.text == "Rust").count();
assert_eq!(rust_count, 1, "Rust should appear exactly once after dedup");
}
@@ -1011,7 +1017,7 @@ mod tests {
fn test_batch_multiple_types() {
let e = default_extractor();
let texts = ["Deploy with Docker.", "We merged last week."];
let entities = e.extract_batch(&texts.iter().map(|s| *s).collect::<Vec<_>>());
let entities = e.extract_batch(&texts);
assert!(
entities
.iter()
+56 -6
View File
@@ -58,7 +58,7 @@ pub fn hybrid_search(
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
}
};
let kw_scores = bm25_index.search(query_text, vectors.len());
let kw_scores = bm25_index.scores(query_text);
merge_vector_keyword(vec_scores, kw_scores, vector_weight, keyword_weight, k)
}
@@ -91,14 +91,31 @@ pub fn merge_vector_keyword(
}
let mut results: Vec<(usize, f32)> = merged.into_iter().collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
// Index tie-break: `merged` is a HashMap, so without it the ties that
// survive differ from run to run.
let by_score_then_id = |a: &(usize, f32), b: &(usize, f32)| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
};
// Only the top k are wanted: partition them out, then order just those,
// instead of sorting every candidate (the keyword side can be the corpus).
if k == 0 {
return Vec::new();
}
if results.len() > k {
results.select_nth_unstable_by(k - 1, by_score_then_id);
results.truncate(k);
}
results.sort_by(by_score_then_id);
results
}
/// Normalize a set of scores to the [0, 1] range using min-max normalization.
///
/// If all scores are identical, returns 0.0 for each entry.
/// If all scores are identical there is no spread to normalise: each entry
/// gets 1.0 when that score is positive (all equally the best match) and 0.0
/// otherwise (nothing matched).
fn normalize_scores(scores: &[(usize, f32)]) -> Vec<(usize, f32)> {
if scores.is_empty() {
return Vec::new();
@@ -112,7 +129,13 @@ fn normalize_scores(scores: &[(usize, f32)]) -> Vec<(usize, f32)> {
let range = max - min;
if range == 0.0 {
return scores.iter().map(|(idx, _)| (*idx, 0.0)).collect();
// All candidates scored the same (including the single-candidate
// case), so min-max has no spread to work with. They are all equally
// the best match if that score is positive, and all non-matches
// otherwise. This used to return 0.0 unconditionally, which erased a
// lone perfect match from the fused score.
let level = if max > 0.0 { 1.0 } else { 0.0 };
return scores.iter().map(|(idx, _)| (*idx, level)).collect();
}
scores
@@ -324,10 +347,37 @@ mod tests {
#[test]
fn normalize_scores_single() {
// A lone positive score is the best match there is, not a non-match.
let result = normalize_scores(&[(0, 5.0)]);
assert_eq!(result.len(), 1);
// Single score normalizes to 0.0 (range is 0)
assert_eq!(result[0].1, 0.0);
assert_eq!(result[0].1, 1.0);
}
#[test]
fn merge_top_k_matches_a_full_sort() {
// Many ties (scores repeat) so the index tie-break is exercised.
let vec_scores: Vec<(usize, f32)> = (0..300).map(|i| (i, ((i * 7) % 13) as f32)).collect();
let kw_scores: Vec<(usize, f32)> = (100..500).map(|i| (i, ((i * 5) % 11) as f32)).collect();
let everything =
merge_vector_keyword(vec_scores.clone(), kw_scores.clone(), 0.7, 0.3, 10_000);
assert_eq!(everything.len(), 500);
assert!(
everything
.windows(2)
.all(|w| { w[0].1 > w[1].1 || (w[0].1 == w[1].1 && w[0].0 < w[1].0) })
);
for k in [0, 1, 7, 50, 499, 500, 501] {
let top = merge_vector_keyword(vec_scores.clone(), kw_scores.clone(), 0.7, 0.3, k);
assert_eq!(top, everything[..k.min(500)], "k = {k}");
}
}
#[test]
fn normalize_scores_all_equal() {
let matched = normalize_scores(&[(0, 0.4), (1, 0.4)]);
assert!(matched.iter().all(|(_, s)| *s == 1.0));
let unmatched = normalize_scores(&[(0, 0.0), (1, 0.0)]);
assert!(unmatched.iter().all(|(_, s)| *s == 0.0));
}
#[test]
File diff suppressed because it is too large Load Diff
+63 -63
View File
@@ -748,6 +748,69 @@ impl MemoryBackend for ClawhdfBackend {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Ephemeral tier methods on ClawhdfBackend
// ─────────────────────────────────────────────────────────────────────────────
impl ClawhdfBackend {
/// Enable the ephemeral (in-memory only) working memory tier.
pub fn enable_ephemeral(&mut self, config: crate::ephemeral::EphemeralConfig) {
self.memory.enable_ephemeral(config);
}
/// Store a text value in ephemeral memory.
///
/// Returns an error string if the ephemeral tier has not been enabled.
pub fn ephemeral_set(
&mut self,
key: &str,
value: &str,
ttl_secs: Option<f64>,
) -> Result<(), String> {
match self.memory.ephemeral_mut() {
Some(s) => {
s.set_text(key, value, ttl_secs);
Ok(())
}
None => Err("ephemeral tier not enabled".to_string()),
}
}
/// Retrieve a text value from ephemeral memory.
///
/// Returns `None` if the tier is disabled, the key is absent, or the
/// entry has expired.
pub fn ephemeral_get(&mut self, key: &str) -> Option<String> {
self.memory
.ephemeral_mut()?
.get_text(key)
.map(|s| s.to_string())
}
/// Delete a key from ephemeral memory.
///
/// Returns `true` if the key existed and was removed.
pub fn ephemeral_delete(&mut self, key: &str) -> bool {
self.memory.ephemeral_mut().is_some_and(|s| s.delete(key))
}
/// Return a snapshot of ephemeral tier statistics, or `None` if the tier
/// is not enabled.
pub fn ephemeral_stats(&self) -> Option<crate::ephemeral::EphemeralStats> {
self.memory.ephemeral().map(|s| s.stats())
}
/// Promote frequently-accessed ephemeral entries to persistent HDF5 storage.
///
/// Entries with `access_count >= min_access_count` are moved from the
/// ephemeral store into the persistent cache. Returns the count promoted.
pub fn promote_ephemeral(&mut self, min_access_count: u32) -> Result<usize, String> {
self.memory
.promote_ephemeral(min_access_count)
.map_err(|e| e.to_string())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
@@ -1333,66 +1396,3 @@ mod tests {
assert!(out.starts_with("# Title"));
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Ephemeral tier methods on ClawhdfBackend
// ─────────────────────────────────────────────────────────────────────────────
impl ClawhdfBackend {
/// Enable the ephemeral (in-memory only) working memory tier.
pub fn enable_ephemeral(&mut self, config: crate::ephemeral::EphemeralConfig) {
self.memory.enable_ephemeral(config);
}
/// Store a text value in ephemeral memory.
///
/// Returns an error string if the ephemeral tier has not been enabled.
pub fn ephemeral_set(
&mut self,
key: &str,
value: &str,
ttl_secs: Option<f64>,
) -> Result<(), String> {
match self.memory.ephemeral_mut() {
Some(s) => {
s.set_text(key, value, ttl_secs);
Ok(())
}
None => Err("ephemeral tier not enabled".to_string()),
}
}
/// Retrieve a text value from ephemeral memory.
///
/// Returns `None` if the tier is disabled, the key is absent, or the
/// entry has expired.
pub fn ephemeral_get(&mut self, key: &str) -> Option<String> {
self.memory
.ephemeral_mut()?
.get_text(key)
.map(|s| s.to_string())
}
/// Delete a key from ephemeral memory.
///
/// Returns `true` if the key existed and was removed.
pub fn ephemeral_delete(&mut self, key: &str) -> bool {
self.memory.ephemeral_mut().is_some_and(|s| s.delete(key))
}
/// Return a snapshot of ephemeral tier statistics, or `None` if the tier
/// is not enabled.
pub fn ephemeral_stats(&self) -> Option<crate::ephemeral::EphemeralStats> {
self.memory.ephemeral().map(|s| s.stats())
}
/// Promote frequently-accessed ephemeral entries to persistent HDF5 storage.
///
/// Entries with `access_count >= min_access_count` are moved from the
/// ephemeral store into the persistent cache. Returns the count promoted.
pub fn promote_ephemeral(&mut self, min_access_count: u32) -> Result<usize, String> {
self.memory
.promote_ephemeral(min_access_count)
.map_err(|e| e.to_string())
}
}
+17
View File
@@ -105,6 +105,23 @@ impl ProvenanceStore {
self.records.insert(provenance.record_id, provenance);
}
/// Renumber records after the store was compacted. `index_map[old]` is
/// the record's new id, or `None` if it was removed. Without this, every
/// surviving record's hash ends up filed under some other record's id and
/// the next integrity check reports a bogus mismatch.
pub fn remap(&mut self, index_map: &[Option<usize>]) {
let old = std::mem::take(&mut self.records);
for (old_id, mut prov) in old {
let new_id = usize::try_from(old_id)
.ok()
.and_then(|i| index_map.get(i).copied().flatten());
if let Some(new_id) = new_id {
prov.record_id = new_id as u64;
self.records.insert(new_id as u64, prov);
}
}
}
/// Retrieve by record ID.
pub fn get(&self, record_id: u64) -> Option<&MemoryProvenance> {
self.records.get(&record_id)
+301 -18
View File
@@ -12,10 +12,18 @@ use crate::MemoryError;
use crate::cache::MemoryCache;
use crate::knowledge::KnowledgeCache;
use crate::session::SessionCache;
use crate::wal::WalMark;
pub const SCHEMA_VERSION: &str = "1.0";
pub const ZEROCLAW_VERSION: &str = "0.8.0";
/// `/meta` attributes holding the [`WalMark`] of the WAL prefix already folded
/// into this file. Absent on files written before the mark existed, and when
/// the checkpoint was taken with an empty WAL.
const WAL_APPLIED_LEN_ATTR: &str = "wal_applied_len";
const WAL_APPLIED_CRC_ATTR: &str = "wal_applied_crc";
const ANN_GENERATION_ATTR: &str = "ann_generation";
/// Build a complete HDF5 file from the in-memory state.
pub fn build_hdf5_file(
config: &MemoryConfig,
@@ -23,6 +31,47 @@ pub fn build_hdf5_file(
sessions: &SessionCache,
knowledge: &KnowledgeCache,
) -> Result<Vec<u8>, MemoryError> {
build_hdf5_file_with_mark(config, cache, sessions, knowledge, None)
}
/// [`build_hdf5_file`], recording which WAL prefix this state already
/// contains (see [`WalMark`]) so a crash before the WAL is truncated doesn't
/// replay those entries a second time.
pub fn build_hdf5_file_with_mark(
config: &MemoryConfig,
cache: &MemoryCache,
sessions: &SessionCache,
knowledge: &KnowledgeCache,
wal_applied: Option<WalMark>,
) -> Result<Vec<u8>, MemoryError> {
let meta = CheckpointMeta {
wal_applied,
ann_generation: None,
};
build_hdf5_file_with_meta(config, cache, sessions, knowledge, &meta)
}
/// Bookkeeping a checkpoint records in `/meta` beside the store's contents.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CheckpointMeta {
/// The WAL prefix this checkpoint already contains; see [`WalMark`].
pub wal_applied: Option<WalMark>,
/// Identifies the vector-index sidecar (`<store>.h5.ann`) written with this
/// checkpoint. A sidecar is loaded only if it carries the same value, so
/// one left over from another checkpoint can never be attached to records
/// it wasn't built from.
pub ann_generation: Option<u64>,
}
/// [`build_hdf5_file`] with checkpoint bookkeeping.
pub fn build_hdf5_file_with_meta(
config: &MemoryConfig,
cache: &MemoryCache,
sessions: &SessionCache,
knowledge: &KnowledgeCache,
checkpoint: &CheckpointMeta,
) -> Result<Vec<u8>, MemoryError> {
let wal_applied = checkpoint.wal_applied;
let mut builder = clawhdf5::FileBuilder::new();
// /meta group with schema attributes
@@ -34,10 +83,40 @@ pub fn build_hdf5_file(
meta.set_attr("embedding_dim", AttrValue::I64(config.embedding_dim as i64));
meta.set_attr("chunk_size", AttrValue::I64(config.chunk_size as i64));
meta.set_attr("overlap", AttrValue::I64(config.overlap as i64));
// Behavioural settings. These used to live only in memory, so reopening a
// store silently reset them to defaults — e.g. a compressed store was
// rewritten uncompressed by the first checkpoint after a reopen. Loaders
// treat each one as optional so older files keep opening.
meta.set_attr("float16", AttrValue::I64(config.float16.into()));
meta.set_attr("compression", AttrValue::I64(config.compression.into()));
meta.set_attr(
"compression_level",
AttrValue::I64(config.compression_level.into()),
);
meta.set_attr(
"compact_threshold",
AttrValue::F64(config.compact_threshold.into()),
);
meta.set_attr("hebbian_boost", AttrValue::F64(config.hebbian_boost.into()));
meta.set_attr("decay_factor", AttrValue::F64(config.decay_factor.into()));
meta.set_attr("wal_enabled", AttrValue::I64(config.wal_enabled.into()));
meta.set_attr(
"wal_max_entries",
AttrValue::I64(config.wal_max_entries as i64),
);
meta.set_attr(
"edgehdf5_version",
AttrValue::String(ZEROCLAW_VERSION.into()),
);
if let Some(mark) = wal_applied.filter(|m| m.len > 0) {
meta.set_attr(WAL_APPLIED_LEN_ATTR, AttrValue::I64(mark.len as i64));
meta.set_attr(WAL_APPLIED_CRC_ATTR, AttrValue::I64(i64::from(mark.crc)));
}
if let Some(generation) = checkpoint.ann_generation {
// Stored as the i64 with the same bits; attributes have no u64 scalar
// round trip through every reader.
meta.set_attr(ANN_GENERATION_ATTR, AttrValue::I64(generation as i64));
}
// Need at least one dataset in the group for it to be a proper group
meta.create_dataset("_marker").with_u8_data(&[1]).compact();
let finished_meta = meta.finish();
@@ -83,16 +162,34 @@ fn build_memory_group(
let rows_per_chunk = (target_chunk_bytes / (d * 4)).max(1).min(n);
ds.with_chunks(&[rows_per_chunk, d]);
// Compression: Zstd for embeddings — faster than deflate at same ratio.
// Shuffle is applied automatically (auto-shuffle pre-filter).
// Compression. Shuffle is applied automatically (auto-shuffle
// pre-filter). Zstd is faster than deflate at the same ratio but
// pulls in libzstd, so it is opt-in via the `zstd` feature; the
// default build uses deflate, which is always available. (This
// used to call `with_zstd` unconditionally, so without the
// feature every checkpoint of a compressed store failed with
// "unsupported filter: 32015".) Both are standard HDF5 filters;
// reading a zstd-compressed store needs a zstd-enabled build.
if config.compression {
#[cfg(feature = "zstd")]
{
let level = if config.compression_level > 0 {
config.compression_level.min(22)
} else {
3 // Zstd level 3: fast + good ratio for f32 embeddings
3 // fast + good ratio for f32 embeddings
};
ds.with_zstd(level);
}
#[cfg(not(feature = "zstd"))]
{
let level = if config.compression_level > 0 {
config.compression_level.min(9)
} else {
4
};
ds.with_deflate(level);
}
}
}
// Skip fill-value initialization — embeddings are fully written
@@ -309,6 +406,36 @@ fn write_string_dataset(
}
/// Validate an HDF5 file has the correct schema and load all data.
/// Read the checkpoint's [`WalMark`] from `/meta`, if it has one.
pub fn read_wal_mark(file: &clawhdf5::File) -> Option<WalMark> {
let attrs = file.group("meta").ok()?.attrs().ok()?;
let len = match attrs.get(WAL_APPLIED_LEN_ATTR)? {
AttrValue::I64(v) => u64::try_from(*v).ok()?,
_ => return None,
};
let crc = match attrs.get(WAL_APPLIED_CRC_ATTR)? {
AttrValue::I64(v) => u32::try_from(*v).ok()?,
_ => return None,
};
Some(WalMark { len, crc })
}
/// Read the checkpoint bookkeeping from `/meta`.
pub fn read_checkpoint_meta(file: &clawhdf5::File) -> CheckpointMeta {
let ann_generation = file
.group("meta")
.ok()
.and_then(|g| g.attrs().ok())
.and_then(|attrs| match attrs.get(ANN_GENERATION_ATTR) {
Some(AttrValue::I64(v)) => Some(*v as u64),
_ => None,
});
CheckpointMeta {
wal_applied: read_wal_mark(file),
ann_generation,
}
}
pub fn validate_and_load(
file: &clawhdf5::File,
) -> Result<(MemoryConfig, MemoryCache, SessionCache, KnowledgeCache), MemoryError> {
@@ -344,15 +471,19 @@ pub fn validate_and_load(
embedding_dim,
chunk_size,
overlap,
float16: false,
compression: false,
compression_level: 0,
compact_threshold: 0.3,
hebbian_boost: 0.15,
decay_factor: 0.98,
float16: optional_bool_attr(&attrs, "float16", false),
compression: optional_bool_attr(&attrs, "compression", false),
compression_level: optional_i64_attr(&attrs, "compression_level")
.and_then(|v| u32::try_from(v).ok())
.unwrap_or(0),
compact_threshold: optional_f32_attr(&attrs, "compact_threshold", 0.3),
hebbian_boost: optional_f32_attr(&attrs, "hebbian_boost", 0.15),
decay_factor: optional_f32_attr(&attrs, "decay_factor", 0.98),
created_at,
wal_enabled: true,
wal_max_entries: 500,
wal_enabled: optional_bool_attr(&attrs, "wal_enabled", true),
wal_max_entries: optional_i64_attr(&attrs, "wal_max_entries")
.and_then(|v| usize::try_from(v).ok())
.unwrap_or(500),
};
// Load /memory group
@@ -391,19 +522,45 @@ fn load_memory_group(
let tags = read_string_dataset_from_group(&group, "tags")?;
let tombstones = read_u8_dataset(&group, "tombstones")?;
// Read norms if present, otherwise compute from embeddings
// Every per-record dataset must describe exactly `n` records. Without
// this, a truncated or hand-edited file loads "successfully" and then
// panics on the first out-of-bounds index during search/delete.
if embedding_dim == 0 {
return Err(MemoryError::Schema(format!(
"/memory has {n} records but embedding_dim is 0"
)));
}
let expected_flat = n.checked_mul(embedding_dim).ok_or_else(|| {
MemoryError::Schema(format!("/memory size overflow: {n} x {embedding_dim}"))
})?;
let check_len = |name: &str, actual: usize, expected: usize| {
if actual == expected {
Ok(())
} else {
Err(MemoryError::Schema(format!(
"/memory/{name} has {actual} entries, expected {expected} \
({n} records)"
)))
}
};
check_len("embeddings", flat_embeddings.len(), expected_flat)?;
check_len("source_channel", source_channels.len(), n)?;
check_len("timestamps", timestamps.len(), n)?;
check_len("session_ids", session_ids.len(), n)?;
check_len("tags", tags.len(), n)?;
check_len("tombstones", tombstones.len(), n)?;
// Norms are derived data: use the stored ones only if they are present
// and the right length, otherwise recompute from the embeddings.
let norms = match read_f32_dataset(&group, "norms") {
Ok(n) if n.len() == n.len() => n,
_ => {
// Compute norms from flat embeddings
flat_embeddings
Ok(stored) if stored.len() == n => stored,
_ => flat_embeddings
.chunks(embedding_dim)
.map(|chunk| {
let sq_sum: f32 = chunk.iter().map(|x| x * x).sum();
sq_sum.sqrt()
})
.collect()
}
.collect(),
};
// Unflatten embeddings
@@ -531,6 +688,27 @@ fn extract_string_attr(
}
}
type MetaAttrs = std::collections::HashMap<String, AttrValue>;
fn optional_i64_attr(attrs: &MetaAttrs, name: &str) -> Option<i64> {
match attrs.get(name) {
Some(AttrValue::I64(v)) => Some(*v),
_ => None,
}
}
fn optional_bool_attr(attrs: &MetaAttrs, name: &str, default: bool) -> bool {
optional_i64_attr(attrs, name).map_or(default, |v| v != 0)
}
/// Finite values only: a NaN threshold/decay would poison every comparison.
fn optional_f32_attr(attrs: &MetaAttrs, name: &str, default: f32) -> f32 {
match attrs.get(name) {
Some(AttrValue::F64(v)) if v.is_finite() => *v as f32,
_ => default,
}
}
fn extract_i64_attr(
attrs: &std::collections::HashMap<String, AttrValue>,
name: &str,
@@ -616,3 +794,108 @@ fn read_u8_dataset(group: &clawhdf5::Group<'_>, name: &str) -> Result<Vec<u8>, M
.map_err(|e| MemoryError::Hdf5(format!("cannot read u8 from {name}: {e}")))?;
Ok(data.into_iter().map(|v| v as u8).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> MemoryConfig {
MemoryConfig::new(std::path::PathBuf::from("unused.h5"), "agent", 4)
}
fn cache_with(n: usize) -> MemoryCache {
let mut cache = MemoryCache::new(4);
for i in 0..n {
cache.push(
format!("chunk {i}"),
vec![i as f32 + 1.0, 0.0, 0.0, 0.0],
"user".into(),
i as f64,
"s".into(),
"t".into(),
);
}
cache
}
fn roundtrip(cache: &MemoryCache) -> Result<MemoryCache, MemoryError> {
let bytes = build_hdf5_file(
&config(),
cache,
&SessionCache::new(),
&KnowledgeCache::new(),
)?;
let file =
clawhdf5::File::from_bytes(bytes).map_err(|e| MemoryError::Hdf5(e.to_string()))?;
validate_and_load(&file).map(|(_, cache, _, _)| cache)
}
#[test]
fn behavioural_config_survives_a_reopen() {
let mut cfg = config();
cfg.compression = true;
cfg.compression_level = 7;
cfg.compact_threshold = 0.5;
cfg.hebbian_boost = 0.25;
cfg.decay_factor = 0.9;
cfg.wal_enabled = false;
cfg.wal_max_entries = 42;
let bytes = build_hdf5_file(
&cfg,
&cache_with(2),
&SessionCache::new(),
&KnowledgeCache::new(),
)
.unwrap();
let file = clawhdf5::File::from_bytes(bytes).unwrap();
let (loaded, loaded_cache, ..) = validate_and_load(&file).unwrap();
// The compressed embeddings must also read back intact.
assert_eq!(loaded_cache.embeddings, cache_with(2).embeddings);
assert!(loaded.compression);
assert_eq!(loaded.compression_level, 7);
assert_eq!(loaded.compact_threshold, 0.5);
assert_eq!(loaded.hebbian_boost, 0.25);
assert_eq!(loaded.decay_factor, 0.9);
assert!(!loaded.wal_enabled);
assert_eq!(loaded.wal_max_entries, 42);
}
#[test]
fn consistent_store_loads() {
let loaded = roundtrip(&cache_with(3)).unwrap();
assert_eq!(loaded.chunks.len(), 3);
assert_eq!(loaded.norms, vec![1.0, 2.0, 3.0]);
}
#[test]
fn wrong_length_norms_are_recomputed_not_trusted() {
// Regression: the guard used to be `n.len() == n.len()`, so a norms
// dataset of any length was accepted and corrupted every cosine score.
let mut cache = cache_with(3);
cache.norms = vec![99.0];
let loaded = roundtrip(&cache).unwrap();
assert_eq!(loaded.norms, vec![1.0, 2.0, 3.0]);
}
#[test]
fn mismatched_per_record_datasets_are_schema_errors() {
type Corrupt = fn(&mut MemoryCache);
let cases: [(&str, Corrupt); 5] = [
("tombstones", |c| c.tombstones.truncate(1)),
("timestamps", |c| c.timestamps.truncate(1)),
("tags", |c| c.tags.truncate(1)),
("session_ids", |c| c.session_ids.truncate(1)),
("source_channel", |c| c.source_channels.truncate(1)),
];
for (name, corrupt) in cases {
let mut cache = cache_with(3);
corrupt(&mut cache);
match roundtrip(&cache) {
Err(MemoryError::Schema(msg)) => {
assert!(msg.contains(name), "{name}: unexpected message {msg}")
}
other => panic!("{name}: expected Schema error, got {:?}", other.map(|_| ())),
}
}
}
}
+35 -7
View File
@@ -4,7 +4,7 @@ use std::path::Path;
use crate::bm25;
use crate::hybrid;
use crate::{HDF5Memory, MemoryError, Result, SearchResult};
use crate::{HDF5Memory, MAX_ACTIVATION_WEIGHT, MemoryError, Result, SearchResult};
impl HDF5Memory {
/// Vector + keyword scoring stage of [`HDF5Memory::hybrid_search`].
@@ -35,7 +35,9 @@ impl HDF5Memory {
.into_iter()
.map(|(id, dist)| (id, 1.0 - dist))
.collect();
let kw_scores = bm25.search(query_text, self.cache.len());
// Fusion normalises over every keyword match, so it needs all
// the scores — but not ranked.
let kw_scores = bm25.scores(query_text);
hybrid::merge_vector_keyword(
vec_scores,
kw_scores,
@@ -90,7 +92,11 @@ impl HDF5Memory {
keyword_weight: f32,
k: usize,
) -> Vec<SearchResult> {
let bm25 = bm25::BM25Index::build(&self.cache.chunks, &self.cache.tombstones);
// The keyword index lives for the life of the store and is updated
// incrementally. Take it out for the duration of the call so the
// vector stage can borrow `self` mutably, then put it back.
self.ensure_bm25_fresh();
let bm25 = self.bm25.take().expect("ensure_bm25_fresh leaves an index");
let scored = self.vector_keyword_search(
query_embedding,
query_text,
@@ -113,23 +119,45 @@ impl HDF5Memory {
}
})
.collect();
// Ties broken by index so results (and therefore which records get
// boosted) don't depend on HashMap iteration order upstream.
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.index.cmp(&b.index))
});
let hit_indices: Vec<usize> = results.iter().map(|r| r.index).collect();
// Only reinforce records that actually matched. When fewer than `k`
// records are relevant, the rest of the list is zero-score filler;
// boosting it would teach the store that arbitrary records are
// important just because they were nearby in iteration order.
let hit_indices: Vec<usize> = results
.iter()
.filter(|r| r.score > 0.0)
.map(|r| r.index)
.collect();
self.apply_hebbian_boost(&hit_indices);
self.flush().ok();
self.bm25 = Some(bm25);
results
}
/// Reinforce the records a query returned. The new weights are persisted by
/// the next checkpoint (any write that flushes, `flush_wal`, or drop) — not
/// by rewriting the whole store inside the query, which is what made
/// `hybrid_search` cost O(store size) in disk I/O. They are a ranking hint,
/// not user data: a crash before the next checkpoint only forgets the
/// boosts since the last one.
fn apply_hebbian_boost(&mut self, hit_indices: &[usize]) {
for &idx in hit_indices {
self.cache.activation_weights[idx] += self.config.hebbian_boost;
if hit_indices.is_empty() || self.config.hebbian_boost == 0.0 {
return;
}
for &idx in hit_indices {
let w = &mut self.cache.activation_weights[idx];
*w = (*w + self.config.hebbian_boost).min(MAX_ACTIVATION_WEIGHT);
}
self.activations_dirty = true;
}
/// Get the chunk text for a memory entry by index.
+94 -5
View File
@@ -11,6 +11,7 @@ use crate::cache::MemoryCache;
use crate::knowledge::KnowledgeCache;
use crate::schema;
use crate::session::SessionCache;
use crate::wal::WalMark;
/// Write all in-memory state to an HDF5 file on disk.
pub fn write_to_disk(
@@ -20,7 +21,36 @@ pub fn write_to_disk(
sessions: &SessionCache,
knowledge: &KnowledgeCache,
) -> Result<(), MemoryError> {
let bytes = schema::build_hdf5_file(config, cache, sessions, knowledge)?;
write_to_disk_with_mark(path, config, cache, sessions, knowledge, None)
}
/// [`write_to_disk`] for a checkpoint: `wal_applied` is the mark of the WAL
/// prefix whose entries `cache` already contains.
pub fn write_to_disk_with_mark(
path: &Path,
config: &MemoryConfig,
cache: &MemoryCache,
sessions: &SessionCache,
knowledge: &KnowledgeCache,
wal_applied: Option<WalMark>,
) -> Result<(), MemoryError> {
let meta = schema::CheckpointMeta {
wal_applied,
ann_generation: None,
};
write_to_disk_with_meta(path, config, cache, sessions, knowledge, &meta)
}
/// [`write_to_disk`] with full checkpoint bookkeeping.
pub fn write_to_disk_with_meta(
path: &Path,
config: &MemoryConfig,
cache: &MemoryCache,
sessions: &SessionCache,
knowledge: &KnowledgeCache,
checkpoint: &schema::CheckpointMeta,
) -> Result<(), MemoryError> {
let bytes = schema::build_hdf5_file_with_meta(config, cache, sessions, knowledge, checkpoint)?;
if bytes.is_empty() {
return Err(MemoryError::Hdf5("build_hdf5_file produced 0 bytes".into()));
@@ -28,9 +58,41 @@ pub fn write_to_disk(
// Write to a temp file first, then rename for atomicity
let tmp_path = path.with_extension("h5.tmp");
std::fs::write(&tmp_path, &bytes).map_err(MemoryError::Io)?;
std::fs::rename(&tmp_path, path).map_err(MemoryError::Io)?;
write_synced(&tmp_path, &bytes)?;
rename_synced(&tmp_path, path)
}
/// Write `bytes` to `path` and flush them to stable storage.
pub(crate) fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), MemoryError> {
use std::io::Write;
let mut f = std::fs::File::create(path).map_err(MemoryError::Io)?;
f.write_all(bytes).map_err(MemoryError::Io)?;
f.sync_all().map_err(MemoryError::Io)
}
/// Rename `from` over `to`, then sync the parent directory so the rename
/// itself survives a power loss. `from` must already be synced: without that,
/// the rename can reach disk before the data and leave an empty or partial
/// file under the final name.
///
/// This is per-checkpoint/snapshot cost only (each is already a full file
/// write). Individual WAL appends are deliberately not synced — see the
/// durability notes in the crate docs.
pub(crate) fn rename_synced(from: &Path, to: &Path) -> Result<(), MemoryError> {
std::fs::rename(from, to).map_err(MemoryError::Io)?;
#[cfg(unix)]
if let Some(dir) = to.parent() {
let dir = if dir.as_os_str().is_empty() {
Path::new(".")
} else {
dir
};
// Directory fsync is best-effort: some filesystems refuse it, and the
// rename has already happened.
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
}
Ok(())
}
@@ -42,6 +104,15 @@ pub fn write_to_disk(
pub fn read_from_disk(
path: &Path,
) -> Result<(MemoryConfig, MemoryCache, SessionCache, KnowledgeCache), MemoryError> {
read_from_disk_with_mark(path).map(|(state, _mark)| state)
}
/// Everything [`read_from_disk`] returns.
pub type StoreState = (MemoryConfig, MemoryCache, SessionCache, KnowledgeCache);
/// [`read_from_disk`], plus the checkpoint's [`WalMark`] (if any) so the
/// caller can skip WAL entries this file already contains.
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)?;
// Advise the OS we'll need the whole file for parsing
@@ -53,8 +124,23 @@ pub fn read_from_disk(
let (mut config, cache, sessions, knowledge) = schema::validate_and_load(&file)?;
config.path = path.to_path_buf();
let wal_applied = schema::read_wal_mark(&file);
Ok((config, cache, sessions, knowledge))
Ok(((config, cache, sessions, knowledge), wal_applied))
}
/// [`read_from_disk`], plus all checkpoint bookkeeping.
pub fn read_from_disk_with_meta(
path: &Path,
) -> Result<(StoreState, schema::CheckpointMeta), MemoryError> {
let mmap = clawhdf5_io::MmapReader::open(path).map_err(MemoryError::Io)?;
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())))?;
let (mut config, cache, sessions, knowledge) = schema::validate_and_load(&file)?;
config.path = path.to_path_buf();
let meta = schema::read_checkpoint_meta(&file);
Ok(((config, cache, sessions, knowledge), meta))
}
/// Copy an HDF5 file atomically to a destination.
@@ -78,7 +164,10 @@ pub fn snapshot_file(src: &Path, dest: &Path) -> Result<std::path::PathBuf, Memo
// Atomic copy: write to temp, then rename
let tmp_path = dest_file.with_extension("h5.tmp");
std::fs::copy(src, &tmp_path).map_err(MemoryError::Io)?;
std::fs::rename(&tmp_path, &dest_file).map_err(MemoryError::Io)?;
std::fs::File::open(&tmp_path)
.and_then(|f| f.sync_all())
.map_err(MemoryError::Io)?;
rename_synced(&tmp_path, &dest_file)?;
Ok(dest_file)
}
+79
View File
@@ -0,0 +1,79 @@
//! Single-writer guard for a memory store.
//!
//! `HDF5Memory` keeps the whole store in memory and rewrites the `.h5` file at
//! every checkpoint, so two handles on one store (two processes, or two opens
//! in one process) silently destroy each other's data: whoever checkpoints
//! last wins, and both append to the same WAL with independent CRC chains.
//! The lock turns that into an immediate, explicit error.
use std::fs::{File, OpenOptions, TryLockError};
use std::path::{Path, PathBuf};
use crate::MemoryError;
const LOCK_RETRIES: u32 = 25;
const LOCK_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(10);
/// An exclusive advisory lock on `<store>.h5.lock`, held for the lifetime of
/// the owning `HDF5Memory` and released when it is dropped (or when the
/// process dies — the OS drops the lock with the file descriptor, so a crash
/// never leaves a stale lock behind; the empty lock file itself is harmless).
#[derive(Debug)]
pub(crate) struct StoreLock {
_file: File,
}
impl StoreLock {
pub(crate) fn lock_path(store: &Path) -> PathBuf {
store.with_extension("h5.lock")
}
pub(crate) fn acquire(store: &Path) -> Result<Self, MemoryError> {
let path = Self::lock_path(store);
let file = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)?;
// A previous owner may be mid-teardown (e.g. an `AsyncHDF5Memory`
// dropped without `shutdown()`: its background task releases the
// store a moment later), so give the lock a short, bounded grace
// period before reporting a genuine second writer.
let mut attempts_left = LOCK_RETRIES;
loop {
match file.try_lock() {
Ok(()) => return Ok(Self { _file: file }),
Err(TryLockError::WouldBlock) if attempts_left > 0 => {
attempts_left -= 1;
std::thread::sleep(LOCK_RETRY_DELAY);
}
Err(TryLockError::WouldBlock) => {
return Err(MemoryError::Locked(format!(
"{} is already open in this or another process (lock file {})",
store.display(),
path.display()
)));
}
Err(TryLockError::Error(e)) => return Err(MemoryError::Io(e)),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn second_acquire_fails_until_first_is_dropped() {
let dir = tempfile::TempDir::new().unwrap();
let store = dir.path().join("s.h5");
let first = StoreLock::acquire(&store).unwrap();
assert!(matches!(
StoreLock::acquire(&store),
Err(MemoryError::Locked(_))
));
drop(first);
StoreLock::acquire(&store).unwrap();
}
}
+326 -14
View File
@@ -28,7 +28,18 @@ const WAL_HEADER_LEN: u64 = WAL_MAGIC.len() as u64 + 1 + 4;
/// its target Save) — the moved/inserted entry's stored CRC was computed
/// against a different predecessor than the one now in front of it on disk,
/// so the chain breaks at that point and replay stops there.
const WAL_VERSION: u8 = 3;
const WAL_VERSION: u8 = 4;
/// The chained-CRC format before [`WalEntryType::Update`] records existed.
/// Byte-for-byte the same framing as [`WAL_VERSION`], so it is read by the
/// same code, and `WalFile::open` upgrades it in place by rewriting the
/// header's version byte (the header is not covered by the CRC chain).
///
/// The bump exists for *older binaries*: they don't know record type 0x04,
/// would treat it as a torn tail, and would truncate it — and everything
/// after it — away. An unknown header version makes them refuse the file
/// with a clear error instead.
const WAL_VERSION_CHAINED_NO_UPDATE: u8 = 3;
/// The previous WAL format version: still a CRC32 per entry (so a bit-flip
/// within one entry is caught), but not chained to the previous entry's CRC
@@ -67,6 +78,10 @@ pub enum WalEntryType {
Save = 0x01,
Tombstone = 0x02,
ActivationUpdate = 0x03,
/// Replace the record at `update_index` in place (`save_or_update` hit).
/// Logged as a plain `Save` before this existed, so replay appended a
/// duplicate instead of updating.
Update = 0x04,
}
impl WalEntryType {
@@ -75,6 +90,7 @@ impl WalEntryType {
0x01 => Some(Self::Save),
0x02 => Some(Self::Tombstone),
0x03 => Some(Self::ActivationUpdate),
0x04 => Some(Self::Update),
_ => None,
}
}
@@ -91,6 +107,8 @@ pub struct WalEntry {
pub tags: String,
/// For tombstone entries: the index of the entry to delete.
pub tombstone_index: Option<usize>,
/// For update entries: the index of the record to replace.
pub update_index: Option<usize>,
}
/// How many entries to accumulate before updating the header entry_count.
@@ -112,6 +130,62 @@ pub struct WalFile {
/// Reset to 0 by `truncate()`/`create_fresh_wal_file`, and re-derived by
/// scanning existing entries when `open()` attaches to a non-empty file.
running_crc: u32,
/// Bytes of verified entries after the header (the length of the chain
/// `running_crc` covers). Together they form the [`WalMark`].
chain_len: u64,
}
/// What a WAL file's 9-byte header looks like, without reading any entries.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalHeaderStatus {
/// A version this build can read (current or legacy).
Readable,
/// Shorter than a header — e.g. a crash while the file was being created.
/// It cannot contain entries.
Torn,
/// Not a WAL file at all.
BadMagic,
/// Well-formed header from a version this build doesn't know — most
/// likely written by a *newer* build. Never discard this: the entries are
/// probably fine, this binary just can't read them.
UnknownVersion(u8),
}
/// Classify the header of the WAL at `path`.
pub fn wal_header_status(path: &Path) -> std::io::Result<WalHeaderStatus> {
let mut header = [0u8; WAL_HEADER_LEN as usize];
let mut f = File::open(path)?;
let mut filled = 0;
while filled < header.len() {
match f.read(&mut header[filled..])? {
0 => return Ok(WalHeaderStatus::Torn),
n => filled += n,
}
}
if header[0..4] != WAL_MAGIC {
return Ok(WalHeaderStatus::BadMagic);
}
Ok(match header[4] {
WAL_VERSION
| WAL_VERSION_CHAINED_NO_UPDATE
| WAL_VERSION_CRC_UNCHAINED
| WAL_VERSION_LEGACY_NO_CRC => WalHeaderStatus::Readable,
v => WalHeaderStatus::UnknownVersion(v),
})
}
/// A position in a WAL's CRC chain: `len` bytes of entries after the header,
/// whose chained CRC is `crc`.
///
/// A checkpoint stores the mark of the WAL prefix it folded into the `.h5`
/// file. If the process dies after the new `.h5` is in place but before the
/// WAL is truncated, the next `open()` finds that exact prefix still in the
/// WAL and skips it instead of replaying it on top of data that already
/// contains it (which used to duplicate every pending entry).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WalMark {
pub len: u64,
pub crc: u32,
}
impl WalFile {
@@ -138,7 +212,15 @@ impl WalFile {
let mut ver = [0u8; 1];
f.read_exact(&mut ver)?;
match ver[0] {
WAL_VERSION => {
WAL_VERSION | WAL_VERSION_CHAINED_NO_UPDATE => {
if ver[0] == WAL_VERSION_CHAINED_NO_UPDATE {
// Same framing; stamp the current version so an older
// binary refuses this file rather than truncating an
// Update record it can't parse. See the constant.
f.seek(SeekFrom::Start(4))?;
f.write_all(&[WAL_VERSION])?;
f.seek(SeekFrom::Start(5))?;
}
let mut count_buf = [0u8; 4];
f.read_exact(&mut count_buf)?;
let header_count = u32::from_le_bytes(count_buf);
@@ -147,7 +229,8 @@ impl WalFile {
// be stale from deferred group-commit sync, same
// tolerance `read_entries` already has, so the scanned
// count is also the more accurate of the two).
let (entries, running_crc, verified_bytes) = read_chained_entries(&mut f, 0);
let (entries, running_crc, verified_bytes) =
read_chained_entries(&mut f, 0, None);
let entry_count = if entries.is_empty() {
header_count
} else {
@@ -190,6 +273,7 @@ impl WalFile {
entry_count,
pending_header_sync: 0,
running_crc,
chain_len: verified_bytes,
})
}
WAL_VERSION_CRC_UNCHAINED | WAL_VERSION_LEGACY_NO_CRC => {
@@ -201,6 +285,7 @@ impl WalFile {
entry_count: 0,
pending_header_sync: 0,
running_crc: 0,
chain_len: 0,
})
}
v => Err(MemoryError::Schema(format!("unsupported WAL version {v}"))),
@@ -213,6 +298,7 @@ impl WalFile {
entry_count: 0,
pending_header_sync: 0,
running_crc: 0,
chain_len: 0,
})
}
}
@@ -236,8 +322,20 @@ impl WalFile {
4 + entry.session_id.len() +
4 + entry.tags.len(),
);
match entry.update_index {
Some(index) => {
let index = u32::try_from(index).map_err(|_| {
MemoryError::Schema(format!("WAL update index {index} exceeds u32"))
})?;
buf.push(WalEntryType::Update as u8);
buf.extend_from_slice(&entry.timestamp.to_le_bytes());
buf.extend_from_slice(&index.to_le_bytes());
}
None => {
buf.push(WalEntryType::Save as u8);
buf.extend_from_slice(&entry.timestamp.to_le_bytes());
}
}
serialize_str(&mut buf, &entry.chunk);
buf.extend_from_slice(&(emb_len as u32).to_le_bytes());
for &val in &entry.embedding {
@@ -258,6 +356,7 @@ impl WalFile {
.as_mut()
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
f.write_all(&buf)?;
self.chain_len += buf.len() as u64;
self.running_crc = crc;
self.entry_count += 1;
@@ -282,6 +381,7 @@ impl WalFile {
.as_mut()
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
f.write_all(&buf)?;
self.chain_len += buf.len() as u64;
self.running_crc = crc;
self.entry_count += 1;
@@ -311,7 +411,7 @@ impl WalFile {
/// legacy-no-CRC file returns a typed error instead of silently
/// downgrading to the unverified parser.
pub fn read_entries(path: &Path) -> Result<Vec<WalEntry>, MemoryError> {
Self::read_entries_impl(path, false)
Self::read_entries_impl(path, false, None)
}
/// Like [`WalFile::read_entries`], but also accepts
@@ -320,13 +420,23 @@ impl WalFile {
/// legitimate caller is `HDF5Memory::open`'s one-time migration of a
/// pre-CRC WAL file, which immediately recreates it in the current
/// format afterward. Do not use this for anything else.
pub(crate) fn read_entries_for_migration(path: &Path) -> Result<Vec<WalEntry>, MemoryError> {
Self::read_entries_impl(path, true)
///
/// `applied` is the checkpoint mark read from the `.h5` file, if any: if
/// the WAL's chain passes through it (same byte length, same chained
/// CRC), everything up to that point is already in the `.h5` and is
/// dropped. If it never does — the normal case, because the WAL was
/// truncated after the checkpoint — every entry is returned.
pub(crate) fn read_entries_for_migration(
path: &Path,
applied: Option<WalMark>,
) -> Result<Vec<WalEntry>, MemoryError> {
Self::read_entries_impl(path, true, applied)
}
fn read_entries_impl(
path: &Path,
allow_legacy_no_crc: bool,
applied: Option<WalMark>,
) -> Result<Vec<WalEntry>, MemoryError> {
if !path.exists() {
return Ok(Vec::new());
@@ -342,8 +452,9 @@ impl WalFile {
let entry_count_hint = u32::from_le_bytes([header[5], header[6], header[7], header[8]]);
match header[4] {
WAL_VERSION => {
let (entries, _final_crc, _verified_bytes) = read_chained_entries(&mut f, 0);
WAL_VERSION | WAL_VERSION_CHAINED_NO_UPDATE => {
let (entries, _final_crc, _verified_bytes) =
read_chained_entries(&mut f, 0, applied);
Ok(entries)
}
WAL_VERSION_CRC_UNCHAINED => {
@@ -406,9 +517,19 @@ impl WalFile {
self.entry_count = 0;
self.pending_header_sync = 0;
self.running_crc = 0;
self.chain_len = 0;
Ok(())
}
/// The mark covering every entry currently in this WAL. Store it with a
/// checkpoint taken from the state those entries produced.
pub fn mark(&self) -> WalMark {
WalMark {
len: self.chain_len,
crc: self.running_crc,
}
}
/// Number of pending entries.
pub fn pending_count(&self) -> u32 {
self.entry_count
@@ -448,6 +569,28 @@ pub fn replay_into_cache(entries: &[WalEntry], cache: &mut crate::cache::MemoryC
entry.tags.clone(),
);
}
WalEntryType::Update => match entry.update_index {
// The index was valid when the record was written; if the
// store no longer has it, keep the data rather than drop it.
Some(idx) if idx < cache.len() => cache.update(
idx,
entry.chunk.clone(),
entry.embedding.clone(),
entry.source_channel.clone(),
entry.timestamp,
entry.session_id.clone(),
),
_ => {
cache.push(
entry.chunk.clone(),
entry.embedding.clone(),
entry.source_channel.clone(),
entry.timestamp,
entry.session_id.clone(),
entry.tags.clone(),
);
}
},
WalEntryType::Tombstone => {
if let Some(idx) = entry.tombstone_index {
cache.mark_deleted(idx);
@@ -524,7 +667,16 @@ fn chained_crc(entry_bytes: &[u8], prev_crc: u32) -> u32 {
/// The byte count is what lets `open()` position an append at the end of the
/// VERIFIED prefix rather than at end-of-file. Appending past a torn tail
/// writes entries that replay can never reach — see `open`.
fn read_chained_entries<R: Read>(f: &mut R, start_crc: u32) -> (Vec<WalEntry>, u32, u64) {
///
/// `applied`, when given, is a checkpoint mark: once the chain reaches exactly
/// that position, the entries collected so far are discarded (they are
/// already in the `.h5` file). A zero-length mark matches nothing.
fn read_chained_entries<R: Read>(
f: &mut R,
start_crc: u32,
applied: Option<WalMark>,
) -> (Vec<WalEntry>, u32, u64) {
let applied = applied.filter(|m| m.len > 0);
let mut entries = Vec::new();
let mut running_crc = start_crc;
let mut verified_bytes: u64 = 0;
@@ -554,6 +706,14 @@ fn read_chained_entries<R: Read>(f: &mut R, start_crc: u32) -> (Vec<WalEntry>, u
if let Some(entry) = entry_opt {
entries.push(entry);
}
if applied
== Some(WalMark {
len: verified_bytes,
crc: running_crc,
})
{
entries.clear();
}
}
(entries, running_crc, verified_bytes)
}
@@ -615,7 +775,14 @@ fn read_one_entry<R: Read>(r: &mut R) -> Result<Option<WalEntry>, ()> {
let timestamp = f64::from_le_bytes(ts_buf);
match entry_type {
WalEntryType::Save => {
WalEntryType::Save | WalEntryType::Update => {
let update_index = if entry_type == WalEntryType::Update {
let mut idx_buf = [0u8; 4];
r.read_exact(&mut idx_buf).map_err(|_| ())?;
Some(u32::from_le_bytes(idx_buf) as usize)
} else {
None
};
let chunk = read_len_prefixed_str(r).map_err(|_| ())?;
let embedding = read_embedding(r).map_err(|_| ())?;
let source_channel = read_len_prefixed_str(r).map_err(|_| ())?;
@@ -630,6 +797,7 @@ fn read_one_entry<R: Read>(r: &mut R) -> Result<Option<WalEntry>, ()> {
session_id,
tags,
tombstone_index: None,
update_index,
}))
}
WalEntryType::Tombstone => {
@@ -645,6 +813,7 @@ fn read_one_entry<R: Read>(r: &mut R) -> Result<Option<WalEntry>, ()> {
session_id: String::new(),
tags: String::new(),
tombstone_index: Some(idx),
update_index: None,
}))
}
WalEntryType::ActivationUpdate => Ok(None),
@@ -668,6 +837,7 @@ mod tests {
session_id: "sess-001".to_string(),
tags: "tag1,tag2".to_string(),
tombstone_index: None,
update_index: None,
}
}
@@ -786,7 +956,7 @@ mod tests {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("test.h5.wal");
let unicode_chunk = "Hello 世界! 🌍 émojis & ünïcödé";
let embedding = vec![0.1, -0.2, 3.14159, f32::MAX, f32::MIN_POSITIVE];
let embedding = vec![0.1, -0.2, 3.4567, f32::MAX, f32::MIN_POSITIVE];
{
let mut wal = WalFile::open(&wal_path).unwrap();
let entry = WalEntry {
@@ -798,6 +968,7 @@ mod tests {
session_id: "sess-öö-123".to_string(),
tags: "α,β,γ".to_string(),
tombstone_index: None,
update_index: None,
};
wal.append_save(&entry).unwrap();
}
@@ -932,6 +1103,148 @@ mod tests {
assert!(entries.is_empty());
}
/// Reopen `path` and return the stored chunks in order.
fn reopen_chunks(path: &std::path::Path) -> Vec<String> {
let mem = HDF5Memory::open(path).unwrap();
mem.cache.chunks.clone()
}
#[test]
fn crash_between_checkpoint_and_wal_truncate_does_not_duplicate() {
// flush() writes the new .h5 and only then truncates the WAL. Dying in
// between leaves BOTH a .h5 that contains the pending entries and a
// WAL that still lists them; replaying blindly used to double them.
let dir = TempDir::new().unwrap();
let config = make_config(&dir);
let h5_path = config.path.clone();
let wal_path = h5_path.with_extension("h5.wal");
let stale_wal = dir.path().join("stale.wal");
{
let mut mem = HDF5Memory::create(config).unwrap();
for name in ["a", "b", "c"] {
mem.save(make_entry(name, &[1.0, 0.0, 0.0, 0.0])).unwrap();
}
assert_eq!(mem.wal_pending_count(), 3);
std::fs::copy(&wal_path, &stale_wal).unwrap();
mem.flush_wal().unwrap();
}
// Undo the truncate: this is the on-disk state right after the crash.
std::fs::copy(&stale_wal, &wal_path).unwrap();
assert_eq!(WalFile::read_entries(&wal_path).unwrap().len(), 3);
assert_eq!(reopen_chunks(&h5_path), ["a", "b", "c"]);
// Entries appended to that same WAL after recovery are still replayed.
{
let mut mem = HDF5Memory::open(&h5_path).unwrap();
mem.save(make_entry("d", &[0.0, 1.0, 0.0, 0.0])).unwrap();
}
assert_eq!(reopen_chunks(&h5_path), ["a", "b", "c", "d"]);
}
#[test]
fn entries_written_after_a_completed_checkpoint_are_all_replayed() {
// Normal case: the checkpoint's mark refers to a WAL that has since
// been truncated, so it must not suppress anything in the new one —
// including when the new WAL grows past the old mark's length.
let dir = TempDir::new().unwrap();
let config = make_config(&dir);
let h5_path = config.path.clone();
{
let mut mem = HDF5Memory::create(config).unwrap();
mem.save(make_entry("a", &[1.0, 0.0, 0.0, 0.0])).unwrap();
mem.flush_wal().unwrap();
for name in ["b", "c", "d"] {
mem.save(make_entry(name, &[1.0, 0.0, 0.0, 0.0])).unwrap();
}
}
assert_eq!(reopen_chunks(&h5_path), ["a", "b", "c", "d"]);
}
#[test]
fn save_or_update_replays_as_update_not_duplicate() {
let dir = TempDir::new().unwrap();
let config = make_config(&dir);
let h5_path = config.path.clone();
{
let mut mem = HDF5Memory::create(config).unwrap();
let mut first = make_entry("v1", &[1.0, 0.0, 0.0, 0.0]);
first.tags = "key".into();
let mut second = make_entry("v2", &[0.0, 1.0, 0.0, 0.0]);
second.tags = "key".into();
let a = mem.save_or_update(first).unwrap();
mem.save(make_entry("other", &[0.0, 0.0, 1.0, 0.0]))
.unwrap();
let b = mem.save_or_update(second).unwrap();
assert_eq!(a, b);
assert_eq!(mem.cache.chunks, ["v2", "other"]);
// Dropped without a checkpoint: all three records live in the WAL.
}
let mem = HDF5Memory::open(&h5_path).unwrap();
assert_eq!(mem.cache.chunks, ["v2", "other"]);
assert_eq!(mem.cache.embeddings[0], [0.0, 1.0, 0.0, 0.0]);
}
#[test]
fn v3_wal_is_read_and_upgraded_in_place() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("old.wal");
{
let mut wal = WalFile::open(&wal_path).unwrap();
wal.append_save(&make_wal_entry("kept", &[1.0])).unwrap();
}
// Rewrite the header as the pre-Update chained format.
let mut bytes = std::fs::read(&wal_path).unwrap();
bytes[4] = WAL_VERSION_CHAINED_NO_UPDATE;
std::fs::write(&wal_path, &bytes).unwrap();
assert_eq!(WalFile::read_entries(&wal_path).unwrap().len(), 1);
{
let mut wal = WalFile::open(&wal_path).unwrap();
assert_eq!(wal.pending_count(), 1);
wal.append_save(&make_wal_entry("new", &[2.0])).unwrap();
}
assert_eq!(std::fs::read(&wal_path).unwrap()[4], WAL_VERSION);
let chunks: Vec<_> = WalFile::read_entries(&wal_path)
.unwrap()
.into_iter()
.map(|e| e.chunk)
.collect();
assert_eq!(chunks, ["kept", "new"]);
}
#[test]
fn mark_matching_is_exact() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("m.wal");
let mut wal = WalFile::open(&wal_path).unwrap();
wal.append_save(&make_wal_entry("one", &[1.0])).unwrap();
let after_one = wal.mark();
wal.append_save(&make_wal_entry("two", &[2.0])).unwrap();
let after_two = wal.mark();
drop(wal);
let read = |m| {
WalFile::read_entries_for_migration(&wal_path, m)
.unwrap()
.into_iter()
.map(|e| e.chunk)
.collect::<Vec<_>>()
};
assert_eq!(read(None), ["one", "two"]);
assert_eq!(read(Some(after_one)), ["two"]);
assert!(read(Some(after_two)).is_empty());
// Right length, wrong CRC (a different WAL generation): skip nothing.
let foreign = WalMark {
crc: after_one.crc ^ 1,
..after_one
};
assert_eq!(read(Some(foreign)), ["one", "two"]);
// Reopening resumes the same mark.
assert_eq!(WalFile::open(&wal_path).unwrap().mark(), after_two);
}
#[test]
fn test_wal_replay_on_open() {
// Test WAL replay using read_entries + replay_into_cache directly,
@@ -1228,8 +1541,7 @@ mod tests {
drop(wal); // simulate a restart without ever truncating the WAL
let mut wal2 = WalFile::open(&wal_path).unwrap();
wal2.append_save(&make_wal_entry("second", &[2.0]))
.unwrap();
wal2.append_save(&make_wal_entry("second", &[2.0])).unwrap();
drop(wal2);
let entries = WalFile::read_entries(&wal_path).unwrap();
@@ -1270,7 +1582,7 @@ mod tests {
std::fs::write(&wal_path, build_legacy_v1_wal_bytes()).unwrap();
// Only the migration-only reader may read a legacy no-CRC file.
let entries = WalFile::read_entries_for_migration(&wal_path).unwrap();
let entries = WalFile::read_entries_for_migration(&wal_path, None).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].chunk, "legacy-chunk");
assert_eq!(entries[0].embedding, vec![1.0, 2.0]);
@@ -0,0 +1,187 @@
//! Crash-recovery matrix for `HDF5Memory`.
//!
//! A process crash leaves whatever reached the OS on disk. These tests build
//! the on-disk images such a crash can leave behind — after every operation,
//! inside the checkpoint window (new `.h5` in place, WAL not yet truncated),
//! and with the WAL torn at every possible length — then reopen each image
//! and check the recovered store against a model of what was acknowledged.
//!
//! Invariants:
//! * never a duplicated or invented record;
//! * an image taken between operations recovers *exactly* the acknowledged
//! state;
//! * a torn WAL recovers the last checkpoint plus a prefix of the operations
//! logged since.
use std::path::{Path, PathBuf};
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
use tempfile::TempDir;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
fn entry(chunk: &str, tags: &str) -> MemoryEntry {
MemoryEntry {
chunk: chunk.to_string(),
embedding: vec![1.0, 0.0, 0.0, 0.0],
source_channel: "test".into(),
timestamp: 1.0,
session_id: "s".into(),
tags: tags.to_string(),
}
}
fn wal_path(h5: &Path) -> PathBuf {
h5.with_extension("h5.wal")
}
/// Copy the store (`.h5` + WAL) into a fresh directory, as a crash image.
fn image(h5: &Path, into: &TempDir, name: &str) -> PathBuf {
let dest = into.path().join(format!("{name}.h5"));
std::fs::copy(h5, &dest).unwrap();
if wal_path(h5).exists() {
std::fs::copy(wal_path(h5), wal_path(&dest)).unwrap();
}
dest
}
fn recovered(h5: &Path) -> Vec<String> {
// Read-only: the image must not be modified, and no lock is needed.
HDF5Memory::open_read_only(h5).unwrap().cache.chunks.clone()
}
/// Apply one random operation to the store and to the model.
fn step(mem: &mut HDF5Memory, model: &mut Vec<String>, rng: &mut Rng, n: usize) {
match rng.below(6) {
0 => mem.flush_wal().unwrap(),
1 if !model.is_empty() => {
// Update an existing record in place, addressed by its tag.
let idx = rng.below(model.len());
let chunk = format!("u{n}");
assert_eq!(
mem.save_or_update(entry(&chunk, &format!("tag{idx}")))
.unwrap(),
idx
);
model[idx] = chunk;
}
_ => {
let chunk = format!("c{n}");
mem.save(entry(&chunk, &format!("tag{}", model.len())))
.unwrap();
model.push(chunk);
}
}
}
#[test]
fn image_after_every_operation_recovers_the_acknowledged_state() {
for seed in 0..40u64 {
let mut rng = Rng(seed);
let dir = TempDir::new().unwrap();
let images = TempDir::new().unwrap();
let mut config = MemoryConfig::new(dir.path().join("store.h5"), "agent", 4);
config.wal_enabled = true;
config.wal_max_entries = 1 + rng.below(6); // force frequent checkpoints
let h5 = config.path.clone();
let mut mem = HDF5Memory::create(config).unwrap();
let mut model = Vec::new();
for n in 0..30 {
step(&mut mem, &mut model, &mut rng, n);
let img = image(&h5, &images, &format!("s{seed}-{n}"));
assert_eq!(recovered(&img), model, "seed {seed}, after op {n}");
}
}
}
#[test]
fn crash_inside_the_checkpoint_window_never_duplicates() {
for seed in 0..40u64 {
let mut rng = Rng(seed ^ 0xABCD);
let dir = TempDir::new().unwrap();
let images = TempDir::new().unwrap();
let mut config = MemoryConfig::new(dir.path().join("store.h5"), "agent", 4);
config.wal_enabled = true;
config.wal_max_entries = 1000; // checkpoints only when we ask
let h5 = config.path.clone();
let mut mem = HDF5Memory::create(config).unwrap();
let mut model = Vec::new();
for round in 0..4 {
for n in 0..(1 + rng.below(6)) {
step(&mut mem, &mut model, &mut rng, round * 100 + n);
}
// The WAL as it is just before the checkpoint...
let stale_wal = images.path().join(format!("stale-{seed}-{round}.wal"));
if wal_path(&h5).exists() {
std::fs::copy(wal_path(&h5), &stale_wal).unwrap();
}
mem.flush_wal().unwrap();
// ...put back next to the NEW .h5: the crash-in-the-window image.
let img = image(&h5, &images, &format!("w{seed}-{round}"));
if stale_wal.exists() {
std::fs::copy(&stale_wal, wal_path(&img)).unwrap();
}
assert_eq!(recovered(&img), model, "seed {seed}, round {round}");
}
}
}
#[test]
fn torn_wal_recovers_checkpoint_plus_a_prefix() {
let dir = TempDir::new().unwrap();
let images = TempDir::new().unwrap();
let mut config = MemoryConfig::new(dir.path().join("store.h5"), "agent", 4);
config.wal_enabled = true;
config.wal_max_entries = 1000;
let h5 = config.path.clone();
let mut mem = HDF5Memory::create(config).unwrap();
for name in ["a", "b"] {
mem.save(entry(name, name)).unwrap();
}
mem.flush_wal().unwrap();
let checkpointed = vec!["a".to_string(), "b".to_string()];
// States the store passes through as each later op is logged.
let mut states = vec![checkpointed.clone()];
let mut model = checkpointed.clone();
mem.save(entry("c", "c")).unwrap();
model.push("c".into());
states.push(model.clone());
mem.save_or_update(entry("a2", "a")).unwrap();
model[0] = "a2".into();
states.push(model.clone());
mem.save(entry("d", "d")).unwrap();
model.push("d".into());
states.push(model.clone());
let full_wal = std::fs::read(wal_path(&h5)).unwrap();
let mut seen = std::collections::BTreeSet::new();
for len in 0..=full_wal.len() {
let img = image(&h5, &images, &format!("t{len}"));
std::fs::write(wal_path(&img), &full_wal[..len]).unwrap();
let got = recovered(&img);
let which = states
.iter()
.position(|s| *s == got)
.unwrap_or_else(|| panic!("WAL torn at {len} bytes recovered {got:?}"));
seen.insert(which);
}
// Every intermediate state is reachable, and the full WAL gives the last.
assert_eq!(seen.into_iter().collect::<Vec<_>>(), [0, 1, 2, 3]);
}
+10 -10
View File
@@ -196,7 +196,7 @@ fn test_migration_round_trip() {
mem.add_relation(e1, e2, "discusses", 0.8).unwrap();
// Verify all data transferred by reopening
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 500);
// Verify sessions
@@ -266,7 +266,7 @@ fn test_knowledge_graph_workflow() {
assert_eq!(entity.entity_type, "library");
// Persistence
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.knowledge().entities.len(), 4);
assert_eq!(reopened.knowledge().relations.len(), 4);
@@ -316,7 +316,7 @@ fn test_multi_session_workflow() {
assert_eq!(mem.count(), 100); // 5 sessions * 20 entries
// Reopen and verify sessions
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
for sess in 0..5 {
let summary = reopened
.get_session_summary(&format!("sess_{sess}"))
@@ -460,7 +460,7 @@ fn test_snapshot_and_continue() {
assert_eq!(snap_mem.count(), 50);
// Original should have 100
let orig_mem = HDF5Memory::open(&path).unwrap();
let orig_mem = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(orig_mem.count(), 100);
}
@@ -483,7 +483,7 @@ fn test_config_persistence_across_ops() {
mem.add_session("s1", 0, 0, "ch", "summary").unwrap();
mem.add_entity("Entity", "type", -1).unwrap();
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.config().embedding_dim, 128);
assert_eq!(reopened.config().embedder, "custom:my-embedder-v2");
assert_eq!(reopened.config().chunk_size, 2048);
@@ -695,7 +695,7 @@ fn test_large_text_chunks() {
mem.save_batch(entries).unwrap();
// Reopen and verify
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 10);
let (_, cache, _, _) = read_cache(&path);
@@ -752,7 +752,7 @@ fn test_interleaved_sessions_entries() {
mem.flush_wal().unwrap();
// Verify
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 6);
assert_eq!(
reopened.get_session_summary("s1").unwrap().as_deref(),
@@ -806,7 +806,7 @@ fn test_knowledge_graph_with_embeddings() {
mem.add_relation(e_python, e_hdf5, "reads", 0.9).unwrap();
// Verify entity-embedding linkage persists
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
let rust_entity = reopened.knowledge().get_entity(e_rust).unwrap();
assert_eq!(rust_entity.embedding_idx, idx0 as i64);
@@ -1048,7 +1048,7 @@ fn test_gpu_l2_fallback_works() {
let tombstones = vec![0u8; 3];
let gpu = clawhdf5_agent::gpu_search::GpuSearchBackend::try_init(&vectors, &norms, 2, 1);
let results = gpu.search_l2(&vec![0.0, 0.0], &vectors, &tombstones, 3);
let results = gpu.search_l2(&[0.0, 0.0], &vectors, &tombstones, 3);
assert_eq!(results.len(), 3);
assert_eq!(results[0].0, 0);
@@ -1099,7 +1099,7 @@ fn test_mmap_reader_direct_access() {
// Open via MmapReader directly
let mmap = clawhdf5_io::MmapReader::open(&path).unwrap();
assert!(mmap.len() > 0);
assert!(!mmap.is_empty());
// Verify we can read bytes at specific offsets
let bytes = mmap.read_at(0, 8);
assert!(bytes.is_some());
@@ -137,12 +137,12 @@ fn bench_hit_at_1_1014_records() {
0.3,
1,
);
if let Some((top_idx, _)) = results.first() {
if *top_idx == target_indices[qi] {
if let Some((top_idx, _)) = results.first()
&& *top_idx == target_indices[qi]
{
hits += 1;
}
}
}
let hit_at_1 = hits as f64 / NUM_QUERIES as f64;
println!(
+5 -5
View File
@@ -105,7 +105,7 @@ fn test_heavy_tombstoning() {
assert_eq!(mem.count_active(), 5000);
// Verify persistence
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 5000);
}
@@ -163,7 +163,7 @@ fn test_large_embeddings_1536() {
assert_eq!(mem.count(), 10_000);
// Verify persistence
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 10_000);
// Verify search works on large dims
@@ -545,7 +545,7 @@ fn test_delete_all_entries() {
assert_eq!(mem.count(), 0);
// Verify persistence
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 0);
}
@@ -639,7 +639,7 @@ fn test_unicode_content() {
];
mem.save_batch(entries).unwrap();
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 3);
let (_, cache, _, _) = clawhdf5_agent::storage::read_from_disk(&path).unwrap();
@@ -685,6 +685,6 @@ fn test_rapid_save_delete_cycles() {
assert_eq!(removed, 250);
assert_eq!(mem.count(), 250);
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 250);
}
@@ -0,0 +1,213 @@
//! Property tests for the write-ahead log.
//!
//! A deterministic generator (no external crates, reproducible from the seed
//! printed on failure) drives thousands of cases through two properties:
//!
//! 1. **Round trip** — whatever was appended is read back, in order, intact.
//! 2. **Prefix under corruption** — after *any* damage to the file (bit flips,
//! truncation, inserted or deleted bytes, duplicated or reordered regions),
//! reading never panics and yields an exact *prefix* of what was written.
//! This is the guarantee the chained CRC exists to provide: replay may stop
//! early, but it never returns a corrupted, reordered, or invented entry.
use clawhdf5_agent::wal::{WalEntry, WalEntryType, WalFile};
/// SplitMix64: tiny, well-distributed, and fully determined by its seed.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
fn string(&mut self, max_len: usize) -> String {
const ALPHABET: &[char] = &['a', 'Z', '0', ' ', '\n', '\0', 'é', '漢', '🦀', '"'];
(0..self.below(max_len + 1))
.map(|_| ALPHABET[self.below(ALPHABET.len())])
.collect()
}
}
/// What a test appended, in a form comparable with what is read back.
#[derive(Debug, Clone, PartialEq)]
enum Logged {
Save(String, Vec<u32>, String, String, String, u64),
Update(usize, String, Vec<u32>, u64),
Tombstone(usize, u64),
}
fn logged(entry: &WalEntry) -> Logged {
// Compare floats by bit pattern so NaN payloads and -0.0 count as intact.
let bits: Vec<u32> = entry.embedding.iter().map(|f| f.to_bits()).collect();
let ts = entry.timestamp.to_bits();
match entry.entry_type {
WalEntryType::Save => Logged::Save(
entry.chunk.clone(),
bits,
entry.source_channel.clone(),
entry.session_id.clone(),
entry.tags.clone(),
ts,
),
WalEntryType::Update => {
Logged::Update(entry.update_index.unwrap(), entry.chunk.clone(), bits, ts)
}
WalEntryType::Tombstone => Logged::Tombstone(entry.tombstone_index.unwrap(), ts),
WalEntryType::ActivationUpdate => unreachable!("never written by these tests"),
}
}
/// Append a random mix of records; return what was written.
fn write_random_wal(path: &std::path::Path, rng: &mut Rng) -> Vec<Logged> {
let mut wal = WalFile::open(path).unwrap();
let mut written = Vec::new();
for _ in 0..rng.below(12) {
let timestamp = f64::from_bits(rng.next());
if rng.below(5) == 0 {
let index = rng.below(1000);
wal.append_tombstone(index, timestamp).unwrap();
written.push(Logged::Tombstone(index, timestamp.to_bits()));
continue;
}
let update_index = (rng.below(4) == 0).then(|| rng.below(1000));
let entry = WalEntry {
entry_type: if update_index.is_some() {
WalEntryType::Update
} else {
WalEntryType::Save
},
timestamp,
chunk: rng.string(40),
embedding: (0..rng.below(9))
.map(|_| f32::from_bits(rng.next() as u32))
.collect(),
source_channel: rng.string(8),
session_id: rng.string(8),
tags: rng.string(8),
tombstone_index: None,
update_index,
};
wal.append_save(&entry).unwrap();
written.push(logged(&entry));
}
written
}
fn read_back(path: &std::path::Path) -> Option<Vec<Logged>> {
WalFile::read_entries(path)
.ok()
.map(|entries| entries.iter().map(logged).collect())
}
#[test]
fn everything_appended_is_read_back_intact() {
let dir = tempfile::TempDir::new().unwrap();
for seed in 0..300u64 {
let path = dir.path().join(format!("rt-{seed}.wal"));
let written = write_random_wal(&path, &mut Rng(seed));
assert_eq!(read_back(&path).unwrap(), written, "seed {seed}");
// Reopening (which scans and repositions) must not disturb anything.
drop(WalFile::open(&path).unwrap());
assert_eq!(
read_back(&path).unwrap(),
written,
"seed {seed} after reopen"
);
}
}
/// Damage `bytes` in one of several ways.
fn corrupt(bytes: &mut Vec<u8>, rng: &mut Rng) {
if bytes.is_empty() {
return;
}
match rng.below(7) {
0 => {
let i = rng.below(bytes.len());
bytes[i] ^= 1 << rng.below(8);
}
1 => bytes.truncate(rng.below(bytes.len())),
2 => {
let i = rng.below(bytes.len() + 1);
bytes.insert(i, rng.next() as u8);
}
3 => {
let i = rng.below(bytes.len());
bytes.remove(i);
}
4 => {
// Duplicate a region in place (a replayed/duplicated entry).
let a = rng.below(bytes.len());
let b = a + rng.below(bytes.len() - a);
let region = bytes[a..b].to_vec();
let at = rng.below(bytes.len() + 1);
bytes.splice(at..at, region);
}
5 => {
// Swap two regions (reordered entries).
let mid = rng.below(bytes.len());
bytes.rotate_left(mid);
}
_ => {
let i = rng.below(bytes.len());
let n = rng.below(bytes.len() - i + 1);
for b in &mut bytes[i..i + n] {
*b = rng.next() as u8;
}
}
}
}
#[test]
fn any_corruption_yields_a_prefix_never_a_wrong_entry() {
let dir = tempfile::TempDir::new().unwrap();
let mut shortened = 0u32;
for seed in 0..1500u64 {
let mut rng = Rng(seed ^ 0xC0FF_EE00);
let path = dir.path().join("c.wal");
let _ = std::fs::remove_file(&path);
let written = write_random_wal(&path, &mut rng);
let mut bytes = std::fs::read(&path).unwrap();
for _ in 0..=rng.below(3) {
corrupt(&mut bytes, &mut rng);
}
std::fs::write(&path, &bytes).unwrap();
// An unreadable header is a clean error; anything else is a prefix.
if let Some(read) = read_back(&path) {
assert!(
read.len() <= written.len() && read[..] == written[..read.len()],
"seed {seed}: read {read:?}\nis not a prefix of {written:?}"
);
if read.len() < written.len() {
shortened += 1;
}
// Opening for append repairs the tail; what was readable stays so,
// and a new entry lands right after it.
if let Ok(mut wal) = WalFile::open(&path) {
wal.append_tombstone(7, 1.0).unwrap();
drop(wal);
let mut expected = read.clone();
expected.push(Logged::Tombstone(7, 1.0f64.to_bits()));
assert_eq!(
read_back(&path).unwrap(),
expected,
"seed {seed} after repair"
);
}
}
}
assert!(
shortened > 100,
"corruption rarely took effect: {shortened}"
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-android"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "Android JNI bridge for edgehdf5-memory HDF5 backend"
license = "MIT"
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-ann"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "HNSW approximate nearest neighbor index stored as HDF5"
license = "MIT"
@@ -10,9 +10,9 @@ keywords = ["hdf5", "ann", "hnsw", "nearest-neighbor"]
categories = ["algorithms", "science"]
[dependencies]
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.2.0" }
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.2.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.4.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.4.0" }
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.4.0" }
rayon = { version = "1", optional = true }
[features]
+462 -23
View File
@@ -1,6 +1,6 @@
//! HNSW index implementation with HDF5 serialization.
use std::collections::{BinaryHeap, HashSet};
use std::collections::BinaryHeap;
use clawhdf5_format::attribute::extract_attributes_full;
use clawhdf5_format::data_layout::DataLayout;
@@ -51,10 +51,30 @@ impl DistanceMetric {
fn compute_distance(a: &[f32], b: &[f32], metric: DistanceMetric) -> f32 {
match metric {
DistanceMetric::L2 => clawhdf5_accel::l2_distance(a, b),
DistanceMetric::Cosine => 1.0 - clawhdf5_accel::cosine_similarity(a, b),
// Both sides are unit length (see `prepare`), so cosine similarity is
// the plain dot product. Computing it as dot / (|a| * |b|) re-derived
// both norms on every call — three reductions instead of one, in the
// innermost loop of both build and search.
DistanceMetric::Cosine => 1.0 - clawhdf5_accel::dot_product(a, b),
}
}
/// Put a vector in the form the index stores and compares: unit length for the
/// cosine metric, unchanged for L2. A zero vector stays zero, giving distance 1
/// to everything — what the cosine kernel reports for a degenerate input.
fn prepare(mut v: Vec<f32>, metric: DistanceMetric) -> Vec<f32> {
if metric == DistanceMetric::Cosine {
let norm = clawhdf5_accel::vector_norm(&v);
if norm > f32::EPSILON {
let inv = 1.0 / norm;
v.iter_mut().for_each(|x| *x *= inv);
} else {
v.iter_mut().for_each(|x| *x = 0.0);
}
}
v
}
/// Assign a random level to a new node based on the HNSW probability distribution.
///
/// Uses a deterministic approach based on the node index for reproducibility.
@@ -134,6 +154,9 @@ impl Ord for FarCandidate {
}
}
/// Magic for [`HnswIndex::graph_to_bytes`].
const GRAPH_MAGIC: &[u8; 4] = b"CHG1";
/// On-disk format version for the serialized HNSW index.
///
/// - Version 1: original layout (`vectors`, `graph_layer_*`, `config`), no
@@ -201,6 +224,8 @@ impl HnswIndex {
let m_max0 = m * 2;
let n = vectors.len();
let prepared: Vec<Vec<f32>> = vectors.iter().map(|v| prepare(v.clone(), metric)).collect();
let vectors: &[Vec<f32>] = &prepared;
// Assign levels to all nodes
let mut node_levels = Vec::with_capacity(n);
@@ -252,8 +277,9 @@ impl HnswIndex {
metric,
);
// Select up to m closest neighbors
let selected: Vec<usize> = neighbors.iter().take(max_conn).map(|c| c.id).collect();
let scored: Vec<(usize, f32)> =
neighbors.iter().map(|c| (c.id, c.distance)).collect();
let selected = select_neighbors(vectors, &scored, max_conn, metric);
// Add bidirectional connections
graph[layer][i] = selected.clone();
@@ -284,7 +310,7 @@ impl HnswIndex {
}
Self {
vectors: vectors.to_vec(),
vectors: prepared,
graph,
deleted: vec![false; n],
entry_point,
@@ -323,6 +349,7 @@ impl HnswIndex {
/// # Panics
/// Panics if `vector`'s dimension does not match the existing vectors.
pub fn insert(&mut self, vector: Vec<f32>) -> usize {
let vector = prepare(vector, self.metric);
let id = self.vectors.len();
// Seed an empty index.
@@ -382,7 +409,8 @@ impl HnswIndex {
self.ef_construction,
self.metric,
);
let selected: Vec<usize> = neighbors.iter().take(max_conn).map(|c| c.id).collect();
let scored: Vec<(usize, f32)> = neighbors.iter().map(|c| (c.id, c.distance)).collect();
let selected = select_neighbors(&self.vectors, &scored, max_conn, self.metric);
self.graph[layer][id] = selected.clone();
for &neighbor in &selected {
self.graph[layer][neighbor].push(id);
@@ -478,6 +506,8 @@ impl HnswIndex {
"query dimension mismatch"
);
let ef = ef.max(k);
let prepared_query = prepare(query.to_vec(), self.metric);
let query = prepared_query.as_slice();
let mut ep = self.entry_point;
let top_layer = self.graph.len().saturating_sub(1);
@@ -628,7 +658,9 @@ impl HnswIndex {
actual: flat_vectors.len(),
});
}
vectors.push(flat_vectors[start..end].to_vec());
// Files written before vectors were stored unit-length hold the
// raw ones; preparing is idempotent, so this handles both.
vectors.push(prepare(flat_vectors[start..end].to_vec(), metric));
}
// Read graph layers
@@ -688,6 +720,160 @@ impl HnswIndex {
})
}
/// Serialize the **graph only** — levels, tombstones and adjacency, not the
/// vectors — for a caller that already stores the vectors elsewhere (the
/// agent's record cache). [`HnswIndex::to_hdf5_bytes`] writes a complete,
/// self-contained index including a full copy of every vector, which would
/// double such a store's size. Reattach with
/// [`HnswIndex::from_graph_bytes`].
///
/// Layout (little endian): magic `CHG1`, then u32 fields `n`, `m`,
/// `m_max0`, `ef_construction`, `entry_point`, `num_layers`, `metric`;
/// `n` level bytes; `n` tombstone bytes; per layer, per node that exists on
/// that layer: u32 neighbour count + u32 ids; trailing CRC32 of all of it.
pub fn graph_to_bytes(&self) -> Vec<u8> {
let n = self.vectors.len();
let mut out = Vec::with_capacity(32 + n * 2 + n * self.m_max0 * 4);
out.extend_from_slice(GRAPH_MAGIC);
for field in [
n,
self.m,
self.m_max0,
self.ef_construction,
self.entry_point,
self.graph.len(),
match self.metric {
DistanceMetric::L2 => 0,
DistanceMetric::Cosine => 1,
},
] {
out.extend_from_slice(&(field as u32).to_le_bytes());
}
out.extend(self.node_levels.iter().map(|&l| l.min(255) as u8));
out.extend(self.deleted.iter().map(|&d| u8::from(d)));
for (layer, adjacency) in self.graph.iter().enumerate() {
for (node, neighbors) in adjacency.iter().enumerate() {
if self.node_levels[node] < layer {
continue; // node does not exist on this layer
}
out.extend_from_slice(&(neighbors.len() as u32).to_le_bytes());
for &id in neighbors {
out.extend_from_slice(&(id as u32).to_le_bytes());
}
}
}
let crc = clawhdf5_format::checksum::crc32(&out);
out.extend_from_slice(&crc.to_le_bytes());
out
}
/// Rebuild an index from [`HnswIndex::graph_to_bytes`] output and the
/// vectors it was built over (same order). Every structural claim in
/// `bytes` is validated — a corrupt or mismatched graph is an error, never
/// an index that panics or walks out of bounds during a search.
pub fn from_graph_bytes(bytes: &[u8], vectors: Vec<Vec<f32>>) -> Result<Self, FormatError> {
let bad = |what: &str| FormatError::SerializationError(format!("HNSW graph: {what}"));
let body_len = bytes
.len()
.checked_sub(4)
.filter(|&l| l >= GRAPH_MAGIC.len() + 7 * 4)
.ok_or_else(|| bad("truncated"))?;
let (body, crc_bytes) = bytes.split_at(body_len);
if &body[..4] != GRAPH_MAGIC {
return Err(bad("bad magic"));
}
let stored_crc =
u32::from_le_bytes([crc_bytes[0], crc_bytes[1], crc_bytes[2], crc_bytes[3]]);
if clawhdf5_format::checksum::crc32(body) != stored_crc {
return Err(bad("checksum mismatch"));
}
let mut pos = 4;
let next_u32 = |pos: &mut usize| -> Result<usize, FormatError> {
let b = body.get(*pos..*pos + 4).ok_or_else(|| bad("truncated"))?;
*pos += 4;
Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize)
};
let n = next_u32(&mut pos)?;
let m = next_u32(&mut pos)?;
let m_max0 = next_u32(&mut pos)?;
let ef_construction = next_u32(&mut pos)?;
let entry_point = next_u32(&mut pos)?;
let num_layers = next_u32(&mut pos)?;
let metric = match next_u32(&mut pos)? {
0 => DistanceMetric::L2,
1 => DistanceMetric::Cosine,
_ => return Err(bad("unknown metric")),
};
if n != vectors.len() {
return Err(bad("vector count does not match the graph"));
}
if n == 0 || entry_point >= n || m < 2 || num_layers == 0 || num_layers > 256 {
return Err(bad("invalid header"));
}
let dim = vectors[0].len();
if vectors.iter().any(|v| v.len() != dim) {
return Err(bad("vectors have mixed dimensions"));
}
let levels = body.get(pos..pos + n).ok_or_else(|| bad("truncated"))?;
pos += n;
let node_levels: Vec<usize> = levels.iter().map(|&l| l as usize).collect();
if node_levels.iter().any(|&l| l >= num_layers)
|| node_levels[entry_point] + 1 != num_layers
{
return Err(bad("levels inconsistent with layer count"));
}
let deleted: Vec<bool> = body
.get(pos..pos + n)
.ok_or_else(|| bad("truncated"))?
.iter()
.map(|&d| d != 0)
.collect();
pos += n;
let mut graph: Vec<Vec<Vec<usize>>> = Vec::with_capacity(num_layers);
for layer in 0..num_layers {
let max_conn = if layer == 0 { m_max0 } else { m };
let mut adjacency = vec![Vec::new(); n];
for (node, slot) in adjacency.iter_mut().enumerate() {
if node_levels[node] < layer {
continue;
}
let count = next_u32(&mut pos)?;
if count > max_conn {
return Err(bad("neighbour list exceeds the connection limit"));
}
let mut neighbors = Vec::with_capacity(count);
for _ in 0..count {
let id = next_u32(&mut pos)?;
// A neighbour must exist, and exist on this layer.
if id >= n || node_levels[id] < layer {
return Err(bad("neighbour id out of range for its layer"));
}
neighbors.push(id);
}
*slot = neighbors;
}
graph.push(adjacency);
}
if pos != body.len() {
return Err(bad("trailing bytes"));
}
Ok(Self {
vectors: vectors.into_iter().map(|v| prepare(v, metric)).collect(),
graph,
deleted,
entry_point,
m,
m_max0,
ef_construction,
node_levels,
metric,
})
}
/// Returns the number of vectors in the index.
pub fn len(&self) -> usize {
self.vectors.len()
@@ -778,9 +964,62 @@ fn search_layer(
distance: ep_dist,
});
let mut visited = HashSet::new();
VISITED.with_borrow_mut(|visited| {
visited.begin(vectors.len());
visited.insert(ep);
search_layer_visit(
vectors, layer, query, ef, metric, visited, candidates, results,
)
})
}
/// Which nodes a layer search has already seen. A `HashSet` allocated per call
/// was the hottest non-arithmetic cost in both build and query; this is one
/// `u32` stamp per node, reused across calls: a node is visited iff its stamp
/// equals the current epoch, so "clearing" is just bumping the epoch.
#[derive(Default)]
struct Visited {
stamps: Vec<u32>,
epoch: u32,
}
impl Visited {
fn begin(&mut self, n: usize) {
if self.stamps.len() < n {
self.stamps.resize(n, 0);
}
self.epoch = self.epoch.wrapping_add(1);
if self.epoch == 0 {
// Wrapped: stale stamps could collide with the new epoch.
self.stamps.iter_mut().for_each(|s| *s = 0);
self.epoch = 1;
}
}
/// Mark `id` visited; `true` if it was not already.
fn insert(&mut self, id: usize) -> bool {
let seen = self.stamps[id] == self.epoch;
self.stamps[id] = self.epoch;
!seen
}
}
thread_local! {
/// Per-thread scratch, so `search(&self)` stays shareable across threads.
static VISITED: std::cell::RefCell<Visited> = std::cell::RefCell::new(Visited::default());
}
#[allow(clippy::too_many_arguments)]
fn search_layer_visit(
vectors: &[Vec<f32>],
layer: &[Vec<usize>],
query: &[f32],
ef: usize,
metric: DistanceMetric,
visited: &mut Visited,
mut candidates: BinaryHeap<Candidate>,
mut results: BinaryHeap<FarCandidate>,
) -> Vec<Candidate> {
while let Some(closest) = candidates.pop() {
let furthest_dist = results.peek().map_or(f32::MAX, |f| f.distance);
if closest.distance > furthest_dist && results.len() >= ef {
@@ -788,10 +1027,9 @@ fn search_layer(
}
for &neighbor in &layer[closest.id] {
if visited.contains(&neighbor) {
if !visited.insert(neighbor) {
continue;
}
visited.insert(neighbor);
let d = compute_distance(query, &vectors[neighbor], metric);
let furthest_dist = results.peek().map_or(f32::MAX, |f| f.distance);
@@ -828,7 +1066,52 @@ fn search_layer(
result
}
/// Prune connections for a node to keep only the closest `max_conn` neighbors.
/// Choose up to `max_conn` neighbours for a node from `candidates` (sorted by
/// ascending distance to that node) — the HNSW paper's Algorithm 4 with
/// `keepPrunedConnections`.
///
/// Taking the plain `max_conn` closest is what breaks the graph on clustered
/// data: every link of a node inside a tight cluster goes to that same cluster,
/// so clusters become islands that a search entering elsewhere can never
/// reach, however large `ef` is. Instead a candidate is accepted only if it is
/// closer to the node than to every neighbour already accepted, which spreads
/// links across directions and keeps the long edges that join clusters. Any
/// remaining slots are then filled with the closest rejected candidates, so a
/// node is never left under-connected.
fn select_neighbors(
vectors: &[Vec<f32>],
candidates: &[(usize, f32)],
max_conn: usize,
metric: DistanceMetric,
) -> Vec<usize> {
if candidates.len() <= max_conn {
return candidates.iter().map(|&(id, _)| id).collect();
}
let mut selected: Vec<usize> = Vec::with_capacity(max_conn);
let mut rejected: Vec<usize> = Vec::new();
for &(id, dist_to_node) in candidates {
if selected.len() >= max_conn {
break;
}
let diverse = selected
.iter()
.all(|&s| compute_distance(&vectors[id], &vectors[s], metric) > dist_to_node);
if diverse {
selected.push(id);
} else {
rejected.push(id);
}
}
for id in rejected {
if selected.len() >= max_conn {
break;
}
selected.push(id);
}
selected
}
/// Trim `node`'s neighbour list back to `max_conn` with [`select_neighbors`].
fn prune_connections(
vectors: &[Vec<f32>],
neighbors: &mut Vec<usize>,
@@ -839,22 +1122,12 @@ fn prune_connections(
if neighbors.len() <= max_conn {
return;
}
#[cfg(feature = "parallel")]
let mut scored: Vec<(usize, f32)> = {
use rayon::prelude::*;
neighbors
.par_iter()
.map(|&n| (n, compute_distance(&vectors[node], &vectors[n], metric)))
.collect()
};
#[cfg(not(feature = "parallel"))]
let mut scored: Vec<(usize, f32)> = neighbors
.iter()
.map(|&n| (n, compute_distance(&vectors[node], &vectors[n], metric)))
.collect();
scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(max_conn);
*neighbors = scored.into_iter().map(|(id, _)| id).collect();
scored.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
*neighbors = select_neighbors(vectors, &scored, max_conn, metric);
}
// ---------------------------------------------------------------------------
@@ -1042,6 +1315,172 @@ fn get_attr_string(attrs: &[(String, AttrValue)], name: &str) -> Result<String,
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
/// Tight, well-separated clusters — the shape real embeddings have, and
/// the case plain closest-M neighbour selection fails on: each cluster
/// becomes an island, so recall is capped no matter how large `ef` is.
fn clustered(n: usize, dim: usize, clusters: usize, seed: u64) -> Vec<Vec<f32>> {
let mut state = seed;
let mut next = move || {
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
((z ^ (z >> 31)) >> 40) as f32 / (1u64 << 24) as f32 - 0.5
};
let centres: Vec<Vec<f32>> = (0..clusters)
.map(|_| (0..dim).map(|_| next() * 10.0).collect())
.collect();
(0..n)
.map(|i| {
centres[i % clusters]
.iter()
.map(|c| c + next() * 0.5)
.collect()
})
.collect()
}
fn recall_at_10(
index: &HnswIndex,
vectors: &[Vec<f32>],
queries: &[Vec<f32>],
ef: usize,
) -> f64 {
let mut hits = 0;
for q in queries {
let mut exact: Vec<(usize, f32)> = vectors
.iter()
.enumerate()
.map(|(i, v)| (i, compute_distance(q, v, DistanceMetric::L2)))
.collect();
exact.sort_by(|a, b| a.1.total_cmp(&b.1));
let want: Vec<usize> = exact[..10].iter().map(|e| e.0).collect();
hits += index
.search(q, 10, ef)
.iter()
.filter(|(id, _)| want.contains(id))
.count();
}
hits as f64 / (10 * queries.len()) as f64
}
#[test]
fn clustered_data_keeps_high_recall() {
// Data and queries come from the same clusters: one draw, split.
let mut vectors = clustered(3060, 24, 30, 1);
let queries = vectors.split_off(3000);
let built = HnswIndex::build_with_metric(&vectors, 8, 40, DistanceMetric::L2);
let recall = recall_at_10(&built, &vectors, &queries, 64);
assert!(recall >= 0.95, "bulk build recall@10 = {recall}");
// Incremental inserts go through the same neighbour selection.
let mut incremental = HnswIndex::new(8, 40, DistanceMetric::L2);
for v in &vectors {
incremental.insert(v.clone());
}
let recall = recall_at_10(&incremental, &vectors, &queries, 64);
assert!(recall >= 0.95, "incremental recall@10 = {recall}");
}
#[test]
fn graph_bytes_round_trip_gives_identical_searches() {
let mut vectors = clustered(1260, 16, 12, 9);
let queries = vectors.split_off(1200);
let mut index = HnswIndex::build_with_metric(&vectors, 8, 40, DistanceMetric::L2);
index.mark_deleted(3);
index.mark_deleted(700);
let bytes = index.graph_to_bytes();
// The graph is a small fraction of the vectors it indexes... not
// necessarily at dim 16, but it must not embed them.
assert!(bytes.len() < 1200 * (16 * 2 + 2) * 4);
let restored = HnswIndex::from_graph_bytes(&bytes, vectors.clone()).unwrap();
assert_eq!(restored.deleted_count(), 2);
for q in &queries {
assert_eq!(restored.search(q, 10, 50), index.search(q, 10, 50));
}
// A restored index keeps working incrementally.
let mut restored = restored;
let id = restored.insert(queries[0].clone());
assert_eq!(restored.search(&queries[0], 1, 50)[0].0, id);
}
#[test]
fn damaged_or_mismatched_graph_bytes_are_errors() {
let vectors = clustered(300, 8, 6, 4);
let index = HnswIndex::build_with_metric(&vectors, 6, 30, DistanceMetric::Cosine);
let bytes = index.graph_to_bytes();
// Wrong vector set.
assert!(HnswIndex::from_graph_bytes(&bytes, vectors[..299].to_vec()).is_err());
// Every truncation.
for len in 0..bytes.len() {
assert!(
HnswIndex::from_graph_bytes(&bytes[..len], vectors.clone()).is_err(),
"truncated to {len}"
);
}
// A flipped bit anywhere.
for i in (0..bytes.len()).step_by(7) {
let mut damaged = bytes.clone();
damaged[i] ^= 0x10;
assert!(
HnswIndex::from_graph_bytes(&damaged, vectors.clone()).is_err(),
"bit flip at {i}"
);
}
}
#[test]
fn structurally_invalid_graph_with_a_valid_checksum_is_rejected() {
// The CRC only proves the bytes are what was written; a hostile or
// buggy writer can checksum nonsense. Out-of-range neighbour ids must
// still be caught, or search would index out of bounds.
let vectors = clustered(50, 4, 3, 5);
let index = HnswIndex::build_with_metric(&vectors, 4, 20, DistanceMetric::L2);
let mut bytes = index.graph_to_bytes();
let body_len = bytes.len() - 4;
// First neighbour id of node 0 on layer 0 sits right after the header,
// levels, tombstones and node 0's count.
let at = 4 + 7 * 4 + 50 + 50 + 4;
bytes[at..at + 4].copy_from_slice(&9999u32.to_le_bytes());
let crc = clawhdf5_format::checksum::crc32(&bytes[..body_len]);
bytes[body_len..].copy_from_slice(&crc.to_le_bytes());
assert!(HnswIndex::from_graph_bytes(&bytes, vectors).is_err());
}
#[test]
fn select_neighbors_prefers_diverse_directions_and_fills_up() {
// Node at the origin. Three candidates bunched together on the right,
// one on the left. With room for two, plain closest-M would take two
// from the bunch and lose the only link leftwards.
let vectors = vec![
vec![0.0, 0.0], // 0: the node
vec![1.0, 0.0], // 1
vec![1.1, 0.0], // 2
vec![1.2, 0.0], // 3
vec![-2.0, 0.0], // 4
];
let scored: Vec<(usize, f32)> = (1..5)
.map(|i| {
(
i,
compute_distance(&vectors[0], &vectors[i], DistanceMetric::L2),
)
})
.collect();
assert_eq!(
select_neighbors(&vectors, &scored, 2, DistanceMetric::L2),
[1, 4]
);
// Spare capacity is filled with the closest rejected candidates.
assert_eq!(
select_neighbors(&vectors, &scored, 3, DistanceMetric::L2),
[1, 4, 2]
);
}
fn make_random_vectors(n: usize, dim: usize, seed: u64) -> Vec<Vec<f32>> {
let mut vectors = Vec::with_capacity(n);
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-bench"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "Benchmark harnesses for clawhdf5-agent (Track 8)"
license = "MIT"
@@ -13,6 +13,10 @@ path = "src/bin/longmemeval_bench.rs"
name = "memory_arena"
path = "src/bin/memory_arena.rs"
[[bin]]
name = "search_harness"
path = "src/bin/search_harness.rs"
[[bin]]
name = "footprint_bench"
path = "src/bin/footprint_bench.rs"
@@ -48,6 +52,7 @@ harness = false
[dependencies]
clawhdf5-agent = { path = "../clawhdf5-agent" }
clawhdf5-ann = { path = "../clawhdf5-ann" }
clawhdf5-io = { path = "../clawhdf5-io" }
mpi = { version = "0.8", optional = true }
serde = { workspace = true }
@@ -242,7 +242,12 @@ fn run_quality_benchmark() {
for i in 0..990 {
let chunk = make_noise_content(i);
let embedding = make_embedding(i + 100);
engine.add_trusted_memory(chunk, embedding, TrustedSource::System, now + i as f64 * 0.1);
engine.add_trusted_memory(
chunk,
embedding,
TrustedSource::System,
now + i as f64 * 0.1,
);
}
println!(" → Inserted {} records total", engine.records().len());
@@ -0,0 +1,508 @@
//! Search measurement harness: recall vs. speed for the HNSW index, and
//! end-to-end `hybrid_search` latency as the store grows.
//!
//! Every search-path change should be justified by a before/after run of this
//! binary. It reports, for deterministic synthetic data:
//!
//! * **ANN** — index build time, and for each `ef`: recall@10 against an exact
//! brute-force scan, queries/second, and p50/p99 latency.
//! * **End to end** — `HDF5Memory`: ingest time, checkpoint time, `open()`
//! time, the one-off cold index build (first query ever), the first query
//! after a reopen, and steady-state `hybrid_search` p50/p99 at each size.
//!
//! Data is *clustered* (points = cluster centre + noise, unit-normalised), not
//! uniform: uniform random high-dimensional vectors are nearly equidistant,
//! which makes recall numbers meaningless and is nothing like embeddings.
//!
//! ```text
//! cargo run --release -p clawhdf5-bench --bin search_harness # 1K, 10K
//! cargo run --release -p clawhdf5-bench --bin search_harness -- --full # + 100K
//! cargo run --release -p clawhdf5-bench --bin search_harness -- --json out.json
//! cargo run --release -p clawhdf5-bench --bin search_harness -- --ann-only --uniform
//! ```
use std::time::{Duration, Instant};
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
use clawhdf5_ann::{DistanceMetric, HnswIndex};
const DIM: usize = 384;
const K: usize = 10;
const N_QUERIES: usize = 200;
const HNSW_M: usize = 16;
const HNSW_EF_CONSTRUCTION: usize = 64;
const EF_VALUES: [usize; 5] = [16, 32, 64, 128, 256];
// ---------------------------------------------------------------------------
// Deterministic data
// ---------------------------------------------------------------------------
struct Rng(u64);
impl Rng {
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Uniform in [0, 1).
fn unit(&mut self) -> f32 {
(self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
}
/// Approximately standard normal (sum of uniforms).
fn gauss(&mut self) -> f32 {
let sum: f32 = (0..6).map(|_| self.unit()).sum();
(sum - 3.0) * std::f32::consts::SQRT_2
}
fn below(&mut self, n: usize) -> usize {
(self.next_u64() % n as u64) as usize
}
}
fn normalize(v: &mut [f32]) {
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 0.0 {
v.iter_mut().for_each(|x| *x /= norm);
}
}
struct Dataset {
vectors: Vec<Vec<f32>>,
queries: Vec<Vec<f32>>,
/// Cluster id of each vector (used to give records topical text).
cluster_of: Vec<usize>,
query_cluster: Vec<usize>,
}
/// `--uniform`: isotropic random unit vectors instead of clusters. Not a
/// realistic workload, but a useful second distribution — a recall problem
/// that appears only on clustered data points at graph connectivity.
static UNIFORM: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn make_dataset(n: usize, seed: u64) -> Dataset {
let mut rng = Rng(seed);
if UNIFORM.load(std::sync::atomic::Ordering::Relaxed) {
let random_unit = |rng: &mut Rng| {
let mut v: Vec<f32> = (0..DIM).map(|_| rng.gauss()).collect();
normalize(&mut v);
v
};
return Dataset {
vectors: (0..n).map(|_| random_unit(&mut rng)).collect(),
queries: (0..N_QUERIES).map(|_| random_unit(&mut rng)).collect(),
cluster_of: vec![0; n],
query_cluster: vec![0; N_QUERIES],
};
}
let n_clusters = (n / 100).clamp(8, 512);
let centres: Vec<Vec<f32>> = (0..n_clusters)
.map(|_| {
let mut c: Vec<f32> = (0..DIM).map(|_| rng.gauss()).collect();
normalize(&mut c);
c
})
.collect();
let point = |rng: &mut Rng, cluster: usize| {
// Noise comparable to the centre's per-dimension magnitude, so
// clusters overlap and the nearest neighbours are non-trivial.
let scale = 0.6 / (DIM as f32).sqrt();
let mut v: Vec<f32> = centres[cluster]
.iter()
.map(|c| c + rng.gauss() * scale)
.collect();
normalize(&mut v);
v
};
let mut vectors = Vec::with_capacity(n);
let mut cluster_of = Vec::with_capacity(n);
for _ in 0..n {
let c = rng.below(n_clusters);
vectors.push(point(&mut rng, c));
cluster_of.push(c);
}
let mut queries = Vec::with_capacity(N_QUERIES);
let mut query_cluster = Vec::with_capacity(N_QUERIES);
for _ in 0..N_QUERIES {
let c = rng.below(n_clusters);
queries.push(point(&mut rng, c));
query_cluster.push(c);
}
Dataset {
vectors,
queries,
cluster_of,
query_cluster,
}
}
const WORDS: &[&str] = &[
"deploy", "latency", "cache", "schema", "index", "vector", "memory", "agent", "kernel",
"buffer", "socket", "thread", "tensor", "gradient", "ledger", "invoice", "meeting", "roadmap",
"customer", "contract", "sensor", "orbit", "protein", "genome", "harbor", "bridge", "engine",
"battery", "harvest", "weather", "museum", "recipe",
];
/// Text whose vocabulary is biased by cluster, so keyword and vector signals
/// agree the way they do for real embedded text.
fn text_for(cluster: usize, i: usize, rng: &mut Rng) -> String {
let topic = [
WORDS[cluster % WORDS.len()],
WORDS[(cluster / 7 + 3) % WORDS.len()],
];
let mut words = Vec::with_capacity(14);
for j in 0..14 {
if j % 3 == 0 {
words.push(topic[j / 3 % 2]);
} else {
words.push(WORDS[rng.below(WORDS.len())]);
}
}
format!("record {i}: {}", words.join(" "))
}
// ---------------------------------------------------------------------------
// Measurement helpers
// ---------------------------------------------------------------------------
fn exact_top_k(vectors: &[Vec<f32>], query: &[f32], k: usize) -> Vec<usize> {
// Vectors are unit length, so cosine order == dot-product order.
let mut scored: Vec<(usize, f32)> = vectors
.iter()
.enumerate()
.map(|(i, v)| (i, v.iter().zip(query).map(|(a, b)| a * b).sum()))
.collect();
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
scored.truncate(k);
scored.into_iter().map(|(i, _)| i).collect()
}
struct Latency {
p50: Duration,
p99: Duration,
qps: f64,
}
fn summarize(mut samples: Vec<Duration>) -> Latency {
samples.sort();
let total: Duration = samples.iter().sum();
let at = |q: f64| samples[((samples.len() - 1) as f64 * q).round() as usize];
Latency {
p50: at(0.50),
p99: at(0.99),
qps: samples.len() as f64 / total.as_secs_f64(),
}
}
fn micros(d: Duration) -> f64 {
d.as_secs_f64() * 1e6
}
fn millis(d: Duration) -> f64 {
d.as_secs_f64() * 1e3
}
// ---------------------------------------------------------------------------
// ANN: recall vs speed
// ---------------------------------------------------------------------------
fn bench_ann(n: usize, json: &mut Vec<serde_json::Value>) {
let data = make_dataset(n, 0xA11CE ^ n as u64);
let truth: Vec<Vec<usize>> = data
.queries
.iter()
.map(|q| exact_top_k(&data.vectors, q, K))
.collect();
let started = Instant::now();
let index = HnswIndex::build_with_metric(
&data.vectors,
HNSW_M,
HNSW_EF_CONSTRUCTION,
DistanceMetric::Cosine,
);
let build = started.elapsed();
// Exact scan baseline, for scale.
let exact = summarize(
data.queries
.iter()
.map(|q| {
let t = Instant::now();
std::hint::black_box(exact_top_k(&data.vectors, q, K));
t.elapsed()
})
.collect(),
);
println!(
"\n### HNSW, N = {n}, dim = {DIM}, M = {HNSW_M}, ef_construction = {HNSW_EF_CONSTRUCTION}\n"
);
println!(
"build: {:.1} ms ({:.0} vectors/s) · exact scan: {:.0} QPS, p50 {:.0} µs\n",
millis(build),
n as f64 / build.as_secs_f64(),
exact.qps,
micros(exact.p50)
);
println!("| ef | recall@{K} | QPS | p50 µs | p99 µs |");
println!("|---:|---:|---:|---:|---:|");
for ef in EF_VALUES {
let mut hits = 0usize;
let mut samples = Vec::with_capacity(data.queries.len());
for (q, want) in data.queries.iter().zip(&truth) {
let t = Instant::now();
let got = index.search(q, K, ef);
samples.push(t.elapsed());
hits += got.iter().filter(|(id, _)| want.contains(id)).count();
}
let recall = hits as f64 / (K * data.queries.len()) as f64;
let lat = summarize(samples);
println!(
"| {ef} | {recall:.4} | {:.0} | {:.0} | {:.0} |",
lat.qps,
micros(lat.p50),
micros(lat.p99)
);
json.push(serde_json::json!({
"bench": "hnsw", "n": n, "ef": ef, "recall_at_10": recall,
"qps": lat.qps, "p50_us": micros(lat.p50), "p99_us": micros(lat.p99),
"build_ms": millis(build),
}));
}
}
// ---------------------------------------------------------------------------
// End to end: HDF5Memory::hybrid_search
// ---------------------------------------------------------------------------
fn bench_end_to_end(n: usize, json: &mut Vec<serde_json::Value>) {
let data = make_dataset(n, 0xE2E ^ n as u64);
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("store.h5");
let mut rng = Rng(7);
let entries: Vec<MemoryEntry> = data
.vectors
.iter()
.enumerate()
.map(|(i, v)| MemoryEntry {
chunk: text_for(data.cluster_of[i], i, &mut rng),
embedding: v.clone(),
source_channel: "bench".into(),
timestamp: i as f64,
session_id: format!("s{}", i % 50),
tags: format!("t{i}"),
})
.collect();
let query_texts: Vec<String> = data
.query_cluster
.iter()
.enumerate()
.map(|(i, c)| text_for(*c, i, &mut rng))
.collect();
let mut mem = HDF5Memory::create(MemoryConfig::new(path.clone(), "bench", DIM)).unwrap();
let t = Instant::now();
mem.save_batch(entries).unwrap();
let ingest = t.elapsed();
// The very first query builds the vector and keyword indexes from
// scratch. It happens once per store, not once per session: the checkpoint
// below saves the vector index, so a later `open()` reloads it.
let t = Instant::now();
std::hint::black_box(mem.hybrid_search(&data.queries[1], &query_texts[1], 0.7, 0.3, K));
let cold_build = t.elapsed();
let t = Instant::now();
mem.flush_wal().unwrap();
let checkpoint = t.elapsed();
drop(mem);
let t = Instant::now();
let mut mem = HDF5Memory::open(&path).unwrap();
let open = t.elapsed();
// The first query after open pays for whatever is rebuilt lazily.
let t = Instant::now();
std::hint::black_box(mem.hybrid_search(&data.queries[0], &query_texts[0], 0.7, 0.3, K));
let first_query = t.elapsed();
// Fewer steady-state samples at large N: each query is currently O(N).
let samples_wanted = if n >= 100_000 { 20 } else { N_QUERIES.min(100) };
let steady = summarize(
(0..samples_wanted)
.map(|i| {
let t = Instant::now();
std::hint::black_box(mem.hybrid_search(
&data.queries[i % N_QUERIES],
&query_texts[i % N_QUERIES],
0.7,
0.3,
K,
));
t.elapsed()
})
.collect(),
);
println!(
"| {n} | {:.0} | {:.0} | {:.1} | {:.1} | {:.1} | {:.2} | {:.2} | {:.1} |",
millis(ingest),
millis(cold_build),
millis(checkpoint),
millis(open),
millis(first_query),
millis(steady.p50),
millis(steady.p99),
steady.qps
);
json.push(serde_json::json!({
"bench": "hybrid_search", "n": n,
"ingest_ms": millis(ingest), "cold_index_build_ms": millis(cold_build),
"checkpoint_ms": millis(checkpoint),
"open_ms": millis(open), "first_query_ms": millis(first_query),
"p50_ms": millis(steady.p50), "p99_ms": millis(steady.p99), "qps": steady.qps,
}));
}
// ---------------------------------------------------------------------------
// Fusion study: does capping the keyword candidate pool change the ranking?
// ---------------------------------------------------------------------------
/// `hybrid_search` min-max normalises each signal over the candidates it is
/// given. The vector stage supplies a pool of `max(8k, 64)`; the keyword stage
/// supplies *every* matching record, which is what now dominates query time.
/// This compares the current fusion with one whose keyword stage is capped to
/// a pool, reporting how often the final top-k agree and what each costs.
fn fusion_study(n: usize) {
use clawhdf5_agent::bm25::BM25Index;
use clawhdf5_agent::hybrid::merge_vector_keyword;
let data = make_dataset(n, 0xE2E ^ n as u64);
let mut rng = Rng(7);
let texts: Vec<String> = (0..n)
.map(|i| text_for(data.cluster_of[i], i, &mut rng))
.collect();
let query_texts: Vec<String> = data
.query_cluster
.iter()
.enumerate()
.map(|(i, c)| text_for(*c, i, &mut rng))
.collect();
let bm25 = BM25Index::build(&texts, &vec![0u8; n]);
let index = HnswIndex::build_with_metric(
&data.vectors,
HNSW_M,
HNSW_EF_CONSTRUCTION,
DistanceMetric::Cosine,
);
let vec_pool = (K * 8).max(64);
println!("\n### Fusion study, N = {n} (k = {K}, weights 0.7 / 0.3, vector pool {vec_pool})\n");
println!(
"| keyword pool | top-{K} overlap vs full | identical top-{K} | same #1 | keyword+merge µs |"
);
println!("|---:|---:|---:|---:|---:|");
let fuse = |q: usize, kw_pool: usize| -> (Vec<usize>, Duration) {
let vec_scores: Vec<(usize, f32)> = index
.search(&data.queries[q], vec_pool, vec_pool)
.into_iter()
.map(|(id, d)| (id, 1.0 - d))
.collect();
let t = Instant::now();
let kw = bm25.search(&query_texts[q], kw_pool);
let merged = merge_vector_keyword(vec_scores, kw, 0.7, 0.3, K);
let took = t.elapsed();
(merged.into_iter().map(|(id, _)| id).collect(), took)
};
let full: Vec<(Vec<usize>, Duration)> = (0..N_QUERIES).map(|q| fuse(q, n)).collect();
let full_time: Duration = full.iter().map(|f| f.1).sum();
println!(
"| all ({n}) | 1.0000 | 100.0% | 100.0% | {:.0} |",
micros(full_time) / N_QUERIES as f64
);
for pool in [vec_pool, vec_pool * 4, 1000] {
if pool >= n {
continue;
}
let (mut overlap, mut identical, mut same_first) = (0usize, 0usize, 0usize);
let mut time = Duration::ZERO;
for (q, (want, _)) in full.iter().enumerate() {
let (got, took) = fuse(q, pool);
time += took;
overlap += got.iter().filter(|id| want.contains(id)).count();
identical += usize::from(&got == want);
same_first += usize::from(got.first() == want.first());
}
println!(
"| {pool} | {:.4} | {:.1}% | {:.1}% | {:.0} |",
overlap as f64 / (K * N_QUERIES) as f64,
100.0 * identical as f64 / N_QUERIES as f64,
100.0 * same_first as f64 / N_QUERIES as f64,
micros(time) / N_QUERIES as f64
);
}
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let full = args.iter().any(|a| a == "--full");
let ann_only = args.iter().any(|a| a == "--ann-only");
if args.iter().any(|a| a == "--fusion-study") {
for &n in if full {
&[10_000, 100_000][..]
} else {
&[10_000][..]
} {
fusion_study(n);
}
return;
}
if args.iter().any(|a| a == "--uniform") {
UNIFORM.store(true, std::sync::atomic::Ordering::Relaxed);
println!("(uniform random data)");
}
let json_path = args
.iter()
.position(|a| a == "--json")
.and_then(|i| args.get(i + 1))
.cloned();
let sizes: &[usize] = if full {
&[1_000, 10_000, 100_000]
} else {
&[1_000, 10_000]
};
if cfg!(debug_assertions) {
eprintln!("warning: debug build — numbers are meaningless. Use --release.");
}
let mut json = Vec::new();
println!("## Search harness");
for &n in sizes {
bench_ann(n, &mut json);
}
if ann_only {
return;
}
println!("\n### End to end: `HDF5Memory::hybrid_search` (k = {K}, weights 0.7 / 0.3)\n");
println!(
"| N | ingest ms | cold index build ms | checkpoint ms | open ms | first query after open ms | p50 ms | p99 ms | QPS |"
);
println!("|---:|---:|---:|---:|---:|---:|---:|---:|---:|");
for &n in sizes {
bench_end_to_end(n, &mut json);
}
if let Some(path) = json_path {
std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
eprintln!("wrote {path}");
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-cli"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
license = "MIT"
description = "CLI for clawhdf5 agent memory — create, save, search, recall, stats"
@@ -14,7 +14,7 @@ name = "clawhdf5"
path = "src/main.rs"
[dependencies]
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.2.0" }
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.4.0" }
clap = { version = "4", features = ["derive", "env"] }
serde_json = "1"
serde = { workspace = true }
+4 -4
View File
@@ -146,7 +146,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
}
Commands::Recall { index } => {
let mem = HDF5Memory::open(&cli.path)?;
let mem = HDF5Memory::open_read_only(&cli.path)?;
match mem.get_chunk(index) {
Some(content) => {
let j = serde_json::json!({ "index": index, "chunk": content });
@@ -160,7 +160,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
}
Commands::Stats => {
let mem = HDF5Memory::open(&cli.path)?;
let mem = HDF5Memory::open_read_only(&cli.path)?;
let cfg = mem.config();
let j = serde_json::json!({
"path": cli.path.display().to_string(),
@@ -187,7 +187,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
}
Commands::AgentsMd { output } => {
let mem = HDF5Memory::open(&cli.path)?;
let mem = HDF5Memory::open_read_only(&cli.path)?;
let md = mem.generate_agents_md();
match output {
Some(p) => {
@@ -199,7 +199,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
}
Commands::Export => {
let mem = HDF5Memory::open(&cli.path)?;
let mem = HDF5Memory::open_read_only(&cli.path)?;
for i in 0..mem.count() {
if let Some(chunk) = mem.get_chunk(i) {
let j = serde_json::json!({ "index": i, "chunk": chunk });
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-derive"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "Derive macros for rustyhdf5 HDF5 traits"
license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-filters"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "Filter and compression pipeline for clawhdf5"
license = "MIT"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-format"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "Pure-Rust HDF5 binary format parsing and writing — no C dependencies"
license = "MIT"
@@ -25,7 +25,7 @@ pco = { version = "1.0", optional = true }
[dev-dependencies]
serde_json = "1"
criterion = { workspace = true }
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.2.0" }
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.4.0" }
[[bench]]
name = "bench"
+115 -16
View File
@@ -1,7 +1,9 @@
//! HDF5 Attribute message parsing (message type 0x000C).
#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};
use alloc::{borrow::Cow, string::String, vec::Vec};
#[cfg(feature = "std")]
use std::borrow::Cow;
use crate::attribute_info::AttributeInfoMessage;
use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records};
@@ -48,17 +50,64 @@ impl AttributeMessage {
///
/// `length_size` is needed for dataspace dimension parsing.
pub fn parse(data: &[u8], length_size: u8) -> Result<AttributeMessage, FormatError> {
Self::parse_impl(data, length_size, None)
}
/// [`AttributeMessage::parse`] with access to the rest of the file, which
/// is needed when the attribute's datatype or dataspace is *shared* (v2/v3
/// flag bits 0/1) — e.g. an attribute created with a committed datatype.
/// In that case the embedded bytes are a reference to the real message,
/// not the message. Without file access such an attribute is an error
/// rather than a garbage datatype.
pub fn parse_in_file(
data: &[u8],
file_data: &[u8],
offset_size: u8,
length_size: u8,
) -> Result<AttributeMessage, FormatError> {
Self::parse_impl(data, length_size, Some((file_data, offset_size)))
}
fn parse_impl(
data: &[u8],
length_size: u8,
file: Option<(&[u8], u8)>,
) -> Result<AttributeMessage, FormatError> {
ensure_len(data, 0, 2)?;
let version = data[0];
match version {
1 => Self::parse_v1(data, length_size),
2 => Self::parse_v2(data, length_size),
3 => Self::parse_v3(data, length_size),
2 => Self::parse_v2(data, length_size, file),
3 => Self::parse_v3(data, length_size, file),
_ => Err(FormatError::InvalidAttributeVersion(version)),
}
}
/// The bytes of an embedded datatype/dataspace message, following the
/// shared-message reference when `shared` is set.
fn embedded_message<'a>(
bytes: &'a [u8],
shared: bool,
msg_type: MessageType,
length_size: u8,
file: Option<(&[u8], u8)>,
) -> Result<Cow<'a, [u8]>, FormatError> {
if !shared {
return Ok(Cow::Borrowed(bytes));
}
let (file_data, offset_size) = file.ok_or(FormatError::UnresolvedSharedMessage)?;
let shared_ref = shared_message::parse_shared_ref(bytes, offset_size)?;
shared_message::resolve_shared_message(
file_data,
&shared_ref,
msg_type,
offset_size,
length_size,
)
.map(Cow::Owned)
}
fn parse_v1(data: &[u8], length_size: u8) -> Result<AttributeMessage, FormatError> {
// version(1) + reserved(1) + name_size(2) + datatype_size(2) + dataspace_size(2) = 8
ensure_len(data, 0, 8)?;
@@ -94,7 +143,13 @@ impl AttributeMessage {
})
}
fn parse_v2(data: &[u8], length_size: u8) -> Result<AttributeMessage, FormatError> {
fn parse_v2(
data: &[u8],
length_size: u8,
file: Option<(&[u8], u8)>,
) -> Result<AttributeMessage, FormatError> {
// Flags: bit 0 = datatype is shared, bit 1 = dataspace is shared.
let flags = data.get(1).copied().unwrap_or(0);
// version(1) + flags(1) + name_size(2) + datatype_size(2) + dataspace_size(2) = 8
ensure_len(data, 0, 8)?;
let name_size = u16::from_le_bytes([data[2], data[3]]) as usize;
@@ -110,12 +165,26 @@ impl AttributeMessage {
// Datatype (NO padding)
ensure_len(data, pos, datatype_size)?;
let (datatype, _) = Datatype::parse(&data[pos..pos + datatype_size])?;
let dt_bytes = Self::embedded_message(
&data[pos..pos + datatype_size],
flags & 0x01 != 0,
MessageType::Datatype,
length_size,
file,
)?;
let (datatype, _) = Datatype::parse(&dt_bytes)?;
pos += datatype_size;
// Dataspace (NO padding)
ensure_len(data, pos, dataspace_size)?;
let dataspace = Dataspace::parse(&data[pos..pos + dataspace_size], length_size)?;
let ds_bytes = Self::embedded_message(
&data[pos..pos + dataspace_size],
flags & 0x02 != 0,
MessageType::Dataspace,
length_size,
file,
)?;
let dataspace = Dataspace::parse(&ds_bytes, length_size)?;
pos += dataspace_size;
let raw_data = compute_raw_data(data, pos, &dataspace, &datatype);
@@ -128,7 +197,13 @@ impl AttributeMessage {
})
}
fn parse_v3(data: &[u8], length_size: u8) -> Result<AttributeMessage, FormatError> {
fn parse_v3(
data: &[u8],
length_size: u8,
file: Option<(&[u8], u8)>,
) -> Result<AttributeMessage, FormatError> {
// Flags: bit 0 = datatype is shared, bit 1 = dataspace is shared.
let flags = data.get(1).copied().unwrap_or(0);
// version(1) + flags(1) + name_size(2) + datatype_size(2) + dataspace_size(2) + encoding(1) = 9
ensure_len(data, 0, 9)?;
let name_size = u16::from_le_bytes([data[2], data[3]]) as usize;
@@ -145,12 +220,26 @@ impl AttributeMessage {
// Datatype (NO padding)
ensure_len(data, pos, datatype_size)?;
let (datatype, _) = Datatype::parse(&data[pos..pos + datatype_size])?;
let dt_bytes = Self::embedded_message(
&data[pos..pos + datatype_size],
flags & 0x01 != 0,
MessageType::Datatype,
length_size,
file,
)?;
let (datatype, _) = Datatype::parse(&dt_bytes)?;
pos += datatype_size;
// Dataspace (NO padding)
ensure_len(data, pos, dataspace_size)?;
let dataspace = Dataspace::parse(&data[pos..pos + dataspace_size], length_size)?;
let ds_bytes = Self::embedded_message(
&data[pos..pos + dataspace_size],
flags & 0x02 != 0,
MessageType::Dataspace,
length_size,
file,
)?;
let dataspace = Dataspace::parse(&ds_bytes, length_size)?;
pos += dataspace_size;
let raw_data = compute_raw_data(data, pos, &dataspace, &datatype);
@@ -326,10 +415,20 @@ pub fn extract_attributes_full(
offset_size,
length_size,
)?;
let attr = AttributeMessage::parse(&resolved_data, length_size)?;
let attr = AttributeMessage::parse_in_file(
&resolved_data,
file_data,
offset_size,
length_size,
)?;
attrs.push(attr);
} else {
let attr = AttributeMessage::parse(&msg.data, length_size)?;
let attr = AttributeMessage::parse_in_file(
&msg.data,
file_data,
offset_size,
length_size,
)?;
attrs.push(attr);
}
}
@@ -399,7 +498,8 @@ fn extract_dense_attributes(
let attr_data = fh.read_managed_object(file_data, id_bytes, offset_size)?;
// The data in the heap is a complete attribute message
let attr = AttributeMessage::parse(&attr_data, length_size)?;
let attr =
AttributeMessage::parse_in_file(&attr_data, file_data, offset_size, length_size)?;
attrs.push(attr);
}
@@ -472,14 +572,13 @@ mod tests {
// Name padded to 8 bytes
data.extend_from_slice(name);
while data.len() % 8 != 0 || data.len() == 8 {
if data.len() % 8 != 0 || data.len() == 8 {
// Pad name to 8-byte boundary from start of name
let name_start = 8;
let name_padded = pad8(name_size);
while data.len() < name_start + name_padded {
data.push(0);
}
break;
}
// Datatype padded to 8 bytes
@@ -749,11 +848,11 @@ mod tests {
data.extend_from_slice(name);
data.extend_from_slice(&dt_bytes);
data.extend_from_slice(&ds_bytes);
data.extend_from_slice(&3.14f64.to_le_bytes());
data.extend_from_slice(&3.25f64.to_le_bytes());
let attr = AttributeMessage::parse(&data, 8).unwrap();
let vals = attr.read_as_f64().unwrap();
assert_eq!(vals, vec![3.14]);
assert_eq!(vals, vec![3.25]);
}
#[test]
+1
View File
@@ -416,6 +416,7 @@ fn header_max_total_records(max_leaf_nrec: u64, depth: u16) -> u64 {
mod tests {
use super::*;
#[allow(clippy::too_many_arguments)]
fn build_btree_v2_header(
tree_type: u8,
node_size: u32,
+161 -29
View File
@@ -132,6 +132,47 @@ fn ensure_len(data: &[u8], offset: usize, needed: usize) -> Result<(), FormatErr
Ok(())
}
/// `elements * elem_size` for sizes that come from the file. Dataspace and
/// chunk dimensions are untrusted 64-bit fields, so a crafted file can make
/// the plain product wrap to a small number (or to something enormous).
pub(crate) fn checked_byte_len(elements: u64, elem_size: usize) -> Result<usize, FormatError> {
usize::try_from(elements)
.ok()
.and_then(|n| n.checked_mul(elem_size))
.ok_or_else(|| {
FormatError::Overflow(format!(
"{elements} elements of {elem_size} bytes exceeds the addressable size"
))
})
}
/// Product of chunk dimensions times the element size, overflow-checked.
pub(crate) fn checked_chunk_byte_len(
chunk_dims: &[usize],
elem_size: usize,
) -> Result<usize, FormatError> {
chunk_dims
.iter()
.try_fold(elem_size, |acc, &d| acc.checked_mul(d))
.ok_or_else(|| {
FormatError::Overflow(format!(
"chunk dimensions {chunk_dims:?} x {elem_size} bytes exceeds the addressable size"
))
})
}
/// A zero-filled output buffer of `len` bytes. `vec![0; len]` aborts the
/// process when the allocation fails; a size taken from the file must surface
/// as an error instead.
pub(crate) fn alloc_output(len: usize) -> Result<Vec<u8>, FormatError> {
let mut out = Vec::new();
out.try_reserve_exact(len).map_err(|_| {
FormatError::Overflow(format!("cannot allocate {len} bytes for dataset output"))
})?;
out.resize(len, 0);
Ok(out)
}
fn read_offset(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
let s = size as usize;
if pos.checked_add(s).is_none_or(|end| end > data.len()) {
@@ -321,15 +362,17 @@ pub fn generate_implicit_chunks(
}
/// Read a chunked dataset, decompressing chunks as needed.
pub fn read_chunked_data(
/// Every allocated chunk of a chunked dataset, for any supported chunk index,
/// plus the spatial chunk dimensions. Chunks the file never allocated (sparse
/// datasets) are simply absent from the list.
pub fn list_chunks(
file_data: &[u8],
layout: &DataLayout,
dataspace: &Dataspace,
datatype: &Datatype,
pipeline: Option<&FilterPipeline>,
elem_size: usize,
offset_size: u8,
length_size: u8,
) -> Result<Vec<u8>, FormatError> {
) -> Result<(Vec<ChunkInfo>, Vec<usize>), FormatError> {
let (
chunk_dimensions,
version,
@@ -363,8 +406,6 @@ pub fn read_chunked_data(
let addr = addr_opt
.ok_or_else(|| FormatError::ChunkedReadError("no address for chunked layout".into()))?;
let elem_size = datatype.type_size() as usize;
// Both v3 and v4 include element size as last dim (rank+1)
let ndims = chunk_dimensions.len();
let rank = ndims
@@ -393,7 +434,7 @@ pub fn read_chunked_data(
}
(4, Some(1)) => {
// Single chunk — one chunk covering the entire dataset
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
let chunk_byte_size = checked_chunk_byte_len(&chunk_dims, elem_size)?;
let (csize, fmask) = if let Some(fs) = single_filtered_size {
(fs as u32, single_filter_mask.unwrap_or(0))
} else {
@@ -453,10 +494,38 @@ pub fn read_chunked_data(
}
};
Ok((chunks, chunk_dims))
}
pub fn read_chunked_data(
file_data: &[u8],
layout: &DataLayout,
dataspace: &Dataspace,
datatype: &Datatype,
pipeline: Option<&FilterPipeline>,
offset_size: u8,
length_size: u8,
) -> Result<Vec<u8>, FormatError> {
let elem_size = datatype.type_size() as usize;
let (chunks, chunk_dims) = list_chunks(
file_data,
layout,
dataspace,
elem_size,
offset_size,
length_size,
)?;
let rank = chunk_dims.len();
let ds_dims: Vec<usize> = dataspace.dimensions.iter().map(|&d| d as usize).collect();
// Assemble output
let total_elements = dataspace.num_elements() as usize;
let total_bytes = total_elements * elem_size;
let mut output = vec![0u8; total_bytes];
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
if total_bytes == 0 {
// Also keeps the stride products below in range: with a zero-sized
// dimension the total is 0 even if other dimensions are huge.
return Ok(Vec::new());
}
let mut output = alloc_output(total_bytes)?;
let mut ds_strides = vec![1usize; rank];
for i in (0..rank.saturating_sub(1)).rev() {
@@ -468,8 +537,7 @@ pub fn read_chunked_data(
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
}
let chunk_total_elements: usize = chunk_dims.iter().product();
let chunk_total_bytes = chunk_total_elements * elem_size;
let chunk_total_bytes = checked_chunk_byte_len(&chunk_dims, elem_size)?;
// Fast path: no filters — copy directly from file_data without intermediate alloc
if pipeline.is_none() {
@@ -623,7 +691,7 @@ pub fn read_chunked_data_cached(
let chunks = match (version, chunk_index_type) {
(3, _) => collect_chunk_info(file_data, addr, ndims, offset_size, length_size)?,
(4, Some(1)) => {
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
let chunk_byte_size = checked_chunk_byte_len(&chunk_dims, elem_size)?;
let (csize, fmask) = if let Some(fs) = single_filtered_size {
(fs as u32, single_filter_mask.unwrap_or(0))
} else {
@@ -689,9 +757,13 @@ pub fn read_chunked_data_cached(
let chunks = cache.all_indexed_chunks().unwrap_or_default();
// Assemble output
let total_elements = dataspace.num_elements() as usize;
let total_bytes = total_elements * elem_size;
let mut output = vec![0u8; total_bytes];
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
if total_bytes == 0 {
// Also keeps the stride products below in range: with a zero-sized
// dimension the total is 0 even if other dimensions are huge.
return Ok(Vec::new());
}
let mut output = alloc_output(total_bytes)?;
let mut ds_strides = vec![1usize; rank];
for i in (0..rank.saturating_sub(1)).rev() {
@@ -703,8 +775,7 @@ pub fn read_chunked_data_cached(
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
}
let chunk_total_elements: usize = chunk_dims.iter().product();
let chunk_total_bytes = chunk_total_elements * elem_size;
let chunk_total_bytes = checked_chunk_byte_len(&chunk_dims, elem_size)?;
for chunk_info in &chunks {
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
@@ -976,7 +1047,7 @@ pub fn read_chunked_data_sweep(
let chunks = match (version, chunk_index_type) {
(3, _) => collect_chunk_info(file_data, addr, ndims, offset_size, length_size)?,
(4, Some(1)) => {
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
let chunk_byte_size = checked_chunk_byte_len(&chunk_dims, elem_size)?;
let (csize, fmask) = if let Some(fs) = single_filtered_size {
(fs as u32, single_filter_mask.unwrap_or(0))
} else {
@@ -1042,9 +1113,13 @@ pub fn read_chunked_data_sweep(
let chunks = cache.all_indexed_chunks().unwrap_or_default();
// Assemble output
let total_elements = dataspace.num_elements() as usize;
let total_bytes = total_elements * elem_size;
let mut output = vec![0u8; total_bytes];
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
if total_bytes == 0 {
// Also keeps the stride products below in range: with a zero-sized
// dimension the total is 0 even if other dimensions are huge.
return Ok(Vec::new());
}
let mut output = alloc_output(total_bytes)?;
let mut ds_strides = vec![1usize; rank];
for i in (0..rank.saturating_sub(1)).rev() {
@@ -1056,8 +1131,7 @@ pub fn read_chunked_data_sweep(
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
}
let chunk_total_elements: usize = chunk_dims.iter().product();
let chunk_total_bytes = chunk_total_elements * elem_size;
let chunk_total_bytes = checked_chunk_byte_len(&chunk_dims, elem_size)?;
for chunk_info in &chunks {
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
@@ -1199,7 +1273,7 @@ pub fn read_chunked_data_indexed(
let chunks = match (version, chunk_index_type) {
(3, _) => collect_chunk_info(file_data, addr, ndims, offset_size, length_size)?,
(4, Some(1)) => {
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
let chunk_byte_size = checked_chunk_byte_len(&chunk_dims, elem_size)?;
let (csize, fmask) = if let Some(fs) = single_filtered_size {
(fs as u32, single_filter_mask.unwrap_or(0))
} else {
@@ -1463,6 +1537,64 @@ fn copy_chunk_to_output(
mod tests {
use super::*;
fn simple_space(dimensions: Vec<u64>) -> Dataspace {
Dataspace {
space_type: crate::dataspace::DataspaceType::Simple,
rank: dimensions.len() as u8,
dimensions,
max_dimensions: None,
}
}
#[test]
fn crafted_dimensions_are_errors_not_wraparound() {
// 2^63 * 2 wraps to 0 with a plain product; 2^40 * 2^40 wraps too.
for dims in [
vec![1u64 << 63, 2],
vec![1 << 40, 1 << 40],
vec![u64::MAX, u64::MAX],
] {
let space = simple_space(dims.clone());
assert!(
matches!(space.checked_num_elements(), Err(FormatError::Overflow(_))),
"{dims:?}"
);
// The infallible accessor saturates instead of wrapping.
assert_eq!(space.num_elements(), u64::MAX, "{dims:?}");
}
assert_eq!(simple_space(vec![3, 4]).checked_num_elements().unwrap(), 12);
// A zero-sized dimension makes the whole product 0, not an overflow.
assert_eq!(
simple_space(vec![0, 1 << 40, 1 << 40])
.checked_num_elements()
.unwrap(),
0
);
}
#[test]
fn byte_length_helpers_check_overflow() {
assert_eq!(checked_byte_len(10, 8).unwrap(), 80);
assert!(matches!(
checked_byte_len(u64::MAX, 8),
Err(FormatError::Overflow(_))
));
assert_eq!(checked_chunk_byte_len(&[10, 10], 4).unwrap(), 400);
assert!(matches!(
checked_chunk_byte_len(&[usize::MAX, 2], 4),
Err(FormatError::Overflow(_))
));
}
#[test]
fn unallocatable_output_is_an_error_not_an_abort() {
assert_eq!(alloc_output(16).unwrap(), vec![0u8; 16]);
assert!(matches!(
alloc_output(usize::MAX / 2),
Err(FormatError::Overflow(_))
));
}
fn write_offset(buf: &mut Vec<u8>, val: u64, size: u8) {
match size {
4 => buf.extend_from_slice(&(val as u32).to_le_bytes()),
@@ -1657,9 +1789,9 @@ mod tests {
let chunk_bytes = chunk_size_elems * elem_size; // full chunk allocation
// Write chunk data (full chunk size, padding with zeros)
for i in start..end {
for (i, value) in values.iter().enumerate().take(end).skip(start) {
let byte_offset = data_offset + (i - start) * elem_size;
file_data[byte_offset..byte_offset + 8].copy_from_slice(&values[i].to_le_bytes());
file_data[byte_offset..byte_offset + 8].copy_from_slice(&value.to_le_bytes());
}
chunk_infos.push(ChunkInfo {
@@ -1837,8 +1969,8 @@ mod tests {
for chunk_idx in 0..2 {
let start = chunk_idx * chunk_elems;
let mut chunk_bytes = Vec::new();
for i in start..start + chunk_elems {
chunk_bytes.extend_from_slice(&values[i].to_le_bytes());
for value in values.iter().skip(start).take(chunk_elems) {
chunk_bytes.extend_from_slice(&value.to_le_bytes());
}
let compressed = compress_chunk(&chunk_bytes, &pipeline, elem_size as u32).unwrap();
+17 -13
View File
@@ -475,8 +475,10 @@ fn read_virtual_data(
use crate::selection::Selection;
let elem_size = datatype.type_size() as usize;
let total_elems = dataspace.num_elements() as usize;
let mut out = vec![0u8; total_elems.saturating_mul(elem_size)];
let mut out = crate::chunked_read::alloc_output(crate::chunked_read::checked_byte_len(
dataspace.checked_num_elements()?,
elem_size,
)?)?;
let virtual_dims = &dataspace.dimensions;
@@ -598,7 +600,7 @@ fn read_named_dataset_raw(
}
/// Extract selected elements from a full dataset buffer.
fn extract_selection_from_buffer(
pub fn extract_selection_from_buffer(
full_data: &[u8],
dims: &[u64],
elem_size: usize,
@@ -616,12 +618,14 @@ fn extract_selection_from_buffer(
block,
} => {
let rank = dims.len();
let output_elements: usize = count
let output_elements = count
.iter()
.zip(block.iter())
.map(|(&c, &b)| (c * b) as usize)
.product();
let mut output = vec![0u8; output_elements * elem_size];
.try_fold(1u64, |acc, (&c, &b)| acc.checked_mul(c.checked_mul(b)?))
.ok_or_else(|| FormatError::Overflow("hyperslab count x block overflows".into()))?;
let mut output = crate::chunked_read::alloc_output(
crate::chunked_read::checked_byte_len(output_elements, elem_size)?,
)?;
// Compute dataset strides (row-major)
let mut ds_strides = vec![1usize; rank];
@@ -1763,11 +1767,11 @@ mod tests {
fn f16_bits(v: f32) -> u16 {
// Encode a few exact values used by the test.
match v {
x if x == 0.0 => 0x0000,
x if x == 1.0 => 0x3c00,
x if x == -2.0 => 0xc000,
x if x == 0.5 => 0x3800,
x if x == 65504.0 => 0x7bff, // f16 max
0.0 => 0x0000,
1.0 => 0x3c00,
-2.0 => 0xc000,
0.5 => 0x3800,
65504.0 => 0x7bff, // f16 max
_ => panic!("unsupported test value {v}"),
}
}
@@ -2186,7 +2190,7 @@ mod tests {
],
};
let mut raw = Vec::new();
raw.extend_from_slice(&3.14f64.to_le_bytes());
raw.extend_from_slice(&3.25f64.to_le_bytes());
raw.extend_from_slice(&42i32.to_le_bytes());
let field = read_compound_field(&raw, &dt, "id").unwrap();
+32 -16
View File
@@ -1,5 +1,7 @@
//! HDF5 Dataspace message parsing (message type 0x0001).
#[cfg(not(feature = "std"))]
use alloc::format;
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
@@ -167,6 +169,27 @@ impl Dataspace {
}
}
/// [`Dataspace::num_elements`] with the product overflow-checked. The
/// dimensions are untrusted 64-bit fields; read paths that size a buffer
/// from them must use this one.
pub fn checked_num_elements(&self) -> Result<u64, FormatError> {
match self.space_type {
DataspaceType::Null => Ok(0),
DataspaceType::Scalar => Ok(1),
DataspaceType::Simple if self.dimensions.is_empty() => Ok(0),
DataspaceType::Simple => self
.dimensions
.iter()
.try_fold(1u64, |acc, &d| acc.checked_mul(d))
.ok_or_else(|| {
FormatError::Overflow(format!(
"dataspace dimensions {:?} overflow the element count",
self.dimensions
))
}),
}
}
/// Total number of elements. Scalar = 1, Null = 0.
pub fn num_elements(&self) -> u64 {
match self.space_type {
@@ -176,7 +199,12 @@ impl Dataspace {
if self.dimensions.is_empty() {
0
} else {
self.dimensions.iter().product()
// Saturate rather than wrap: a wrapped product could
// under-size a buffer. Size-critical callers use
// `checked_num_elements`.
self.dimensions
.iter()
.fold(1u64, |acc, &d| acc.saturating_mul(d))
}
}
}
@@ -189,11 +217,7 @@ mod tests {
fn build_v1_dataspace(rank: u8, flags: u8, dims: &[u64], max_dims: Option<&[u64]>) -> Vec<u8> {
let length_size = 8u8;
let mut buf = Vec::new();
buf.push(1); // version
buf.push(rank);
buf.push(flags);
buf.push(0); // reserved
let mut buf = vec![1, rank, flags, 0]; // version, rank, flags, reserved
buf.extend_from_slice(&[0u8; 4]); // reserved(4)
for &d in dims {
buf.extend_from_slice(&d.to_le_bytes());
@@ -214,11 +238,7 @@ mod tests {
dims: &[u64],
max_dims: Option<&[u64]>,
) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(2); // version
buf.push(rank);
buf.push(flags);
buf.push(type_byte);
let mut buf = vec![2, rank, flags, type_byte]; // version, rank, flags, type
for &d in dims {
buf.extend_from_slice(&d.to_le_bytes());
}
@@ -298,11 +318,7 @@ mod tests {
#[test]
fn v1_with_4byte_length() {
let mut buf = Vec::new();
buf.push(1); // version
buf.push(1); // rank
buf.push(0); // flags
buf.push(0); // reserved
let mut buf = vec![1, 1, 0, 0]; // version, rank, flags, reserved
buf.extend_from_slice(&[0u8; 4]); // reserved(4)
buf.extend_from_slice(&10u32.to_le_bytes()); // dim with length_size=4
let ds = Dataspace::parse(&buf, 4).unwrap();
+103 -16
View File
@@ -372,7 +372,8 @@ impl Datatype {
pos += name_len;
let byte_offset = read_uint(data, pos, ob)?;
pos += ob;
let (member_dt, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
let (member_dt, consumed) =
Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed;
members.push(CompoundMember {
name,
@@ -381,24 +382,29 @@ impl Datatype {
});
}
} else if version == 1 || version == 2 {
// v1/v2: name, offset(4), dimensionality(1), reserved(3), dim_perm(4),
// reserved_dims(up to 4*4=16), member datatype
// v1/v2: name (null-terminated, padded to a multiple of 8
// bytes), offset(4), member datatype. v1 additionally
// carries the legacy per-member array fields between the
// offset and the member datatype: dimensionality(1),
// reserved(3), dim_perm(4), reserved(4), 4 dim sizes(16).
// v1 is what default (non-`latest`) libver bounds emit.
for _ in 0..num_members {
let (name, name_len) = read_null_terminated_string(data, pos)?;
pos += name_len;
// v1: names padded to 8-byte boundary
if version == 1 {
let total_name_bytes = name_len;
let padded = (total_name_bytes + 7) & !7;
pos = pos - name_len + padded;
}
let padded = name_len.checked_add(7).ok_or(FormatError::UnexpectedEof {
expected: usize::MAX,
available: data.len(),
})? & !7;
ensure_len(data, pos, padded)?;
pos += padded;
ensure_len(data, pos, 4)?;
let byte_offset = LittleEndian::read_u32(&data[pos..pos + 4]) as u64;
pos += 4;
// dimensionality(1) + reserved(3) + dim_perm(4) + 4 dim slots(16) = 24
ensure_len(data, pos, 24)?;
pos += 24;
let (member_dt, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
if version == 1 {
ensure_len(data, pos, 28)?;
pos += 28;
}
let (member_dt, consumed) =
Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed;
members.push(CompoundMember {
name,
@@ -1138,6 +1144,82 @@ mod tests {
}
}
/// Real datatype message bytes emitted by h5py 3.16 / HDF5 2.0 with
/// *default* libver bounds for [('x','f8'),('y','f8'),('id','i4')]:
/// compound datatype version 1 (padded names + 28 bytes of legacy
/// per-member array fields).
fn compound_v1_bytes() -> Vec<u8> {
let f64le: [u8; 20] = [
0x11, 0x20, 0x3f, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x34, 0x0b,
0x00, 0x34, 0xff, 0x03, 0x00, 0x00,
];
let i32le: [u8; 12] = [
0x10, 0x08, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00,
];
let mut b = vec![0x16, 0x03, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00];
for (name, offset, dt) in [
(&b"x"[..], 0u32, &f64le[..]),
(&b"y"[..], 8, &f64le[..]),
(&b"id"[..], 16, &i32le[..]),
] {
let mut padded = name.to_vec();
padded.resize((name.len() + 1 + 7) & !7, 0);
b.extend_from_slice(&padded);
b.extend_from_slice(&offset.to_le_bytes());
b.extend_from_slice(&[0u8; 28]);
b.extend_from_slice(dt);
}
b
}
fn assert_xyid_compound(dt: Datatype) {
match dt {
Datatype::Compound { size, members } => {
assert_eq!(size, 20);
let got: Vec<(&str, u64, u32)> = members
.iter()
.map(|m| (m.name.as_str(), m.byte_offset, m.datatype.type_size()))
.collect();
assert_eq!(got, vec![("x", 0, 8), ("y", 8, 8), ("id", 16, 4)]);
}
other => panic!("expected Compound, got {other:?}"),
}
}
#[test]
fn test_compound_v1_default_libver() {
let bytes = compound_v1_bytes();
let (dt, consumed) = Datatype::parse(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_xyid_compound(dt);
}
#[test]
fn test_compound_v2_padded_names_no_array_fields() {
// v2 = v1 without the 28 bytes of per-member array fields; names are
// still padded to a multiple of 8 (matches libhdf5's H5O decoder).
let v1 = compound_v1_bytes();
let mut v2 = vec![0x26, 0x03, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00];
let mut pos = 8;
for dt_len in [20usize, 20, 12] {
v2.extend_from_slice(&v1[pos..pos + 8 + 4]); // padded name + offset
pos += 8 + 4 + 28;
v2.extend_from_slice(&v1[pos..pos + dt_len]);
pos += dt_len;
}
let (dt, consumed) = Datatype::parse(&v2).unwrap();
assert_eq!(consumed, v2.len());
assert_xyid_compound(dt);
}
#[test]
fn test_compound_v1_truncated_is_error_not_panic() {
let bytes = compound_v1_bytes();
for cut in 8..bytes.len() {
assert!(Datatype::parse(&bytes[..cut]).is_err(), "cut at {cut}");
}
}
/// Real datatype message bytes emitted by HDF5 2.0 for the native complex
/// type `H5T_COMPLEX_IEEE_F64LE`: class 11, version 5, size 16, followed by
/// the base IEEE f64 datatype message.
@@ -1172,7 +1254,9 @@ mod tests {
// Compound { z: complex f64 @0, k: i64 @16 } as written by HDF5 2.0.
// Regression guard: the complex member must consume exactly its own
// bytes so the following member parses.
let mut bytes = vec![0x56, 0x02, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, b'z', 0x00, 0x00];
let mut bytes = vec![
0x56, 0x02, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, b'z', 0x00, 0x00,
];
bytes.extend_from_slice(&COMPLEX_F64_HDF5_2_0);
bytes.extend_from_slice(&[b'k', 0x00, 0x10]);
bytes.extend_from_slice(&[
@@ -1188,7 +1272,10 @@ mod tests {
&members[0].datatype,
Datatype::Compound { size: 16, members } if members.len() == 2
));
assert_eq!((members[1].name.as_str(), members[1].byte_offset), ("k", 16));
assert_eq!(
(members[1].name.as_str(), members[1].byte_offset),
("k", 16)
);
}
other => panic!("expected Compound, got {other:?}"),
}
+30
View File
@@ -114,6 +114,20 @@ pub enum FormatError {
InvalidAttributeInfoVersion(u8),
/// Invalid shared message version.
InvalidSharedMessageVersion(u8),
/// A message is marked shared but was parsed without access to the file,
/// so the reference to the real message could not be followed.
UnresolvedSharedMessage,
/// The dataset's raw data is stored in external files (External Data
/// Files message), which this reader does not follow.
ExternalDataFilesUnsupported,
/// The path goes through an external link (a link into another file),
/// which this reader does not follow.
ExternalLinkUnsupported {
/// The file the link points into.
filename: String,
/// The object path within that file.
object_path: String,
},
/// Invalid SOHM table version.
InvalidSohmTableVersion(u8),
/// Invalid SOHM table signature (expected "SMTB").
@@ -307,6 +321,22 @@ impl fmt::Display for FormatError {
FormatError::InvalidSharedMessageVersion(v) => {
write!(f, "invalid shared message version: {v}")
}
FormatError::ExternalLinkUnsupported {
filename,
object_path,
} => write!(
f,
"path goes through an external link to {object_path} in {filename}, which is \
not supported"
),
FormatError::ExternalDataFilesUnsupported => write!(
f,
"dataset raw data is stored in external file(s), which is not supported"
),
FormatError::UnresolvedSharedMessage => write!(
f,
"message is shared but no file data was available to resolve it"
),
FormatError::InvalidSohmTableVersion(v) => {
write!(f, "invalid SOHM table version: {v}")
}
+407
View File
@@ -0,0 +1,407 @@
//! Fill Value messages (0x0005, and the old 0x0004) and applying them on read.
//!
//! HDF5 allocates storage lazily: a chunk nobody wrote to does not exist in the
//! file, and a contiguous dataset nobody wrote to has no data address at all.
//! Reading such a region must yield the dataset's *fill value* (zeros unless
//! the creator chose otherwise). The readers in [`crate::chunked_read`] leave
//! those regions zeroed; [`apply_to_unallocated_chunks`] then overwrites exactly
//! the chunk-grid cells that are absent from the chunk index — so it can never
//! mistake a stored zero for a hole — and is skipped entirely in the common
//! case of a zero fill value.
#[cfg(not(feature = "std"))]
use alloc::{format, vec, vec::Vec};
use crate::chunked_read::{alloc_output, checked_byte_len, list_chunks};
use crate::data_layout::DataLayout;
use crate::dataspace::Dataspace;
use crate::error::FormatError;
use crate::message_type::MessageType;
use crate::object_header::HeaderMessage;
/// Largest fill value accepted. A fill value is one element of the dataset's
/// datatype; this only bounds the allocation driven by the message's size field.
const MAX_FILL_VALUE_SIZE: usize = 1 << 20;
/// Parse a Fill Value message, returning the user-defined fill value bytes, or
/// `None` when the dataset uses the default (all zeros) or has the fill value
/// explicitly undefined.
pub fn parse_fill_value(msg: &HeaderMessage) -> Result<Option<Vec<u8>>, FormatError> {
let data = msg.data.as_slice();
let value_at = |pos: usize| -> Result<Option<Vec<u8>>, FormatError> {
let size_bytes = data.get(pos..pos + 4).ok_or(FormatError::UnexpectedEof {
expected: pos + 4,
available: data.len(),
})?;
let size = u32::from_le_bytes([size_bytes[0], size_bytes[1], size_bytes[2], size_bytes[3]])
as usize;
if size == 0 {
return Ok(None);
}
if size > MAX_FILL_VALUE_SIZE {
return Err(FormatError::Overflow(format!(
"fill value of {size} bytes exceeds the {MAX_FILL_VALUE_SIZE}-byte limit"
)));
}
let start = pos + 4;
let value =
data.get(start..start.saturating_add(size))
.ok_or(FormatError::UnexpectedEof {
expected: start.saturating_add(size),
available: data.len(),
})?;
Ok(Some(value.to_vec()))
};
match msg.msg_type {
// Old fill value message: size(4), value.
MessageType::FillValueOld => value_at(0),
MessageType::FillValue => {
let version = *data.first().ok_or(FormatError::UnexpectedEof {
expected: 1,
available: 0,
})?;
match version {
// version, alloc time, write time, defined, [size, value]
1 | 2 => {
let defined = *data.get(3).ok_or(FormatError::UnexpectedEof {
expected: 4,
available: data.len(),
})?;
if version == 2 && defined == 0 {
Ok(None)
} else if data.len() < 8 && version == 1 {
// v1 always carries a size, but tolerate its absence.
Ok(None)
} else {
value_at(4)
}
}
// version, flags (bit 4 = undefined, bit 5 = defined), [size, value]
3 => {
let flags = *data.get(1).ok_or(FormatError::UnexpectedEof {
expected: 2,
available: data.len(),
})?;
if flags & 0x10 != 0 || flags & 0x20 == 0 {
Ok(None)
} else {
value_at(2)
}
}
v => Err(FormatError::UnsupportedVersion(v)),
}
}
_ => Ok(None),
}
}
/// The fill value that applies to a dataset given its header messages. The new
/// message wins over the old one when both are present.
pub fn dataset_fill_value(messages: &[HeaderMessage]) -> Result<Option<Vec<u8>>, FormatError> {
for wanted in [MessageType::FillValue, MessageType::FillValueOld] {
if let Some(msg) = messages.iter().find(|m| m.msg_type == wanted) {
if crate::shared_message::is_shared(msg.flags) {
// A shared fill value is legal but vanishingly rare; treat it
// as the default rather than misparsing the reference.
return Ok(None);
}
if let Some(value) = parse_fill_value(msg)? {
return Ok(Some(value));
}
}
}
Ok(None)
}
/// `true` when a fill value is absent or all zeros, i.e. identical to what the
/// readers already produce for unallocated storage.
pub fn is_default(fill: Option<&[u8]>) -> bool {
fill.is_none_or(|f| f.iter().all(|&b| b == 0))
}
/// A whole dataset's worth of fill value: what reading a dataset with no
/// allocated storage at all must return.
pub fn filled_dataset(
dataspace: &Dataspace,
elem_size: usize,
fill: Option<&[u8]>,
) -> Result<Vec<u8>, FormatError> {
let total = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
let mut out = alloc_output(total)?;
if let Some(fill) = fill.filter(|f| f.len() == elem_size && !is_default(Some(f))) {
for element in out.chunks_exact_mut(elem_size) {
element.copy_from_slice(fill);
}
}
Ok(out)
}
/// Whether the layout has any storage in the file at all. A dataset that was
/// created but never written to has none.
pub fn has_storage(layout: &DataLayout) -> bool {
!matches!(
layout,
DataLayout::Contiguous { address: None, .. }
| DataLayout::Chunked {
btree_address: None,
..
}
)
}
/// Run a full-dataset `read`, giving unallocated storage its fill value: a
/// dataset with no storage at all reads as entirely fill value (instead of
/// failing), and a chunked dataset has the fill value written into every
/// chunk the file never allocated.
#[allow(clippy::too_many_arguments)]
pub fn read_full_with_fill<E: From<FormatError>>(
messages: &[HeaderMessage],
file_data: &[u8],
layout: &DataLayout,
dataspace: &Dataspace,
elem_size: usize,
offset_size: u8,
length_size: u8,
read: impl FnOnce() -> Result<Vec<u8>, E>,
) -> Result<Vec<u8>, E> {
// A dataset with external raw data also has no data address in this
// file. It is NOT unallocated — its values live elsewhere — so it must
// never be answered with the fill value.
if messages
.iter()
.any(|m| m.msg_type == MessageType::ExternalDataFiles)
{
return Err(FormatError::ExternalDataFilesUnsupported.into());
}
let fill = dataset_fill_value(messages)?;
if !has_storage(layout) {
return Ok(filled_dataset(dataspace, elem_size, fill.as_deref())?);
}
let mut output = read()?;
apply_to_unallocated_chunks(
&mut output,
file_data,
layout,
dataspace,
elem_size,
fill.as_deref(),
offset_size,
length_size,
)?;
Ok(output)
}
/// Overwrite, in a fully read chunked dataset `output`, every region whose
/// chunk was never allocated with `fill`. No-op for non-chunked layouts, a
/// default fill value, or a fill value whose size doesn't match the element.
#[allow(clippy::too_many_arguments)]
pub fn apply_to_unallocated_chunks(
output: &mut [u8],
file_data: &[u8],
layout: &DataLayout,
dataspace: &Dataspace,
elem_size: usize,
fill: Option<&[u8]>,
offset_size: u8,
length_size: u8,
) -> Result<(), FormatError> {
let Some(fill) = fill.filter(|f| f.len() == elem_size && !is_default(Some(f))) else {
return Ok(());
};
if !matches!(layout, DataLayout::Chunked { .. }) || elem_size == 0 {
return Ok(());
}
let (chunks, chunk_dims) = list_chunks(
file_data,
layout,
dataspace,
elem_size,
offset_size,
length_size,
)?;
let rank = chunk_dims.len();
let ds_dims: Vec<usize> = dataspace.dimensions.iter().map(|&d| d as usize).collect();
if rank == 0 || ds_dims.len() != rank || chunk_dims.contains(&0) {
return Ok(());
}
// Row-major strides over the dataset and over the chunk grid.
let mut ds_strides = vec![1usize; rank];
for i in (0..rank - 1).rev() {
ds_strides[i] = ds_strides[i + 1].saturating_mul(ds_dims[i + 1]);
}
let grid: Vec<usize> = ds_dims
.iter()
.zip(&chunk_dims)
.map(|(&d, &c)| d.div_ceil(c))
.collect();
let cells = grid
.iter()
.try_fold(1usize, |acc, &g| acc.checked_mul(g))
.ok_or_else(|| FormatError::Overflow("chunk grid size overflows".into()))?;
if cells == 0 {
return Ok(());
}
let mut allocated = vec![false; cells];
for chunk in &chunks {
// Undefined address: the index has a slot for the chunk but no storage.
if chunk.address == u64::MAX || chunk.offsets.len() < rank {
continue;
}
let mut cell = 0usize;
let mut in_range = true;
for d in 0..rank {
let coord = chunk.offsets[d] as usize / chunk_dims[d];
if coord >= grid[d] {
in_range = false;
break;
}
cell = cell * grid[d] + coord;
}
if in_range {
allocated[cell] = true;
}
}
let mut coord = vec![0usize; rank];
for (cell, is_allocated) in allocated.iter().enumerate() {
if *is_allocated {
continue;
}
// Decode the cell index into grid coordinates.
let mut rem = cell;
for d in (0..rank).rev() {
coord[d] = rem % grid[d];
rem /= grid[d];
}
fill_cell(
output,
&coord,
&chunk_dims,
&ds_dims,
&ds_strides,
elem_size,
fill,
);
}
Ok(())
}
/// Fill the part of chunk-grid cell `coord` that lies inside the dataset.
fn fill_cell(
output: &mut [u8],
coord: &[usize],
chunk_dims: &[usize],
ds_dims: &[usize],
ds_strides: &[usize],
elem_size: usize,
fill: &[u8],
) {
let rank = coord.len();
let start: Vec<usize> = (0..rank).map(|d| coord[d] * chunk_dims[d]).collect();
let end: Vec<usize> = (0..rank)
.map(|d| (start[d] + chunk_dims[d]).min(ds_dims[d]))
.collect();
if (0..rank).any(|d| start[d] >= end[d]) {
return;
}
// Walk every row (all dims but the last) and fill the run along the last.
let run = end[rank - 1] - start[rank - 1];
let mut idx = start.clone();
loop {
let first: usize = (0..rank).map(|d| idx[d] * ds_strides[d]).sum();
let from = first * elem_size;
let to = from + run * elem_size;
if let Some(region) = output.get_mut(from..to) {
for element in region.chunks_exact_mut(elem_size) {
element.copy_from_slice(fill);
}
}
// Advance the odometer over dims 0..rank-1.
let mut d = rank - 1;
loop {
if d == 0 {
return;
}
d -= 1;
idx[d] += 1;
if idx[d] < end[d] {
break;
}
idx[d] = start[d];
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn msg(msg_type: MessageType, data: &[u8]) -> HeaderMessage {
HeaderMessage {
msg_type,
size: data.len(),
flags: 0,
creation_order: None,
data: data.to_vec(),
}
}
#[test]
fn parses_v3_defined_undefined_and_default() {
// Real message for h5py `fillvalue=-1` on an i4 dataset (HDF5 2.0).
let defined = msg(
MessageType::FillValue,
&[3, 0x2b, 4, 0, 0, 0, 0xff, 0xff, 0xff, 0xff],
);
assert_eq!(parse_fill_value(&defined).unwrap(), Some(vec![0xff; 4]));
let default = msg(MessageType::FillValue, &[3, 0x0a]);
assert_eq!(parse_fill_value(&default).unwrap(), None);
let undefined = msg(MessageType::FillValue, &[3, 0x19]);
assert_eq!(parse_fill_value(&undefined).unwrap(), None);
}
#[test]
fn parses_v2_and_old_messages() {
let v2 = msg(MessageType::FillValue, &[2, 2, 2, 1, 2, 0, 0, 0, 7, 0]);
assert_eq!(parse_fill_value(&v2).unwrap(), Some(vec![7, 0]));
let v2_undefined = msg(MessageType::FillValue, &[2, 2, 2, 0]);
assert_eq!(parse_fill_value(&v2_undefined).unwrap(), None);
let old = msg(MessageType::FillValueOld, &[2, 0, 0, 0, 9, 9]);
assert_eq!(parse_fill_value(&old).unwrap(), Some(vec![9, 9]));
}
#[test]
fn truncated_or_oversized_fill_is_an_error() {
let short = msg(MessageType::FillValue, &[3, 0x29, 4, 0, 0, 0, 0xff]);
assert!(parse_fill_value(&short).is_err());
let huge = msg(MessageType::FillValue, &[3, 0x29, 0xff, 0xff, 0xff, 0x7f]);
assert!(matches!(
parse_fill_value(&huge),
Err(FormatError::Overflow(_))
));
}
#[test]
fn fill_cell_clips_edge_chunks_in_2d() {
// 3x5 dataset, 2x2 chunks; fill grid cell (1, 2): rows 2..3, cols 4..5.
let mut out = vec![0u8; 15];
fill_cell(&mut out, &[1, 2], &[2, 2], &[3, 5], &[5, 1], 1, &[9]);
let mut expected = vec![0u8; 15];
expected[2 * 5 + 4] = 9;
assert_eq!(out, expected);
// Interior cell (0, 1): rows 0..2, cols 2..4.
let mut out = vec![0u8; 15];
fill_cell(&mut out, &[0, 1], &[2, 2], &[3, 5], &[5, 1], 1, &[7]);
let filled: Vec<usize> = out
.iter()
.enumerate()
.filter(|(_, b)| **b == 7)
.map(|(i, _)| i)
.collect();
assert_eq!(filled, [2, 3, 7, 8]);
}
}
+21 -15
View File
@@ -1045,24 +1045,30 @@ fn pcodec_compress(data: &[u8], element_size: usize) -> Result<Vec<u8>, FormatEr
match element_size {
4 => {
let nums: Vec<f32> = data
.chunks_exact(4)
.map(|b| f32::from_le_bytes(b.try_into().unwrap()))
.as_chunks::<4>()
.0
.iter()
.map(|b| f32::from_le_bytes(*b))
.collect();
simple_compress(&nums, &config)
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
}
8 => {
let nums: Vec<f64> = data
.chunks_exact(8)
.map(|b| f64::from_le_bytes(b.try_into().unwrap()))
.as_chunks::<8>()
.0
.iter()
.map(|b| f64::from_le_bytes(*b))
.collect();
simple_compress(&nums, &config)
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
}
_ => {
let nums: Vec<u32> = data
.chunks_exact(4)
.map(|b| u32::from_le_bytes(b.try_into().unwrap()))
.as_chunks::<4>()
.0
.iter()
.map(|b| u32::from_le_bytes(*b))
.collect();
simple_compress(&nums, &config)
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
@@ -1092,11 +1098,7 @@ fn pcodec_decompress(
} else {
MAX_DECOMPRESS_SIZE
};
let n = if element_size != 0 {
limit_bytes / element_size
} else {
0
};
let n = limit_bytes.checked_div(element_size).unwrap_or(0);
match element_size {
4 => {
let mut buf = vec![0f32; n];
@@ -1543,8 +1545,10 @@ mod tests {
fn as_f32(bytes: &[u8]) -> Vec<f32> {
bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.as_chunks::<4>()
.0
.iter()
.map(|c| f32::from_le_bytes(*c))
.collect()
}
@@ -1578,8 +1582,10 @@ mod tests {
fn as_f64(bytes: &[u8]) -> Vec<f64> {
bytes
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.as_chunks::<8>()
.0
.iter()
.map(|c| f64::from_le_bytes(*c))
.collect()
}
+1 -3
View File
@@ -184,9 +184,7 @@ mod tests {
buf.extend_from_slice(data);
// Pad to 8 bytes
let padded = pad8(data.len());
for _ in data.len()..padded {
buf.push(0);
}
buf.resize(buf.len() + (padded - data.len()), 0);
}
// Free space marker
+48
View File
@@ -60,6 +60,54 @@ pub fn resolve_v1_group_entries(
Ok(entries)
}
/// Symbol table cache type for a soft link: the scratch pad's first four bytes
/// are the local-heap offset of the link's target path, and the entry's object
/// header address is undefined.
const CACHE_TYPE_SOFT_LINK: u32 = 2;
/// The target path of the soft link called `name` in a v1 group, if any.
pub fn find_v1_soft_link(
file_data: &[u8],
sym_table_msg: &SymbolTableMessage,
name: &str,
offset_size: u8,
length_size: u8,
) -> Result<Option<String>, FormatError> {
let heap = LocalHeap::parse(
file_data,
sym_table_msg.local_heap_address as usize,
offset_size,
length_size,
)?;
let snod_addrs = collect_symbol_table_nodes(
file_data,
sym_table_msg.btree_address,
offset_size,
length_size,
)?;
for snod_addr in snod_addrs {
let snod = SymbolTableNode::parse(file_data, snod_addr as usize, offset_size)?;
for entry in &snod.entries {
if entry.cache_type != CACHE_TYPE_SOFT_LINK {
continue;
}
if heap.read_string(file_data, entry.link_name_offset)? != name {
continue;
}
let value_offset = u32::from_le_bytes([
entry.scratch_pad[0],
entry.scratch_pad[1],
entry.scratch_pad[2],
entry.scratch_pad[3],
]);
return heap
.read_string(file_data, u64::from(value_offset))
.map(Some);
}
}
Ok(None)
}
/// Extract the SymbolTableMessage from an object header's messages.
fn find_symbol_table_message(
obj_header: &ObjectHeader,
+126 -9
View File
@@ -63,14 +63,15 @@ fn resolve_compact_entries(
Ok(entries)
}
/// Resolve entries from dense storage (fractal heap + B-tree v2).
fn resolve_dense_entries(
/// Visit every link in dense storage (fractal heap + B-tree v2 name index).
fn for_each_dense_link(
file_data: &[u8],
link_info: &LinkInfoMessage,
fh_addr: u64,
offset_size: u8,
length_size: u8,
) -> Result<Vec<GroupEntry>, FormatError> {
mut visit: impl FnMut(LinkMessage),
) -> Result<(), FormatError> {
// Parse fractal heap
let fh = FractalHeapHeader::parse(file_data, fh_addr as usize, offset_size, length_size)?;
@@ -81,7 +82,6 @@ fn resolve_dense_entries(
let btree_hdr = BTreeV2Header::parse(file_data, btree_addr as usize, offset_size, length_size)?;
let records = collect_btree_v2_records(file_data, &btree_hdr, offset_size, length_size)?;
let mut entries = Vec::new();
for record in &records {
// For type 5 (name index): hash(4) + heap_id(heap_id_length)
// For type 6 (creation order): creation_order(8) + heap_id(heap_id_length)
@@ -98,9 +98,27 @@ fn resolve_dense_entries(
// Read managed object from fractal heap
let link_data = fh.read_managed_object(file_data, id_bytes, offset_size)?;
visit(LinkMessage::parse(&link_data, offset_size)?);
}
Ok(())
}
// Parse as Link message
let link = LinkMessage::parse(&link_data, offset_size)?;
/// Resolve entries from dense storage (fractal heap + B-tree v2).
fn resolve_dense_entries(
file_data: &[u8],
link_info: &LinkInfoMessage,
fh_addr: u64,
offset_size: u8,
length_size: u8,
) -> Result<Vec<GroupEntry>, FormatError> {
let mut entries = Vec::new();
for_each_dense_link(
file_data,
link_info,
fh_addr,
offset_size,
length_size,
|link| {
if let LinkTarget::Hard {
object_header_address,
} = link.link_target
@@ -111,9 +129,63 @@ fn resolve_dense_entries(
cache_type: 0,
});
}
},
)?;
Ok(entries)
}
Ok(entries)
/// The soft or external link called `name` in this group, if there is one.
/// Hard links are what `resolve_group_entries` returns; this is consulted only
/// when a path component isn't among them.
fn find_symbolic_link(
file_data: &[u8],
object_header: &ObjectHeader,
name: &str,
offset_size: u8,
length_size: u8,
) -> Result<Option<LinkTarget>, FormatError> {
if is_v1_group(object_header) {
let Some(sym_msg) = object_header
.messages
.iter()
.find(|m| m.msg_type == MessageType::SymbolTable)
else {
return Ok(None);
};
let stm = SymbolTableMessage::parse(&sym_msg.data, offset_size)?;
return group_v1::find_v1_soft_link(file_data, &stm, name, offset_size, length_size)
.map(|target| target.map(|target_path| LinkTarget::Soft { target_path }));
}
if !is_v2_group(object_header) {
return Ok(None);
}
let is_symbolic = |t: &LinkTarget| !matches!(t, LinkTarget::Hard { .. });
let link_info = find_link_info(object_header, offset_size)?;
let mut found = None;
if let Some(fh_addr) = link_info.fractal_heap_address {
for_each_dense_link(
file_data,
&link_info,
fh_addr,
offset_size,
length_size,
|link| {
if link.name == name && is_symbolic(&link.link_target) {
found = Some(link.link_target);
}
},
)?;
} else {
for msg in &object_header.messages {
if msg.msg_type == MessageType::Link {
let link = LinkMessage::parse(&msg.data, offset_size)?;
if link.name == name && is_symbolic(&link.link_target) {
found = Some(link.link_target);
}
}
}
}
Ok(found)
}
/// Find and parse the Link Info message from an object header.
@@ -158,6 +230,19 @@ pub fn resolve_path_any(
file_data: &[u8],
superblock: &Superblock,
path: &str,
) -> Result<u64, FormatError> {
resolve_path_following_links(file_data, superblock, path, 0)
}
/// Soft links followed while resolving one path. Guards against link cycles
/// (`a -> b -> a`), which are legal to create.
const MAX_SOFT_LINK_DEPTH: u8 = 16;
fn resolve_path_following_links(
file_data: &[u8],
superblock: &Superblock,
path: &str,
depth: u8,
) -> Result<u64, FormatError> {
let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if components.is_empty() {
@@ -176,7 +261,9 @@ pub fn resolve_path_any(
for (i, component) in components.iter().enumerate() {
let entries = resolve_group_entries(file_data, &current_header, os, ls)?;
let found = entries.iter().find(|e| e.name == *component);
let found = entries
.iter()
.find(|e| e.name == *component && e.object_header_address != u64::MAX);
match found {
Some(entry) => {
if i == components.len() - 1 {
@@ -186,7 +273,37 @@ pub fn resolve_path_any(
current_header = ObjectHeader::parse(file_data, current_addr as usize, os, ls)?;
}
None => {
return Err(FormatError::PathNotFound(String::from(*component)));
return match find_symbolic_link(file_data, &current_header, component, os, ls)? {
Some(LinkTarget::Soft { target_path }) => {
if depth >= MAX_SOFT_LINK_DEPTH {
return Err(FormatError::NestingDepthExceeded);
}
// A relative target is relative to the group holding
// the link; then the rest of the original path.
let mut full = String::new();
if !target_path.starts_with('/') {
for parent in &components[..i] {
full.push('/');
full.push_str(parent);
}
}
full.push('/');
full.push_str(&target_path);
for rest in &components[i + 1..] {
full.push('/');
full.push_str(rest);
}
resolve_path_following_links(file_data, superblock, &full, depth + 1)
}
Some(LinkTarget::External {
filename,
object_path,
}) => Err(FormatError::ExternalLinkUnsupported {
filename,
object_path,
}),
_ => Err(FormatError::PathNotFound(String::from(*component))),
};
}
}
}
+1
View File
@@ -67,6 +67,7 @@ pub mod ea_writer;
pub mod error;
pub mod extensible_array;
pub mod file_writer;
pub mod fill_value;
pub mod filter_pipeline;
pub mod filters;
mod filters_szip;
+4 -11
View File
@@ -413,11 +413,8 @@ mod tests {
#[test]
fn soft_link() {
let target = "/group1/dataset";
let mut data = Vec::new();
data.push(1); // version
data.push(0x08); // flags: bit 3 = link type present, name size = 1 byte (bits 0-1 = 0)
data.push(1); // link type = soft
data.push(4); // name length = 4
// version, flags (bit 3 = link type present, name size = 1 byte), link type = soft, name length = 4
let mut data = vec![1, 0x08, 1, 4];
data.extend_from_slice(b"link");
data.extend_from_slice(&(target.len() as u16).to_le_bytes());
data.extend_from_slice(target.as_bytes());
@@ -455,12 +452,8 @@ mod tests {
#[test]
fn invalid_link_type() {
let mut data = Vec::new();
data.push(1); // version
data.push(0x08); // flags: bit 3 = link type present
data.push(99); // invalid link type
data.push(1); // name length = 1
data.push(b'x');
// version, flags (bit 3 = link type present), invalid link type = 99, name length = 1, name = 'x'
let data = vec![1, 0x08, 99, 1, b'x'];
let err = LinkMessage::parse(&data, 8).unwrap_err();
assert_eq!(err, FormatError::InvalidLinkType(99));
}
+14 -3
View File
@@ -9,6 +9,9 @@ pub enum MessageType {
Datatype,
FillValueOld,
FillValue,
/// External Data Files (0x0007): the dataset's raw data lives in other
/// files, listed by this message.
ExternalDataFiles,
Link,
DataLayout,
GroupInfo,
@@ -36,6 +39,7 @@ impl MessageType {
0x0004 => MessageType::FillValueOld,
0x0005 => MessageType::FillValue,
0x0006 => MessageType::Link,
0x0007 => MessageType::ExternalDataFiles,
0x0008 => MessageType::DataLayout,
0x000A => MessageType::GroupInfo,
0x000B => MessageType::FilterPipeline,
@@ -60,6 +64,7 @@ impl MessageType {
MessageType::Datatype => 0x0003,
MessageType::FillValueOld => 0x0004,
MessageType::FillValue => 0x0005,
MessageType::ExternalDataFiles => 0x0007,
MessageType::Link => 0x0006,
MessageType::DataLayout => 0x0008,
MessageType::GroupInfo => 0x000A,
@@ -90,6 +95,7 @@ mod tests {
(0x0003, MessageType::Datatype),
(0x0004, MessageType::FillValueOld),
(0x0005, MessageType::FillValue),
(0x0007, MessageType::ExternalDataFiles),
(0x0006, MessageType::Link),
(0x0008, MessageType::DataLayout),
(0x000A, MessageType::GroupInfo),
@@ -119,8 +125,13 @@ mod tests {
#[test]
fn unknown_type_zero_gap() {
// 0x0007 is not a defined type
let mt = MessageType::from_u16(0x0007);
assert_eq!(mt, MessageType::Unknown(0x0007));
// 0x0009 is reserved for the library's own testing; no file uses it.
let mt = MessageType::from_u16(0x0009);
assert_eq!(mt, MessageType::Unknown(0x0009));
// 0x0007 used to be treated as unknown: it is External Data Files.
assert_eq!(
MessageType::from_u16(0x0007),
MessageType::ExternalDataFiles
);
}
}
+15 -6
View File
@@ -73,9 +73,12 @@ pub fn decompress_chunks_lane_partitioned(
let c_addr = chunk_info.address as usize;
let size = chunk_info.chunk_size as usize;
if c_addr + size > file_data.len() {
if c_addr
.checked_add(size)
.is_none_or(|end| end > file_data.len())
{
return Err(FormatError::UnexpectedEof {
expected: c_addr + size,
expected: c_addr.saturating_add(size),
available: file_data.len(),
});
}
@@ -144,9 +147,12 @@ pub fn decompress_chunks_parallel(
.map(|(index, chunk_info)| {
let c_addr = chunk_info.address as usize;
let size = chunk_info.chunk_size as usize;
if c_addr + size > file_data.len() {
if c_addr
.checked_add(size)
.is_none_or(|end| end > file_data.len())
{
return Err(FormatError::UnexpectedEof {
expected: c_addr + size,
expected: c_addr.saturating_add(size),
available: file_data.len(),
});
}
@@ -182,9 +188,12 @@ pub fn decompress_chunks_sequential(
for chunk_info in chunks {
let c_addr = chunk_info.address as usize;
let size = chunk_info.chunk_size as usize;
if c_addr + size > file_data.len() {
if c_addr
.checked_add(size)
.is_none_or(|end| end > file_data.len())
{
return Err(FormatError::UnexpectedEof {
expected: c_addr + size,
expected: c_addr.saturating_add(size),
available: file_data.len(),
});
}
+1 -1
View File
@@ -509,7 +509,7 @@ mod tests {
#[test]
fn selection_slice_1d() {
let sel = Selection::slice(&[5..15]);
let sel = Selection::slice(std::slice::from_ref(&(5..15)));
assert_eq!(sel.num_elements(&[100]), 10);
assert_eq!(sel.output_shape(&[100]), vec![10]);
}
+94 -54
View File
@@ -16,8 +16,12 @@
//! - SMLI list structure: simple list of shared message entries
//! - B-tree v2 type 7: indexed shared message entries
#[cfg(not(feature = "std"))]
use alloc::borrow::Cow;
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
#[cfg(feature = "std")]
use std::borrow::Cow;
use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records};
use crate::error::FormatError;
@@ -28,6 +32,14 @@ use crate::object_header::ObjectHeader;
/// Fractal heap ID length for SOHM entries (fixed at 8 bytes).
const FHEAP_ID_LEN: usize = 8;
/// Shared-message `type` values (version 3 encoding).
/// The message is in the file's shared-message (SOHM) fractal heap.
const SHARE_TYPE_SOHM: u8 = 1;
/// The message is in another object's header (a committed/named datatype).
const SHARE_TYPE_COMMITTED: u8 = 2;
/// The message is stored here but is sharable.
const SHARE_TYPE_HERE: u8 = 3;
/// A resolved shared message reference.
#[derive(Debug, Clone)]
pub struct SharedMessageRef {
@@ -35,9 +47,10 @@ pub struct SharedMessageRef {
pub ref_type: u8,
/// Version of the shared message encoding.
pub version: u8,
/// Address of the object header containing the shared message (type 1, 3).
/// Address of the object header holding the message (committed). Set for
/// every v1/v2 reference and for v3 types 2 and 3.
pub object_header_address: Option<u64>,
/// Fractal heap ID for type 2 (SOHM) references.
/// Fractal heap ID for a v3 SOHM (type 1) reference.
pub heap_id: Option<[u8; FHEAP_ID_LEN]>,
}
@@ -146,35 +159,27 @@ pub fn parse_shared_ref(data: &[u8], offset_size: u8) -> Result<SharedMessageRef
let version = data[0];
let ref_type = data[1];
match version {
1 | 2 => {
// v1/v2: reserved(6) + address(offset_size)
let pos = 2 + 6; // skip reserved bytes
// Layouts (HDF5 spec IV.A.2 "Shared Message", and libhdf5's decoder):
// v1: version, type, reserved(6), address — always "committed"
// v2: version, type, address — always "committed"
// v3: version, type, then a fractal-heap ID if type == SOHM, otherwise
// an address
// Verified against h5py/HDF5 2.0 output, which writes `02 02 <address>`
// for a dataset using a committed datatype under both default and
// `latest` libver bounds.
let address_at = |pos: usize| -> Result<SharedMessageRef, FormatError> {
ensure_len(data, pos, offset_size as usize)?;
let addr = read_offset(data, pos, offset_size)?;
Ok(SharedMessageRef {
ref_type,
version,
object_header_address: Some(addr),
object_header_address: Some(read_offset(data, pos, offset_size)?),
heap_id: None,
})
}
3 => {
match ref_type {
1 | 3 => {
// type 1/3: message in another object header
// v3 layout: version(1) + type(1) + address(offset_size)
ensure_len(data, 2, offset_size as usize)?;
let addr = read_offset(data, 2, offset_size)?;
Ok(SharedMessageRef {
ref_type,
version,
object_header_address: Some(addr),
heap_id: None,
})
}
2 => {
// type 2: SOHM table (fractal heap ID)
};
match version {
1 => address_at(2 + 6),
2 => address_at(2),
3 if ref_type == SHARE_TYPE_SOHM => {
ensure_len(data, 2, FHEAP_ID_LEN)?;
let mut id = [0u8; FHEAP_ID_LEN];
id.copy_from_slice(&data[2..2 + FHEAP_ID_LEN]);
@@ -185,9 +190,8 @@ pub fn parse_shared_ref(data: &[u8], offset_size: u8) -> Result<SharedMessageRef
heap_id: Some(id),
})
}
_ => Err(FormatError::InvalidSharedMessageVersion(ref_type)),
}
}
3 if ref_type == SHARE_TYPE_COMMITTED || ref_type == SHARE_TYPE_HERE => address_at(2),
3 => Err(FormatError::InvalidSharedMessageVersion(ref_type)),
_ => Err(FormatError::InvalidSharedMessageVersion(version)),
}
}
@@ -422,6 +426,35 @@ pub fn resolve_sohm_message(
fh_header.read_managed_object(file_data, heap_id, offset_size)
}
/// The payload of an object-header message, following the indirection if the
/// message is *shared* (header flag bit 1).
///
/// A shared message's bytes are not the message itself but a reference to
/// where it lives — e.g. a dataset created with a committed (named) datatype
/// stores only a pointer to that datatype's object header. Every reader of a
/// message that may be shared (datatype, dataspace, fill value, filter
/// pipeline, attribute) must go through this; parsing the reference bytes as
/// the message yields garbage rather than an error.
pub fn message_data<'a>(
file_data: &[u8],
msg: &'a crate::object_header::HeaderMessage,
offset_size: u8,
length_size: u8,
) -> Result<Cow<'a, [u8]>, FormatError> {
if !is_shared(msg.flags) {
return Ok(Cow::Borrowed(&msg.data));
}
let shared_ref = parse_shared_ref(&msg.data, offset_size)?;
resolve_shared_message(
file_data,
&shared_ref,
msg.msg_type,
offset_size,
length_size,
)
.map(Cow::Owned)
}
/// Resolve a shared message to its actual message data.
///
/// For type 1/3 (shared in another object header), reads the target object header
@@ -453,14 +486,14 @@ pub fn resolve_shared_message_with_sohm(
length_size: u8,
sohm_table: Option<&SohmTable>,
) -> Result<Vec<u8>, FormatError> {
match shared_ref.ref_type {
1 | 3 => {
let addr = shared_ref
.object_header_address
.ok_or(FormatError::UnexpectedEof {
expected: 1,
available: 0,
})?;
// Dispatch on what the reference carries rather than on `ref_type`: v1/v2
// references are always an object-header address whatever their type
// byte says.
match (
shared_ref.object_header_address,
shared_ref.heap_id.as_ref(),
) {
(Some(addr), _) => {
let target_header =
ObjectHeader::parse(file_data, addr as usize, offset_size, length_size)?;
for msg in &target_header.messages {
@@ -487,11 +520,7 @@ pub fn resolve_shared_message_with_sohm(
available: 0,
})
}
2 => {
let heap_id = shared_ref
.heap_id
.as_ref()
.ok_or(FormatError::InvalidSharedMessageVersion(2))?;
(None, Some(heap_id)) => {
let table = sohm_table.ok_or(FormatError::InvalidSharedMessageVersion(2))?;
resolve_sohm_message(
file_data,
@@ -502,7 +531,7 @@ pub fn resolve_shared_message_with_sohm(
length_size,
)
}
_ => Err(FormatError::InvalidSharedMessageVersion(
(None, None) => Err(FormatError::InvalidSharedMessageVersion(
shared_ref.ref_type,
)),
}
@@ -522,15 +551,15 @@ mod tests {
}
#[test]
fn parse_v3_type1_ref() {
fn parse_v3_committed_ref() {
let mut data = Vec::new();
data.push(3); // version
data.push(1); // type 1 = shared in another OH
data.push(SHARE_TYPE_COMMITTED); // message lives in another object header
data.extend_from_slice(&0x1234u64.to_le_bytes()); // address
let shared = parse_shared_ref(&data, 8).unwrap();
assert_eq!(shared.version, 3);
assert_eq!(shared.ref_type, 1);
assert_eq!(shared.ref_type, SHARE_TYPE_COMMITTED);
assert_eq!(shared.object_header_address, Some(0x1234));
assert!(shared.heap_id.is_none());
}
@@ -539,7 +568,7 @@ mod tests {
fn parse_v3_type3_ref() {
let mut data = Vec::new();
data.push(3); // version
data.push(3); // type 3 = shared in another OH (v3 encoding)
data.push(SHARE_TYPE_HERE); // stored here but sharable: an address
data.extend_from_slice(&0xABCDu64.to_le_bytes());
let shared = parse_shared_ref(&data, 8).unwrap();
@@ -563,10 +592,10 @@ mod tests {
#[test]
fn parse_v2_ref() {
// v2 dropped v1's six reserved bytes: the address follows the type.
let mut data = Vec::new();
data.push(2); // version
data.push(0); // type
data.extend_from_slice(&[0u8; 6]); // reserved
data.push(SHARE_TYPE_COMMITTED);
data.extend_from_slice(&0x9000u32.to_le_bytes());
let shared = parse_shared_ref(&data, 4).unwrap();
@@ -575,15 +604,26 @@ mod tests {
}
#[test]
fn parse_v3_type2_sohm() {
fn parse_v2_ref_from_hdf5_2_0() {
// Datatype message of a dataset created with a committed datatype,
// as written by h5py 3.16 / HDF5 2.0 (libver='latest'): header flags
// 0x03 (shared), payload `02 02 <8-byte object header address>`.
let data = [0x02, 0x02, 0xb3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let shared = parse_shared_ref(&data, 8).unwrap();
assert_eq!(shared.object_header_address, Some(0xb3));
assert!(shared.heap_id.is_none());
}
#[test]
fn parse_v3_sohm_ref() {
let mut data = Vec::new();
data.push(3); // version
data.push(2); // type 2 = SOHM heap
data.push(SHARE_TYPE_SOHM); // message lives in the SOHM fractal heap
data.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44]);
let shared = parse_shared_ref(&data, 8).unwrap();
assert_eq!(shared.version, 3);
assert_eq!(shared.ref_type, 2);
assert_eq!(shared.ref_type, SHARE_TYPE_SOHM);
assert_eq!(shared.object_header_address, None);
assert_eq!(
shared.heap_id,
@@ -592,10 +632,10 @@ mod tests {
}
#[test]
fn parse_v3_type2_too_short() {
fn parse_v3_sohm_too_short() {
let mut data = Vec::new();
data.push(3); // version
data.push(2); // type 2 = SOHM heap
data.push(SHARE_TYPE_SOHM);
data.extend_from_slice(&[0xAA, 0xBB]); // only 2 bytes, need 8
let err = parse_shared_ref(&data, 8).unwrap_err();
@@ -620,7 +660,7 @@ mod tests {
fn parse_four_byte_offsets() {
let mut data = Vec::new();
data.push(3); // version
data.push(1); // type 1
data.push(SHARE_TYPE_COMMITTED);
data.extend_from_slice(&0x1000u32.to_le_bytes());
let shared = parse_shared_ref(&data, 4).unwrap();
+8 -5
View File
@@ -80,7 +80,10 @@ impl SymbolTableNode {
offset_size: u8,
) -> Result<SymbolTableNode, FormatError> {
// signature(4) + version(1) + reserved(1) + number_of_symbols(2) = 8
if offset.checked_add(8).is_none_or(|end| end > file_data.len()) {
if offset
.checked_add(8)
.is_none_or(|end| end > file_data.len())
{
return Err(FormatError::UnexpectedEof {
expected: offset.saturating_add(8),
available: file_data.len(),
@@ -103,12 +106,12 @@ impl SymbolTableNode {
// Each entry: link_name_offset(os) + obj_hdr_addr(os) + cache_type(4) + reserved(4) + scratch(16)
let entry_size = os + os + 4 + 4 + 16;
let entries_start = offset + 8;
let needed = entries_start
.checked_add(num_symbols * entry_size)
.ok_or(FormatError::UnexpectedEof {
let needed = entries_start.checked_add(num_symbols * entry_size).ok_or(
FormatError::UnexpectedEof {
expected: usize::MAX,
available: file_data.len(),
})?;
},
)?;
if needed > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: needed,
+51 -1
View File
@@ -279,6 +279,43 @@ pub(crate) fn build_attr_message(name: &str, value: &AttrValue) -> AttributeMess
dataspace: scalar_ds(),
raw_data: v.to_le_bytes().to_vec(),
},
AttrValue::U64Array(arr) => {
let mut raw = Vec::with_capacity(arr.len() * 8);
for v in arr {
raw.extend_from_slice(&v.to_le_bytes());
}
AttributeMessage {
name: name.to_string(),
datatype: Datatype::FixedPoint {
size: 8,
byte_order: DatatypeByteOrder::LittleEndian,
signed: false,
bit_offset: 0,
bit_precision: 64,
},
dataspace: simple_1d(arr.len() as u64),
raw_data: raw,
}
}
AttrValue::Raw {
datatype,
shape,
data,
} => AttributeMessage {
name: name.to_string(),
datatype: datatype.clone(),
dataspace: if shape.is_empty() {
scalar_ds()
} else {
Dataspace {
space_type: DataspaceType::Simple,
rank: shape.len() as u8,
dimensions: shape.clone(),
max_dimensions: None,
}
},
raw_data: data.clone(),
},
AttrValue::String(s) => {
let bytes = s.as_bytes();
AttributeMessage {
@@ -334,7 +371,7 @@ pub(crate) fn simple_1d(n: u64) -> Dataspace {
// ---- Attribute values ----
/// Convenient attribute values for the write API.
/// Attribute values, for both the write API and what reading returns.
#[derive(Debug, Clone)]
pub enum AttrValue {
F64(f64),
@@ -342,8 +379,21 @@ pub enum AttrValue {
I64(i64),
I64Array(Vec<i64>),
U64(u64),
/// Unsigned integers, kept unsigned so values above `i64::MAX` survive.
U64Array(Vec<u64>),
String(String),
StringArray(Vec<String>),
/// An attribute whose datatype has no dedicated variant above (compound,
/// general enum, complex, reference, opaque, array, ...), carried verbatim
/// so it is never silently lost: the datatype, the dataspace dimensions
/// (empty for a scalar) and the element bytes exactly as stored. Decode
/// `data` with `clawhdf5_format::data_read` (e.g. `read_compound_fields`)
/// against `datatype`. Writing a `Raw` value stores it back unchanged.
Raw {
datatype: Datatype,
shape: Vec<u64>,
data: Vec<u8>,
},
}
// ---- Dataset builder ----
@@ -343,7 +343,11 @@ fn attrs_h5_dataset_scale() {
let scale_attr = find_attribute(&attrs, "scale").expect("scale attr not found");
let vals = scale_attr.read_as_f64().unwrap();
assert_eq!(vals.len(), 1);
assert!((vals[0] - 3.14).abs() < 1e-10);
// 3.14 here is the literal value baked into the binary fixture (fixtures/attrs.h5),
// not an arbitrary sample value, so it cannot be swapped for another constant.
#[allow(clippy::approx_constant)]
let expected = 3.14;
assert!((vals[0] - expected).abs() < 1e-10);
}
#[test]
@@ -556,8 +560,8 @@ fn chunked_deflate_read_values() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
let values = read_as_f64(&raw, &datatype).unwrap();
assert_eq!(values.len(), 100);
for i in 0..100 {
assert_eq!(values[i], i as f64, "mismatch at index {i}");
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as f64, "mismatch at index {i}");
}
}
@@ -567,8 +571,8 @@ fn chunked_shuffle_deflate_read_values() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
let values = read_as_f64(&raw, &datatype).unwrap();
assert_eq!(values.len(), 100);
for i in 0..100 {
assert_eq!(values[i], i as f64, "mismatch at index {i}");
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as f64, "mismatch at index {i}");
}
}
@@ -578,8 +582,8 @@ fn chunked_fletcher32_read_values() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
let values = read_as_f64(&raw, &datatype).unwrap();
assert_eq!(values.len(), 100);
for i in 0..100 {
assert_eq!(values[i], i as f64, "mismatch at index {i}");
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as f64, "mismatch at index {i}");
}
}
@@ -589,11 +593,10 @@ fn chunked_2d_read_values() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "matrix");
let values = read_as_f32(&raw, &datatype).unwrap();
assert_eq!(values.len(), 60);
for i in 0..60 {
for (i, &v) in values.iter().enumerate() {
assert!(
(values[i] - i as f32).abs() < 1e-6,
"mismatch at index {i}: got {}",
values[i]
(v - i as f32).abs() < 1e-6,
"mismatch at index {i}: got {v}"
);
}
}
@@ -604,8 +607,8 @@ fn chunked_large_read_values() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "big");
let values = read_as_i32(&raw, &datatype).unwrap();
assert_eq!(values.len(), 1000);
for i in 0..1000 {
assert_eq!(values[i], i as i32, "mismatch at index {i}");
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as i32, "mismatch at index {i}");
}
}
@@ -615,8 +618,8 @@ fn chunked_nofilter_read_values() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "raw");
let values = read_as_f64(&raw, &datatype).unwrap();
assert_eq!(values.len(), 50);
for i in 0..50 {
assert_eq!(values[i], i as f64, "mismatch at index {i}");
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as f64, "mismatch at index {i}");
}
}
@@ -646,8 +649,8 @@ fn v4_implicit_read() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
let values = read_as_f64(&raw, &datatype).unwrap();
assert_eq!(values.len(), 100);
for i in 0..100 {
assert_eq!(values[i], i as f64, "mismatch at index {i}");
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as f64, "mismatch at index {i}");
}
}
@@ -657,8 +660,8 @@ fn v4_fixed_array_read() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
let values = read_as_f64(&raw, &datatype).unwrap();
assert_eq!(values.len(), 100);
for i in 0..100 {
assert_eq!(values[i], i as f64, "mismatch at index {i}");
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as f64, "mismatch at index {i}");
}
}
@@ -871,11 +874,10 @@ fn v4_2d_fixed_array_read() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "matrix");
let values = read_as_f32(&raw, &datatype).unwrap();
assert_eq!(values.len(), 60);
for i in 0..60 {
for (i, &v) in values.iter().enumerate() {
assert!(
(values[i] - i as f32).abs() < 1e-6,
"mismatch at index {i}: got {}",
values[i]
(v - i as f32).abs() < 1e-6,
"mismatch at index {i}: got {v}"
);
}
}
@@ -1272,7 +1274,7 @@ fn write_roundtrip_scalar_f64_attr() {
let mut fw = FileWriter::new();
fw.create_dataset("data")
.with_f64_data(&[1.0])
.set_attr("scale", AttrValue::F64(3.14));
.set_attr("scale", AttrValue::F64(3.25));
let bytes = fw.finish().unwrap();
let sig = find_signature(&bytes).unwrap();
@@ -1283,7 +1285,7 @@ fn write_roundtrip_scalar_f64_attr() {
let scale = find_attribute(&attrs, "scale").expect("scale attr not found");
let vals = scale.read_as_f64().unwrap();
assert_eq!(vals.len(), 1);
assert!((vals[0] - 3.14).abs() < 1e-10);
assert!((vals[0] - 3.25).abs() < 1e-10);
}
#[test]
@@ -180,15 +180,20 @@ print('ok')
let output = match output {
Ok(o) if o.status.success() => o,
_ => {
// CI sets CLAWHDF5_REQUIRE_INTEROP=1 so this can't silently skip.
assert!(
!std::env::var("CLAWHDF5_REQUIRE_INTEROP").is_ok_and(|v| v == "1"),
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
);
eprintln!("skipping h5py_object_reference_roundtrip: python3+h5py not available");
return;
}
};
let stdout = String::from_utf8(output.stdout).unwrap();
if !stdout.trim().contains("ok") {
eprintln!("skipping h5py_object_reference_roundtrip: h5py script failed");
return;
}
assert!(
stdout.trim().contains("ok"),
"h5py reference-file generator did not report ok: {stdout}"
);
// Read the file and parse object references
let file_data = std::fs::read(&path).unwrap();
@@ -235,17 +235,31 @@ fn h5py_reads_our_array_dataset() {
#[test]
#[ignore = "requires Python h5py module"]
fn read_h5py_generated_compound() {
let path = std::env::temp_dir().join("clawhdf5_h5py_compound.h5");
check_h5py_generated_compound("latest", ", libver='latest'");
}
/// Same file written with h5py's default format bounds. HDF5 2.0 raised the
/// default low bound to 1.8, so "default" files exercise different on-disk
/// structures than both `libver='latest'` and pre-2.0 defaults.
#[test]
#[ignore = "requires Python h5py module"]
fn read_h5py_generated_compound_default_libver() {
check_h5py_generated_compound("default", "");
}
fn check_h5py_generated_compound(tag: &str, libver_kw: &str) {
let path = std::env::temp_dir().join(format!("clawhdf5_h5py_compound_{tag}.h5"));
let gen_script = format!(
r#"
import h5py, numpy as np
dt = np.dtype([('x', 'f8'), ('y', 'f8'), ('id', 'i4')])
data = np.array([(1.0, 2.0, 10), (3.0, 4.0, 20)], dtype=dt)
f = h5py.File('{}', 'w', libver='latest')
f = h5py.File('{}', 'w'{})
f.create_dataset('particles', data=data)
f.close()
"#,
path.display()
path.display(),
libver_kw
);
h5py_read(&path, &gen_script);
@@ -363,17 +377,31 @@ else:
#[test]
#[ignore = "requires Python h5py module"]
fn read_h5py_generated_enum() {
let path = std::env::temp_dir().join("clawhdf5_h5py_enum.h5");
check_h5py_generated_enum("latest", ", libver='latest'");
}
/// Same file written with h5py's default format bounds. HDF5 2.0 raised the
/// default low bound to 1.8, so "default" files exercise different on-disk
/// structures than both `libver='latest'` and pre-2.0 defaults.
#[test]
#[ignore = "requires Python h5py module"]
fn read_h5py_generated_enum_default_libver() {
check_h5py_generated_enum("default", "");
}
fn check_h5py_generated_enum(tag: &str, libver_kw: &str) {
let path = std::env::temp_dir().join(format!("clawhdf5_h5py_enum_{tag}.h5"));
let gen_script = format!(
r#"
import h5py, numpy as np
dt = h5py.enum_dtype({{"RED": 0, "GREEN": 1, "BLUE": 2}}, basetype=np.int32)
data = np.array([1, 0, 2, 1], dtype=np.int32)
f = h5py.File('{}', 'w', libver='latest')
f = h5py.File('{}', 'w'{})
f.create_dataset('colors', data=data, dtype=dt)
f.close()
"#,
path.display()
path.display(),
libver_kw
);
h5py_read(&path, &gen_script);
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-gpu"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders"
license = "MIT"
+6 -1
View File
@@ -6,6 +6,9 @@ use crate::shaders;
use bytemuck::Pod;
use wgpu::util::DeviceExt;
/// Upper bound on a single GPU→CPU readback wait.
const READBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// GPU-accelerated vector search engine.
///
/// Upload vectors once, then run many searches against them.
@@ -1033,10 +1036,12 @@ impl GpuAccelerator {
slice.map_async(wgpu::MapMode::Read, move |result| {
let _ = tx.send(result);
});
// Bounded wait: a wedged driver must surface as an error, not hang
// the caller forever.
self.device
.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
timeout: Some(READBACK_TIMEOUT),
})
.map_err(|e| GpuError::BufferMap(format!("device poll failed: {e}")))?;
rx.recv()
+36 -2
View File
@@ -6,9 +6,41 @@
mod tests {
use clawhdf5_gpu::{GpuAccelerator, GpuError};
fn skip_if_no_gpu() -> Option<GpuAccelerator> {
/// Serialises GPU access across tests. The harness runs tests on many
/// threads; letting each create its own wgpu instance + device (with
/// adapter-maximum limits) at the same time can wedge the driver and hang
/// the whole suite, so every test holds this lock while it owns a device.
static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn gpu_lock() -> std::sync::MutexGuard<'static, ()> {
// A panicking test poisons the lock; the guarded state is `()`.
GPU_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// A `GpuAccelerator` plus the lock that keeps other tests off the GPU.
/// Field order matters: the device is dropped before the lock is released.
struct LockedGpu {
gpu: GpuAccelerator,
_guard: std::sync::MutexGuard<'static, ()>,
}
impl std::ops::Deref for LockedGpu {
type Target = GpuAccelerator;
fn deref(&self) -> &GpuAccelerator {
&self.gpu
}
}
impl std::ops::DerefMut for LockedGpu {
fn deref_mut(&mut self) -> &mut GpuAccelerator {
&mut self.gpu
}
}
fn skip_if_no_gpu() -> Option<LockedGpu> {
let guard = gpu_lock();
match GpuAccelerator::new() {
Ok(gpu) => Some(gpu),
Ok(gpu) => Some(LockedGpu { gpu, _guard: guard }),
Err(_) => {
eprintln!("SKIPPED: no GPU available");
None
@@ -69,6 +101,7 @@ mod tests {
#[test]
fn test_gpu_availability_detection() {
// Should not panic regardless of GPU presence
let _guard = gpu_lock();
let available = GpuAccelerator::is_available();
eprintln!("GPU available: {available}");
}
@@ -425,6 +458,7 @@ mod tests {
#[test]
fn test_graceful_no_gpu_fallback() {
// This test just demonstrates the pattern — it always passes
let _guard = gpu_lock();
match GpuAccelerator::new() {
Ok(gpu) => {
eprintln!("GPU found: {}", gpu.device_info());
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-io"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "I/O abstraction layer for rustyhdf5"
license = "MIT"
@@ -10,7 +10,7 @@ keywords = ["hdf5", "io", "science", "data"]
categories = ["filesystem", "science"]
[dependencies]
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.4.0" }
memmap2 = { version = "0.9", optional = true }
libc = { version = "0.2", optional = true }
tokio = { version = "1", features = ["fs", "io-util"], optional = true }
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-migrate"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "CLI to migrate SQLite agent memory databases to HDF5 format"
license = "MIT"
@@ -14,9 +14,9 @@ name = "clawhdf5-migrate"
path = "src/main.rs"
[dependencies]
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.2.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0" }
clawhdf5 = { path = "../clawhdf5", version = "2.2.0" }
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.4.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.4.0" }
clawhdf5 = { path = "../clawhdf5", version = "2.4.0" }
rusqlite = { version = "0.31", features = ["bundled"] }
clap = { version = "4", features = ["derive"] }
half = { workspace = true }
+5 -1
View File
@@ -57,7 +57,11 @@ fn iso8601_now() -> String {
.as_secs();
let days = (secs / 86_400) as i64;
let time_of_day = secs % 86_400;
let (h, m, s) = (time_of_day / 3600, (time_of_day % 3600) / 60, time_of_day % 60);
let (h, m, s) = (
time_of_day / 3600,
(time_of_day % 3600) / 60,
time_of_day % 60,
);
let (y, mo, d) = civil_from_days(days);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
+4 -2
View File
@@ -185,8 +185,10 @@ fn detect_embedding_dim(conn: &Connection, config: &SchemaConfig) -> SqlResult<O
/// Parse a raw byte BLOB into a Vec<f32>.
fn blob_to_f32(blob: &[u8]) -> Vec<f32> {
blob.chunks_exact(4)
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
blob.as_chunks::<4>()
.0
.iter()
.map(|b| f32::from_le_bytes(*b))
.collect()
}
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-napi"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "Node.js native addon (napi-rs) exposing clawhdf5-agent to TypeScript/JavaScript"
license = "MIT"
@@ -10,7 +10,7 @@ repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
crate-type = ["cdylib"]
[dependencies]
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.2.0" }
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.4.0" }
napi = { version = "2", default-features = false, features = ["napi9"] }
napi-derive = "2"
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-netcdf4"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "NetCDF-4 read support built on rustyhdf5 — pure Rust, no C dependencies"
license = "MIT"
@@ -10,8 +10,8 @@ keywords = ["netcdf", "netcdf4", "hdf5", "science", "climate"]
categories = ["parser-implementations", "science"]
[dependencies]
clawhdf5 = { path = "../clawhdf5", version = "2.2.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0" }
clawhdf5 = { path = "../clawhdf5", version = "2.4.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.4.0" }
[dev-dependencies]
tempfile = { workspace = true }
+5
View File
@@ -142,6 +142,7 @@ fn get_fill_value(attrs: &HashMap<String, AttrValue>, key: &str) -> Option<FillV
Some(AttrValue::String(s)) => Some(FillValue::String(s.clone())),
Some(AttrValue::F64Array(arr)) if !arr.is_empty() => Some(FillValue::Float(arr[0])),
Some(AttrValue::I64Array(arr)) if !arr.is_empty() => Some(FillValue::Int(arr[0])),
Some(AttrValue::U64Array(arr)) if !arr.is_empty() => Some(FillValue::UInt(arr[0])),
_ => None,
}
}
@@ -155,6 +156,10 @@ fn get_valid_range(attrs: &HashMap<String, AttrValue>) -> Option<(f64, f64)> {
Some(AttrValue::I64Array(arr)) if arr.len() >= 2 => {
return Some((arr[0] as f64, arr[1] as f64));
}
// Unsigned variables (NC_UBYTE..NC_UINT64) carry unsigned attributes.
Some(AttrValue::U64Array(arr)) if arr.len() >= 2 => {
return Some((arr[0] as f64, arr[1] as f64));
}
_ => {}
}
@@ -10,6 +10,12 @@ use clawhdf5_netcdf4::{AttrValue, NetCDF4File};
// Helpers
// ---------------------------------------------------------------------------
/// When `CLAWHDF5_REQUIRE_INTEROP=1` (set in CI), a missing Python dependency
/// is a test failure instead of a silent skip.
fn interop_required() -> bool {
std::env::var("CLAWHDF5_REQUIRE_INTEROP").is_ok_and(|v| v == "1")
}
fn netcdf4_python_available() -> bool {
Command::new("python3")
.args(["-c", "import netCDF4; print(netCDF4.__version__)"])
@@ -29,6 +35,10 @@ fn xarray_available() -> bool {
macro_rules! skip_if_no_netcdf4 {
() => {
if !netcdf4_python_available() {
assert!(
!interop_required(),
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with netCDF4 is not available"
);
eprintln!("SKIP: python3 with netCDF4 not available");
return;
}
@@ -38,6 +48,10 @@ macro_rules! skip_if_no_netcdf4 {
macro_rules! skip_if_no_xarray {
() => {
if !xarray_available() {
assert!(
!interop_required(),
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with xarray is not available"
);
eprintln!("SKIP: python3 with xarray not available");
return;
}
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-py"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
license = "MIT"
@@ -14,8 +14,8 @@ name = "clawhdf5"
crate-type = ["cdylib", "rlib"]
[dependencies]
clawhdf5_rs = { path = "../clawhdf5", version = "2.2.0", package = "clawhdf5" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0" }
clawhdf5_rs = { path = "../clawhdf5", version = "2.4.0", package = "clawhdf5" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.4.0" }
pyo3 = "0.29"
numpy = "0.29"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "rustyhdf5"
version = "2.2.0"
version = "2.4.0"
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
requires-python = ">=3.8"
license = { text = "MIT" }
+18
View File
@@ -128,10 +128,28 @@ pub(crate) fn attr_value_to_py(py: Python<'_>, val: &clawhdf5_rs::AttrValue) ->
let list = pyo3::types::PyList::new(py, a).unwrap();
list.into_any().unbind()
}
clawhdf5_rs::AttrValue::U64Array(a) => {
let list = pyo3::types::PyList::new(py, a).unwrap();
list.into_any().unbind()
}
clawhdf5_rs::AttrValue::StringArray(a) => {
let list = pyo3::types::PyList::new(py, a).unwrap();
list.into_any().unbind()
}
// No Python-side decoding for this datatype: hand back everything
// needed to interpret it rather than dropping the attribute.
clawhdf5_rs::AttrValue::Raw {
datatype,
shape,
data,
} => {
let dict = pyo3::types::PyDict::new(py);
dict.set_item("dtype", format!("{datatype:?}")).unwrap();
dict.set_item("shape", shape).unwrap();
dict.set_item("data", pyo3::types::PyBytes::new(py, data))
.unwrap();
dict.into_any().unbind()
}
}
}
+6 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5"
version = "2.2.0"
version = "2.4.0"
edition = "2024"
description = "Pure-Rust HDF5 reader/writer — no C dependencies"
license = "MIT"
@@ -10,16 +10,16 @@ keywords = ["hdf5", "science", "data", "binary"]
categories = ["parser-implementations", "science", "encoding"]
[dependencies]
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.2.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.4.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.4.0" }
rayon = { version = "1", optional = true }
[dev-dependencies]
tempfile = { workspace = true }
criterion = { workspace = true }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.2.0", features = ["mmap"] }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0", features = ["parallel", "fast-checksum"] }
clawhdf5-filters = { path = "../clawhdf5-filters", version = "2.2.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.4.0", features = ["mmap"] }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.4.0", features = ["parallel", "fast-checksum"] }
clawhdf5-filters = { path = "../clawhdf5-filters", version = "2.4.0" }
[[bench]]
name = "mmap_bench"
+53 -11
View File
@@ -416,15 +416,43 @@ impl<'f, R: HDF5Read> LazyDataset<'f, R> {
))
}
/// A header message's payload, resolved through the shared-message
/// indirection when needed (e.g. a committed datatype). See
/// [`clawhdf5_format::shared_message::message_data`].
fn message_payload(
&self,
msg_type: MessageType,
) -> Result<Option<std::borrow::Cow<'_, [u8]>>, Error> {
self.header
.messages
.iter()
.find(|m| m.msg_type == msg_type)
.map(|msg| {
clawhdf5_format::shared_message::message_data(
self.file.as_bytes(),
msg,
self.file.offset_size(),
self.file.length_size(),
)
.map_err(Error::Format)
})
.transpose()
}
fn required_payload(&self, msg_type: MessageType) -> Result<std::borrow::Cow<'_, [u8]>, Error> {
self.message_payload(msg_type)?
.ok_or(Error::MissingMessage(msg_type))
}
fn datatype(&self) -> Result<Datatype, Error> {
let msg = find_message(&self.header, MessageType::Datatype)?;
let (dt, _) = Datatype::parse(&msg.data)?;
let data = self.required_payload(MessageType::Datatype)?;
let (dt, _) = Datatype::parse(&data)?;
Ok(dt)
}
fn dataspace(&self) -> Result<Dataspace, Error> {
let msg = find_message(&self.header, MessageType::Dataspace)?;
Ok(Dataspace::parse(&msg.data, self.file.length_size())?)
let data = self.required_payload(MessageType::Dataspace)?;
Ok(Dataspace::parse(&data, self.file.length_size())?)
}
fn data_layout(&self) -> Result<DataLayout, Error> {
@@ -436,20 +464,32 @@ impl<'f, R: HDF5Read> LazyDataset<'f, R> {
)?)
}
fn filter_pipeline(&self) -> Option<FilterPipeline> {
self.header
.messages
.iter()
.find(|m| m.msg_type == MessageType::FilterPipeline)
.and_then(|msg| FilterPipeline::parse(&msg.data).ok())
/// `Ok(None)` means the dataset has no filter pipeline. A pipeline message
/// that is present but unparseable is an error: treating it as "no
/// filters" would hand the caller the still-compressed bytes as if they
/// were the data.
fn filter_pipeline(&self) -> Result<Option<FilterPipeline>, Error> {
self.message_payload(MessageType::FilterPipeline)?
.map(|data| FilterPipeline::parse(&data).map_err(Error::Format))
.transpose()
}
fn read_raw(&self) -> Result<Vec<u8>, Error> {
let dt = self.datatype()?;
let ds = self.dataspace()?;
let dl = self.data_layout()?;
let pipeline = self.filter_pipeline();
let pipeline = self.filter_pipeline()?;
let data = self.file.reader.as_bytes();
// Unallocated storage reads as the dataset's fill value.
clawhdf5_format::fill_value::read_full_with_fill(
&self.header.messages,
data,
&dl,
&ds,
dt.type_size() as usize,
self.file.offset_size(),
self.file.length_size(),
|| {
Ok(data_read::read_raw_data_full(
data,
&dl,
@@ -459,6 +499,8 @@ impl<'f, R: HDF5Read> LazyDataset<'f, R> {
self.file.offset_size(),
self.file.length_size(),
)?)
},
)
}
}
+4 -7
View File
@@ -48,11 +48,11 @@ pub use clawhdf5_format::dict_encoding::{DictEncoded, DictionaryEncoder};
pub use clawhdf5_format::property_list::{
DatasetCreateProps, FileAccessProps, FileCreateProps, lib_version,
};
#[cfg(feature = "provenance")]
pub use clawhdf5_format::provenance;
pub use clawhdf5_format::selection::Selection;
pub use clawhdf5_format::superblock::swmr_flags;
pub use clawhdf5_format::type_builders::{CompoundTypeBuilder, EnumTypeBuilder, FillTime};
#[cfg(feature = "provenance")]
pub use clawhdf5_format::provenance;
#[cfg(test)]
mod tests {
@@ -469,13 +469,10 @@ mod tests {
let ds = file.dataset("data").unwrap();
// Zero-copy should succeed for contiguous LE f64 on mmap
match ds.read_f64_zerocopy() {
Ok(slice) => {
if let Ok(slice) = ds.read_f64_zerocopy() {
assert_eq!(slice, &original[..]);
assert_eq!(slice, &ds.read_f64().unwrap()[..]);
}
Err(_) => {} // alignment issue, acceptable
}
} // else: alignment issue, acceptable
assert_eq!(ds.read_f64().unwrap(), original);
std::fs::remove_file(&path).ok();
+53 -11
View File
@@ -357,15 +357,43 @@ impl<'f> MmapDataset<'f> {
))
}
/// A header message's payload, resolved through the shared-message
/// indirection when needed (e.g. a committed datatype). See
/// [`clawhdf5_format::shared_message::message_data`].
fn message_payload(
&self,
msg_type: MessageType,
) -> Result<Option<std::borrow::Cow<'_, [u8]>>, Error> {
self.header
.messages
.iter()
.find(|m| m.msg_type == msg_type)
.map(|msg| {
clawhdf5_format::shared_message::message_data(
self.file.as_bytes(),
msg,
self.file.offset_size(),
self.file.length_size(),
)
.map_err(Error::Format)
})
.transpose()
}
fn required_payload(&self, msg_type: MessageType) -> Result<std::borrow::Cow<'_, [u8]>, Error> {
self.message_payload(msg_type)?
.ok_or(Error::MissingMessage(msg_type))
}
fn datatype(&self) -> Result<Datatype, Error> {
let msg = find_message(&self.header, MessageType::Datatype)?;
let (dt, _) = Datatype::parse(&msg.data)?;
let data = self.required_payload(MessageType::Datatype)?;
let (dt, _) = Datatype::parse(&data)?;
Ok(dt)
}
fn dataspace(&self) -> Result<Dataspace, Error> {
let msg = find_message(&self.header, MessageType::Dataspace)?;
Ok(Dataspace::parse(&msg.data, self.file.length_size())?)
let data = self.required_payload(MessageType::Dataspace)?;
Ok(Dataspace::parse(&data, self.file.length_size())?)
}
fn data_layout(&self) -> Result<DataLayout, Error> {
@@ -377,19 +405,31 @@ impl<'f> MmapDataset<'f> {
)?)
}
fn filter_pipeline(&self) -> Option<FilterPipeline> {
self.header
.messages
.iter()
.find(|m| m.msg_type == MessageType::FilterPipeline)
.and_then(|msg| FilterPipeline::parse(&msg.data).ok())
/// `Ok(None)` means the dataset has no filter pipeline. A pipeline message
/// that is present but unparseable is an error: treating it as "no
/// filters" would hand the caller the still-compressed bytes as if they
/// were the data.
fn filter_pipeline(&self) -> Result<Option<FilterPipeline>, Error> {
self.message_payload(MessageType::FilterPipeline)?
.map(|data| FilterPipeline::parse(&data).map_err(Error::Format))
.transpose()
}
fn read_raw(&self) -> Result<Vec<u8>, Error> {
let dt = self.datatype()?;
let ds = self.dataspace()?;
let dl = self.data_layout()?;
let pipeline = self.filter_pipeline();
let pipeline = self.filter_pipeline()?;
// Unallocated storage reads as the dataset's fill value.
clawhdf5_format::fill_value::read_full_with_fill(
&self.header.messages,
self.file.reader.as_bytes(),
&dl,
&ds,
dt.type_size() as usize,
self.file.offset_size(),
self.file.length_size(),
|| {
Ok(data_read::read_raw_data_full(
self.file.reader.as_bytes(),
&dl,
@@ -399,6 +439,8 @@ impl<'f> MmapDataset<'f> {
self.file.offset_size(),
self.file.length_size(),
)?)
},
)
}
}
+111 -14
View File
@@ -426,7 +426,6 @@ impl<'f> Dataset<'f> {
Ok(data_read::read_as_strings(&raw, &dt)?)
}
// ----- Selection-based read methods -----
/// Read selected elements as raw bytes.
@@ -448,7 +447,25 @@ impl<'f> Dataset<'f> {
let dt = self.datatype()?;
let ds = self.dataspace()?;
let dl = self.data_layout()?;
let pipeline = self.filter_pipeline();
let pipeline = self.filter_pipeline()?;
// The selection reader knows nothing about fill values. When they
// matter — no storage at all, or a non-zero fill on a chunked (possibly
// sparse) dataset — select from a fill-aware full read instead. (The
// selection reader currently decodes the full dataset too, so this
// costs nothing extra.)
let fill = clawhdf5_format::fill_value::dataset_fill_value(&self.header.messages)?;
let fill_matters = !clawhdf5_format::fill_value::has_storage(&dl)
|| (matches!(dl, DataLayout::Chunked { .. })
&& !clawhdf5_format::fill_value::is_default(fill.as_deref()));
if fill_matters {
let full = self.read_raw()?;
return Ok(data_read::extract_selection_from_buffer(
&full,
&ds.dimensions,
dt.type_size() as usize,
selection,
)?);
}
Ok(data_read::read_raw_data_selection(
self.file.data.as_bytes(),
&dl,
@@ -724,15 +741,43 @@ impl<'f> Dataset<'f> {
)?)
}
/// A header message's payload, resolved through the shared-message
/// indirection when needed (e.g. a committed datatype). See
/// [`clawhdf5_format::shared_message::message_data`].
fn message_payload(
&self,
msg_type: MessageType,
) -> Result<Option<std::borrow::Cow<'_, [u8]>>, Error> {
self.header
.messages
.iter()
.find(|m| m.msg_type == msg_type)
.map(|msg| {
clawhdf5_format::shared_message::message_data(
self.file.as_bytes(),
msg,
self.file.offset_size(),
self.file.length_size(),
)
.map_err(Error::Format)
})
.transpose()
}
fn required_payload(&self, msg_type: MessageType) -> Result<std::borrow::Cow<'_, [u8]>, Error> {
self.message_payload(msg_type)?
.ok_or(Error::MissingMessage(msg_type))
}
fn datatype(&self) -> Result<Datatype, Error> {
let msg = find_message(&self.header, MessageType::Datatype)?;
let (dt, _) = Datatype::parse(&msg.data)?;
let data = self.required_payload(MessageType::Datatype)?;
let (dt, _) = Datatype::parse(&data)?;
Ok(dt)
}
fn dataspace(&self) -> Result<Dataspace, Error> {
let msg = find_message(&self.header, MessageType::Dataspace)?;
Ok(Dataspace::parse(&msg.data, self.file.length_size())?)
let data = self.required_payload(MessageType::Dataspace)?;
Ok(Dataspace::parse(&data, self.file.length_size())?)
}
fn data_layout(&self) -> Result<DataLayout, Error> {
@@ -744,19 +789,21 @@ impl<'f> Dataset<'f> {
)?)
}
fn filter_pipeline(&self) -> Option<FilterPipeline> {
self.header
.messages
.iter()
.find(|m| m.msg_type == MessageType::FilterPipeline)
.and_then(|msg| FilterPipeline::parse(&msg.data).ok())
/// `Ok(None)` means the dataset has no filter pipeline. A pipeline message
/// that is present but unparseable is an error: treating it as "no
/// filters" would hand the caller the still-compressed bytes as if they
/// were the data.
fn filter_pipeline(&self) -> Result<Option<FilterPipeline>, Error> {
self.message_payload(MessageType::FilterPipeline)?
.map(|data| FilterPipeline::parse(&data).map_err(Error::Format))
.transpose()
}
fn read_raw(&self) -> Result<Vec<u8>, Error> {
let dt = self.datatype()?;
let ds = self.dataspace()?;
let dl = self.data_layout()?;
let pipeline = self.filter_pipeline();
let pipeline = self.filter_pipeline()?;
// Virtual datasets are assembled from source datasets; the per-file
// chunk cache does not apply. Route them through the resolver path so
@@ -765,7 +812,7 @@ impl<'f> Dataset<'f> {
let base_dir = self.file.base_dir.clone();
let resolver = move |name: &str| -> Option<Vec<u8>> {
let dir = base_dir.as_ref()?;
std::fs::read(dir.join(name)).ok()
std::fs::read(dir.join(sibling_file_name(name)?)).ok()
};
return Ok(data_read::read_raw_data_full_with_resolver(
self.file.data.as_bytes(),
@@ -779,6 +826,16 @@ impl<'f> Dataset<'f> {
)?);
}
// Unallocated storage reads as the dataset's fill value.
clawhdf5_format::fill_value::read_full_with_fill(
&self.header.messages,
self.file.data.as_bytes(),
&dl,
&ds,
dt.type_size() as usize,
self.file.offset_size(),
self.file.length_size(),
|| {
Ok(data_read::read_raw_data_cached(
self.file.data.as_bytes(),
&dl,
@@ -789,6 +846,8 @@ impl<'f> Dataset<'f> {
self.file.length_size(),
&self.file.chunk_cache,
)?)
},
)
}
}
@@ -829,6 +888,23 @@ fn datatype_byte_order(dt: &Datatype) -> DatatypeByteOrder {
}
}
/// A source-file name taken from inside an HDF5 file, accepted only if it
/// stays within the directory of the file that named it.
///
/// The name is untrusted input. Joining it blindly lets a crafted file make
/// the reader open any path the process can reach — an absolute path replaces
/// the base directory entirely, and `..` components climb out of it. Only
/// plain relative paths made of normal components are allowed.
fn sibling_file_name(name: &str) -> Option<&std::path::Path> {
use std::path::Component;
let path = std::path::Path::new(name);
let mut components = path.components().peekable();
components.peek()?;
components
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
.then_some(path)
}
fn find_message(
header: &ObjectHeader,
msg_type: MessageType,
@@ -882,3 +958,24 @@ fn resolve_group_entries(
Ok(Vec::new())
}
}
#[cfg(test)]
mod sibling_file_name_tests {
use super::sibling_file_name;
#[test]
fn only_paths_inside_the_base_directory_are_accepted() {
for ok in ["source.h5", "./source.h5", "sub/dir/source.h5"] {
assert!(sibling_file_name(ok).is_some(), "{ok}");
}
for bad in [
"",
"/etc/passwd",
"../secret.h5",
"sub/../../secret.h5",
"sub/../ok.h5",
] {
assert!(sibling_file_name(bad).is_none(), "{bad}");
}
}
}
+51 -8
View File
@@ -163,13 +163,53 @@ pub(crate) fn attrs_to_map(
) -> HashMap<String, AttrValue> {
let mut map = HashMap::new();
for attr in attrs {
if let Some(val) = decode_attr_value(attr, file_data, offset_size, length_size) {
// Every attribute is reported. One that has no dedicated `AttrValue`
// variant, or that fails to decode as its declared type, is returned
// verbatim as `AttrValue::Raw` rather than dropped — a partial
// attribute list with no indication anything is missing is worse than
// an undecoded value.
let val =
decode_attr_value(attr, file_data, offset_size, length_size).unwrap_or_else(|| {
AttrValue::Raw {
datatype: attr.datatype.clone(),
shape: attr.dataspace.dimensions.clone(),
data: attr.raw_data.clone(),
}
});
map.insert(attr.name.clone(), val);
}
}
map
}
/// `Some(values)` if `attr` is a numpy/h5py-style boolean: an enumeration over
/// an integer base type whose members are exactly `FALSE` = 0 and `TRUE` = 1.
/// This is how `attrs["flag"] = True` is stored. Reported as 0/1 integers.
fn decode_bool_enum(attr: &clawhdf5_format::attribute::AttributeMessage) -> Option<Vec<i64>> {
use clawhdf5_format::datatype::Datatype;
let Datatype::Enumeration {
base_type, members, ..
} = &attr.datatype
else {
return None;
};
if members.len() != 2 {
return None;
}
let member_value = |name: &str| {
let m = members.iter().find(|m| m.name.eq_ignore_ascii_case(name))?;
clawhdf5_format::data_read::read_as_i64(&m.value, base_type)
.ok()?
.first()
.copied()
};
if member_value("FALSE")? != 0 || member_value("TRUE")? != 1 {
return None;
}
let values = clawhdf5_format::data_read::read_as_i64(&attr.raw_data, base_type).ok()?;
values.iter().all(|v| *v == 0 || *v == 1).then_some(values)
}
fn decode_attr_value(
attr: &clawhdf5_format::attribute::AttributeMessage,
file_data: &[u8],
@@ -200,12 +240,7 @@ fn decode_attr_value(
if vals.len() == 1 {
Some(AttrValue::U64(vals[0]))
} else {
// No U64Array variant, store as I64Array.
// NOTE: This cast is lossy for values > i64::MAX (bit 63 set).
// Those values will appear as negative i64. A dedicated U64Array
// variant would be needed to handle the full u64 range.
let i64_vals: Vec<i64> = vals.iter().map(|&v| v as i64).collect();
Some(AttrValue::I64Array(i64_vals))
Some(AttrValue::U64Array(vals))
}
}
Datatype::String { .. } => {
@@ -228,6 +263,14 @@ fn decode_attr_value(
Some(AttrValue::StringArray(strings))
}
}
Datatype::Enumeration { .. } => {
let vals = decode_bool_enum(attr)?;
if vals.len() == 1 {
Some(AttrValue::I64(vals[0]))
} else {
Some(AttrValue::I64Array(vals))
}
}
_ => None,
}
}
+84 -1
View File
@@ -72,7 +72,7 @@ impl FileBuilder {
/// Serialize and write the file to the given path.
pub fn write<P: AsRef<std::path::Path>>(self, path: P) -> Result<(), Error> {
let bytes = self.finish()?;
std::fs::write(path, bytes).map_err(Error::Io)
write_file_atomically(path.as_ref(), &bytes).map_err(Error::Io)
}
}
@@ -186,3 +186,86 @@ pub fn create_datasets_parallel(specs: Vec<DatasetSpec>) -> Result<Vec<u8>, Erro
let bytes = clawhdf5_format::file_writer::finalize_parallel(blocks)?;
Ok(bytes)
}
/// Write `bytes` to `path` so that a crash or power loss leaves either the old
/// file or the complete new one — never a truncated mix. `std::fs::write`
/// truncates the destination first, so dying mid-write used to destroy the
/// existing file.
fn write_file_atomically(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
// Same directory as the target, so the rename stays on one filesystem.
let mut tmp_name = path
.file_name()
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no file name")
})?
.to_os_string();
tmp_name.push(format!(".tmp-{}", std::process::id()));
let tmp_path = path.with_file_name(tmp_name);
let result = (|| {
let mut f = std::fs::File::create(&tmp_path)?;
f.write_all(bytes)?;
f.sync_all()?;
std::fs::rename(&tmp_path, path)
})();
if result.is_err() {
let _ = std::fs::remove_file(&tmp_path);
return result;
}
// Make the rename itself durable. Best-effort: not every filesystem
// supports syncing a directory, and the new file is already in place.
#[cfg(unix)]
if let Some(dir) = path.parent() {
let dir = if dir.as_os_str().is_empty() {
std::path::Path::new(".")
} else {
dir
};
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
}
Ok(())
}
#[cfg(test)]
mod atomic_write_tests {
use super::write_file_atomically;
fn entries(dir: &std::path::Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
#[test]
fn replaces_existing_file_and_leaves_no_temp_behind() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("out.h5");
std::fs::write(&path, b"old contents").unwrap();
write_file_atomically(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(entries(dir.path()), ["out.h5"]);
}
#[test]
fn failure_leaves_the_existing_file_untouched() {
let dir = tempfile::TempDir::new().unwrap();
// The target is a directory, so the final rename cannot succeed.
let path = dir.path().join("taken");
std::fs::create_dir(&path).unwrap();
std::fs::write(path.join("keep"), b"x").unwrap();
assert!(write_file_atomically(&path, b"new").is_err());
assert!(path.is_dir());
assert_eq!(entries(dir.path()), ["taken"], "temp file cleaned up");
}
}
+358
View File
@@ -10,6 +10,12 @@ use clawhdf5::{AttrValue, CompoundTypeBuilder, DType, File, FileBuilder};
// Helpers
// ---------------------------------------------------------------------------
/// When `CLAWHDF5_REQUIRE_INTEROP=1` (set in CI), a missing Python dependency
/// is a test failure instead of a silent skip.
fn interop_required() -> bool {
std::env::var("CLAWHDF5_REQUIRE_INTEROP").is_ok_and(|v| v == "1")
}
fn python_available() -> bool {
Command::new("python3")
.args(["-c", "import h5py; print(h5py.__version__)"])
@@ -21,6 +27,10 @@ fn python_available() -> bool {
macro_rules! skip_if_no_python {
() => {
if !python_available() {
assert!(
!interop_required(),
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
);
eprintln!("SKIP: python3 with h5py not available");
return;
}
@@ -555,3 +565,351 @@ print("OK")
);
assert_eq!(run_python_output(&script), "OK");
}
// ---------------------------------------------------------------------------
// h5py uses committed (named) datatypes -> clawhdf5 reads
// ---------------------------------------------------------------------------
/// A dataset or attribute created from a committed datatype stores only a
/// *shared message* reference to it. These used to be parsed as the datatype
/// itself (yielding `Time { size: 0 }` and unreadable data) and the attribute
/// was silently dropped.
#[test]
fn h5py_committed_datatypes_clawhdf5_reads() {
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
for (tag, kwargs) in [("default", ""), ("latest", ", libver='latest'")] {
let path = dir.path().join(format!("committed_{tag}.h5"));
let path_str = path.display().to_string();
let script = format!(
r#"
import h5py, numpy as np
with h5py.File("{path_str}", "w"{kwargs}) as f:
f["f8type"] = np.dtype("<f8")
f["cmpd"] = np.dtype([("a", "<i4"), ("b", "<f8")])
f.create_dataset("d", data=np.arange(6, dtype="<f8"), dtype=f["f8type"])
f.create_dataset("c", data=np.array([(1, 2.5), (3, 4.5)], dtype=f["cmpd"].dtype), dtype=f["cmpd"])
f["d"].attrs.create("att", 7.0, dtype=f["f8type"])
"#
);
run_python(&script);
let file = File::open(&path).unwrap();
let d = file.dataset("d").unwrap();
assert_eq!(d.dtype().unwrap(), DType::F64, "{tag}");
assert_eq!(
d.read_f64().unwrap(),
vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
"{tag}"
);
assert!(
matches!(d.attrs().unwrap().get("att"), Some(AttrValue::F64(v)) if *v == 7.0),
"{tag}: attribute with a committed datatype"
);
let c = file.dataset("c").unwrap();
assert_eq!(
c.dtype().unwrap(),
DType::Compound(vec![("a".into(), DType::I32), ("b".into(), DType::F64)]),
"{tag}"
);
}
}
// ---------------------------------------------------------------------------
// h5py writes sparse / never-written datasets -> clawhdf5 applies fill values
// ---------------------------------------------------------------------------
/// Parse h5py's `print(arr.ravel().tolist())` output for integer data.
fn parse_int_list(s: &str) -> Vec<i32> {
s.trim()
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.filter(|t| !t.trim().is_empty())
.map(|t| t.trim().parse().unwrap())
.collect()
}
/// Storage HDF5 never allocated must read as the dataset's fill value. These
/// used to read as zeros (silently wrong for a non-zero fill value) or fail
/// outright (`NoDataAllocated`) for a dataset that was never written.
#[test]
fn h5py_fill_values_clawhdf5_reads() {
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
for (tag, kwargs) in [("default", ""), ("latest", ", libver='latest'")] {
let path = dir.path().join(format!("fill_{tag}.h5"));
let path_str = path.display().to_string();
let script = format!(
r#"
import h5py, numpy as np
with h5py.File("{path_str}", "w"{kwargs}) as f:
d = f.create_dataset("partial", shape=(20,), dtype="<i4", chunks=(5,), fillvalue=-1)
d[0:5] = np.arange(5)
f.create_dataset("never", shape=(4,), dtype="<i4", fillvalue=25)
f.create_dataset("never_chunked", shape=(6,), dtype="<i4", chunks=(3,), fillvalue=9)
f.create_dataset("default_fill", shape=(3,), dtype="<i4")
g = f.create_dataset("gz", shape=(20,), dtype="<i4", chunks=(5,), fillvalue=7, compression="gzip")
g[10:15] = 1
s = f.create_dataset("sparse2d", shape=(5, 7), dtype="<i4", chunks=(2, 3), fillvalue=-3)
s[2:4, 3:6] = 8
s[4, 6] = 5
with h5py.File("{path_str}", "r") as f:
for name in ["partial", "never", "never_chunked", "default_fill", "gz", "sparse2d"]:
print(name, f[name][...].ravel().tolist())
print("slab", f["sparse2d"][1:5, 2:7].ravel().tolist())
"#
);
let expected: std::collections::HashMap<String, Vec<i32>> = run_python_output(&script)
.lines()
.map(|line| {
let (name, list) = line.split_once(' ').unwrap();
(name.to_string(), parse_int_list(list))
})
.collect();
let file = File::open(&path).unwrap();
for name in [
"partial",
"never",
"never_chunked",
"default_fill",
"gz",
"sparse2d",
] {
assert_eq!(
file.dataset(name).unwrap().read_i32().unwrap(),
expected[name],
"{tag}/{name}"
);
}
// A hyperslab straddling allocated and unallocated chunks.
let slab = clawhdf5_format::selection::Selection::Hyperslab {
start: vec![1, 2],
stride: vec![1, 1],
count: vec![4, 5],
block: vec![1, 1],
};
assert_eq!(
file.dataset("sparse2d")
.unwrap()
.read_i32_selection(&slab)
.unwrap(),
expected["slab"],
"{tag}/sparse2d hyperslab"
);
}
}
// ---------------------------------------------------------------------------
// h5py writes soft / external links and external raw data -> clawhdf5
// ---------------------------------------------------------------------------
/// Soft links are followed (absolute, relative, through groups, with a cycle
/// guard). Things this reader does not follow — external links, and datasets
/// whose raw data lives in another file — are explicit errors. They used to
/// surface as a misleading `PathNotFound`, and external raw data could read
/// back as fill values.
#[test]
fn h5py_links_clawhdf5_resolves_or_refuses() {
use clawhdf5::Error;
use clawhdf5_format::error::FormatError;
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let dir_str = dir.path().display().to_string();
for (tag, kwargs) in [("default", ""), ("latest", ", libver='latest'")] {
let script = format!(
r#"
import h5py, numpy as np, os
os.chdir("{dir_str}")
with h5py.File("other_{tag}.h5", "w"{kwargs}) as o:
o.create_dataset("remote", data=np.arange(3, dtype="<i4"))
with h5py.File("links_{tag}.h5", "w"{kwargs}) as f:
f.create_dataset("real", data=np.arange(4, dtype="<i4"))
g = f.create_group("grp")
g.create_dataset("inner", data=np.arange(2, dtype="<i4"))
g["rel"] = h5py.SoftLink("inner")
f["soft"] = h5py.SoftLink("/real")
f["soft_grp"] = h5py.SoftLink("/grp")
f["dangling"] = h5py.SoftLink("/nope")
f["loop_a"] = h5py.SoftLink("/loop_b")
f["loop_b"] = h5py.SoftLink("/loop_a")
f["ext"] = h5py.ExternalLink("other_{tag}.h5", "/remote")
f.create_dataset("extdata", shape=(4,), dtype="<i4", external=[("raw_{tag}.bin", 0, 16)])
f["extdata"][...] = np.array([11, 22, 33, 44], dtype="<i4")
"#
);
run_python(&script);
let file = File::open(dir.path().join(format!("links_{tag}.h5"))).unwrap();
let read = |path: &str| file.dataset(path).and_then(|d| d.read_i32());
assert_eq!(read("soft").unwrap(), vec![0, 1, 2, 3], "{tag}");
assert_eq!(read("soft_grp/inner").unwrap(), vec![0, 1], "{tag}");
assert_eq!(
read("grp/rel").unwrap(),
vec![0, 1],
"{tag}: relative target"
);
assert_eq!(
read("soft_grp/rel").unwrap(),
vec![0, 1],
"{tag}: link via link"
);
assert!(
matches!(read("dangling"), Err(Error::Format(FormatError::PathNotFound(p))) if p == "nope"),
"{tag}: dangling link names its missing target"
);
assert!(
matches!(
read("loop_a"),
Err(Error::Format(FormatError::NestingDepthExceeded))
),
"{tag}: link cycle"
);
assert!(
matches!(
read("ext"),
Err(Error::Format(FormatError::ExternalLinkUnsupported { ref object_path, .. }))
if object_path == "/remote"
),
"{tag}: external link"
);
assert!(
matches!(
read("extdata"),
Err(Error::Format(FormatError::ExternalDataFilesUnsupported))
),
"{tag}: external raw data must not read as fill values"
);
}
}
// ---------------------------------------------------------------------------
// Attribute fidelity: nothing is dropped, unsigned stays unsigned
// ---------------------------------------------------------------------------
/// `attrs()` used to omit, without any error, every attribute whose datatype
/// had no `AttrValue` variant — including every Python `bool` (stored as an
/// enum) — and to wrap unsigned 64-bit arrays into negative `i64`s.
#[test]
fn h5py_attribute_kinds_are_all_reported() {
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("attrs_kinds.h5");
let path_str = path.display().to_string();
let script = format!(
r#"
import h5py, numpy as np
with h5py.File("{path_str}", "w") as f:
d = f.create_dataset("d", data=np.arange(3))
a = d.attrs
a["float"] = 1.5; a["int"] = 7; a["str"] = "hello"; a["int_array"] = np.arange(4)
a["str_list"] = ["a", "bc"]
a["uint64_array"] = np.array([2**63, 1], dtype="u8")
a["bool_true"] = True
a["bool_false"] = False
a["bool_array"] = np.array([True, False, True])
a["complex"] = 1 + 2j
a["compound"] = np.array([(1, 2.5)], dtype=[("a", "<i4"), ("b", "<f8")])
a["object_ref"] = d.ref
a["color"] = np.array(2, dtype=h5py.enum_dtype({{"RED": 0, "GREEN": 1, "BLUE": 2}}, basetype="i1"))
print(len(a))
"#
);
let written: usize = run_python_output(&script).trim().parse().unwrap();
let file = File::open(&path).unwrap();
let attrs = file.dataset("d").unwrap().attrs().unwrap();
assert_eq!(
attrs.len(),
written,
"every attribute is reported: {attrs:?}"
);
// Booleans decode as 0/1.
assert!(matches!(attrs["bool_true"], AttrValue::I64(1)));
assert!(matches!(attrs["bool_false"], AttrValue::I64(0)));
assert!(matches!(&attrs["bool_array"], AttrValue::I64Array(v) if v == &[1, 0, 1]));
// Unsigned stays unsigned.
assert!(matches!(&attrs["uint64_array"], AttrValue::U64Array(v) if v == &[1u64 << 63, 1]));
// A general enum is not a boolean: kept verbatim, member names intact.
match &attrs["color"] {
AttrValue::Raw { datatype, data, .. } => {
assert_eq!(data, &[2]);
assert!(format!("{datatype:?}").contains("BLUE"));
}
other => panic!("color: {other:?}"),
}
// A compound attribute is kept verbatim and decodes against its datatype.
match &attrs["compound"] {
AttrValue::Raw {
datatype,
shape,
data,
} => {
assert_eq!(shape, &[1]);
let fields = clawhdf5_format::data_read::read_compound_fields(data, datatype).unwrap();
assert_eq!(fields[0].name, "a");
let b =
clawhdf5_format::data_read::read_as_f64(&fields[1].raw_data, &fields[1].datatype)
.unwrap();
assert_eq!(b, vec![2.5]);
}
other => panic!("compound: {other:?}"),
}
for name in ["complex", "object_ref"] {
assert!(
matches!(attrs[name], AttrValue::Raw { .. }),
"{name}: {:?}",
attrs[name]
);
}
}
/// `Raw` and `U64Array` are writable, so an attribute read from one file can
/// be stored in another unchanged.
#[test]
fn clawhdf5_writes_raw_and_unsigned_attrs_h5py_reads() {
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.h5");
let dst = dir.path().join("dst.h5");
let (src_str, dst_str) = (src.display().to_string(), dst.display().to_string());
run_python(&format!(
r#"
import h5py, numpy as np
with h5py.File("{src_str}", "w") as f:
d = f.create_dataset("d", data=np.arange(3))
d.attrs["compound"] = np.array([(1, 2.5), (3, 4.5)], dtype=[("a", "<i4"), ("b", "<f8")])
"#
));
let compound = File::open(&src)
.unwrap()
.dataset("d")
.unwrap()
.attrs()
.unwrap()["compound"]
.clone();
let mut builder = FileBuilder::new();
let ds = builder.create_dataset("d");
ds.with_f64_data(&[1.0]);
ds.set_attr("compound", compound);
ds.set_attr("big", AttrValue::U64Array(vec![u64::MAX, 0, 1 << 63]));
builder.write(&dst).unwrap();
let out = run_python_output(&format!(
r#"
import h5py
with h5py.File("{dst_str}", "r") as f:
a = f["d"].attrs
print(a["compound"].tolist(), a["compound"].dtype.names, a["big"].tolist(), a["big"].dtype)
"#
));
assert_eq!(
out.trim(),
"[(1, 2.5), (3, 4.5)] ('a', 'b') [18446744073709551615, 0, 9223372036854775808] uint64"
);
}
+4 -2
View File
@@ -955,8 +955,10 @@ fn read_selection_all_matches_read_raw_on_chunked_dataset() {
let via_read_f64 = ds.read_f64().unwrap();
let via_selection_bytes = ds.read_selection(&Selection::All).unwrap();
let via_selection: Vec<f64> = via_selection_bytes
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.as_chunks::<8>()
.0
.iter()
.map(|c| f64::from_le_bytes(*c))
.collect();
assert_eq!(via_read_f64, data);
+1 -1
View File
@@ -315,7 +315,7 @@ fn zerocopy_roundtrip_write_read() {
let dir = std::env::temp_dir();
let path = dir.join("zc_roundtrip.h5");
let original = vec![3.14, 2.718, 1.414, 1.732, 0.577];
let original = vec![3.25, 2.75, 1.414, 1.732, 0.577];
let mut b = FileBuilder::new();
b.create_dataset("data")
.with_f64_data(&original)
+64 -7
View File
@@ -72,11 +72,68 @@ generated yet; one written with the C API (`H5T_STD_REF`) is needed.
## `clawhdf5-gpu` `gpu_tests` can hang under the default parallel test runner
**Status:** open. Observed 2026-09-18 (RTX 5060 Ti, Linux).
**Status:** fixed 2026-09-19.
**Summary:** during `cargo test --workspace`, the `gpu_tests` binary sat idle
(~1% CPU) for 25+ minutes and had to be killed. Run single-threaded it passes
in seconds (20/20): `cargo test -p clawhdf5-gpu --test gpu_tests -- --test-threads=1`.
Suspected cause: several tests creating wgpu devices concurrently (possibly
compounded by the rest of the workspace's tests loading the machine). Not yet
root-caused; workaround is `--test-threads=1` for that crate.
**Summary:** during `cargo test --workspace` the `gpu_tests` binary sat idle for
25+ minutes. Every test created its own `wgpu::Instance` + device (requesting
adapter-maximum limits) concurrently, and readback used an unbounded
`device.poll(Wait)`.
**Fix:** tests hold a process-wide lock while they own a device, and
`GpuAccelerator` readback waits time out after 30 s with `GpuError::BufferMap`.
## Compound datatype versions 1 and 2 are mis-parsed (default libver files)
**Status:** fixed 2026-09-19. Found by adding a default-libver axis to the h5py
interop tests.
**Summary:** any compound dataset written with default libver bounds (plain
`h5py.File(path, 'w')`, datatype message version 1) failed to read, typically
with `Overflow("compound member 'x': byte_offset(0) + field_size(4136977) ...")`.
Only `libver='latest'` files (version 3+) and files written by clawhdf5 itself
worked, which is why the existing tests never caught it.
**Root cause:** `Datatype::parse` skipped 24 bytes of legacy per-member array
fields for v1 where the format has 28 (dimensionality 1 + reserved 3 +
permutation 4 + reserved 4 + 4 dimension sizes 16), and treated v2 like v1 minus
name padding, whereas v2 keeps the 8-byte name padding and has no array fields.
## Attributes with unsupported datatypes are silently dropped
**Status:** fixed 2026-09-19.
**Summary:** `Dataset::attrs()` / `Group::attrs()` returned only attributes
convertible to `AttrValue` and omitted the rest without any indication — every
Python `bool` (an HDF5 enum), complex, compound and reference attributes. Unsigned
64-bit arrays were also cast to `I64Array`, turning values above `i64::MAX`
negative.
**Fix:** booleans decode as 0/1 integers, `AttrValue::U64Array` keeps unsigned
arrays unsigned, and `AttrValue::Raw { datatype, shape, data }` carries any other
attribute verbatim. Both new variants are writable. Still lossy: a
multi-dimensional numeric attribute is returned as a flat array (its shape is
not reported).
## B-tree v2 chunk index (layout v4, index type 5) is not supported
**Status:** open.
**Summary:** a chunked dataset with **two or more unlimited dimensions** written
with `libver='latest'` indexes its chunks with a version-2 B-tree. Reading it
fails with `ChunkedReadError("unsupported chunked layout version=4,
index_type=Some(5)")`. Single-chunk, implicit, fixed-array and
extensible-array indexes (and the v3 B-tree v1) are supported.
**Repro:** `f.create_dataset("d", shape=(5, 7), chunks=(2, 3), maxshape=(None, None))`
with `h5py.File(..., libver='latest')`.
## External links and external raw data are not followed
**Status:** open (by design for now); both are explicit errors.
**Summary:** a path through an external link returns
`FormatError::ExternalLinkUnsupported { filename, object_path }`, and a dataset
created with `external=[...]` storage returns
`FormatError::ExternalDataFilesUnsupported`. Neither is resolved. If support is
added, file names must be confined to the opened file's directory, as the
virtual-dataset resolver now does.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@redclaw/clawhdf5",
"version": "2.2.0",
"version": "2.4.0",
"description": "Node.js bindings for clawhdf5 — HDF5-backed agent memory with hippocampal consolidation",
"main": "index.js",
"types": "index.d.ts",
+65 -5
View File
@@ -1,9 +1,18 @@
#!/usr/bin/env bash
# CI test script — runs fmt, clippy, tests, and no_std checks.
# CI test script — runs fmt, clippy (all targets + feature matrix), tests,
# Python interop suites, bench compilation, and no_std checks.
#
# Usage:
# ./scripts/ci-test.sh
#
# Environment:
# CLAWHDF5_REQUIRE_INTEROP=1 Fail (instead of skip) when python3 with
# h5py/netCDF4/xarray is missing. CI sets this.
# Unset locally, the interop steps are skipped
# if python3+h5py is not importable.
# CLAWHDF5_FUZZ_SECONDS=N Run each cargo-fuzz target for N seconds
# (needs nightly + cargo-fuzz). Default: skip.
#
# Exit codes:
# 0 — all checks passed
# 1 — one or more checks failed
@@ -31,23 +40,74 @@ run_step() {
fi
}
# All steps always run so one failure doesn't hide the others; the summary
# and the exit code at the end are the verdict.
# 1. Format check
run_step "cargo fmt --check" cargo fmt --check
# 2. Clippy (exclude clawhdf5-py which needs PyO3/Python)
run_step "cargo clippy" cargo clippy \
# 2. Clippy over every target (lib, bins, tests, benches, examples). Without
# --all-targets, test and bench code is never linted. clawhdf5-py is
# excluded because it needs PyO3/Python headers.
run_step "cargo clippy --all-targets" cargo clippy \
--workspace \
--exclude clawhdf5-py \
--all-targets \
-- -D warnings
# 3. Tests (exclude clawhdf5-py)
# 3. Clippy over clawhdf5-format's optional features, which the default
# workspace build never compiles (szip is left out: it needs libaec).
run_step "cargo clippy (format feature matrix)" cargo clippy \
-p clawhdf5-format \
--all-targets \
--features parallel,lz4,zstd,pcodec,fast-checksum \
-- -D warnings
# 4. Tests (exclude clawhdf5-py)
run_step "cargo test" cargo test \
--workspace \
--exclude clawhdf5-py
# 4. no_std check
run_step "cargo test (format feature matrix)" cargo test \
-p clawhdf5-format \
--features parallel,lz4,zstd,pcodec,fast-checksum
# 5. Python interop suites. The h5py writer tests are #[ignore]d so a plain
# `cargo test` stays hermetic; run them explicitly here.
if python3 -c "import h5py" >/dev/null 2>&1 || [ "${CLAWHDF5_REQUIRE_INTEROP:-0}" = "1" ]; then
run_step "h5py interop (format, ignored tests)" cargo test \
-p clawhdf5-format --test writer_h5py_tests -- --include-ignored
else
echo ""
echo "==> [h5py interop] SKIPPED: python3 with h5py not available"
STEPS+=("SKIP: h5py interop (format, ignored tests)")
fi
# 6. Benches must keep compiling (they are not run).
run_step "cargo bench --no-run" cargo bench \
--workspace \
--exclude clawhdf5-py \
--no-run
# 7. no_std check
run_step "check-nostd.sh" "$SCRIPT_DIR/check-nostd.sh"
# 8. Optional fuzz smoke run
if [ -n "${CLAWHDF5_FUZZ_SECONDS:-}" ]; then
fuzz_smoke() {
local crate target
for crate in clawhdf5-format clawhdf5-agent; do
cd "$SCRIPT_DIR/../crates/$crate" || return 1
for target in $(cargo +nightly fuzz list); do
echo "--- fuzz: $crate/$target"
cargo +nightly fuzz run "$target" -- \
-max_total_time="$CLAWHDF5_FUZZ_SECONDS" || return 1
done
done
}
run_step "fuzz smoke (${CLAWHDF5_FUZZ_SECONDS}s/target)" fuzz_smoke
fi
# Summary
echo ""
echo "========================================"