30 Commits
Author SHA1 Message Date
osobhandClaude Opus 5 7b16dc90d6 Merge release/v2.7.0
CI / test (push) Failing after 2s
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 18:24:07 -07:00
osobhandClaude Opus 5 4a5544da1d chore(release): v2.7.0
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 18:24:07 -07:00
osobhandClaude Opus 5 f4c6d43a3f Merge feat/hnsw-tuning: HNSW parameters are configurable
CI / test (push) Failing after 1s
Graph degree and both candidate-list sizes were constants, so recall
could not be traded against memory or query speed. Now MemoryConfig
fields, persisted with the store, defaulting to today's behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 17:53:11 -07:00
osobhandClaude Opus 5 e5e087f9ab feat(agent): expose the HNSW parameters in MemoryConfig
Graph degree and the build- and query-time candidate list sizes were
constants, so a deployment had no way to trade recall against memory or
query speed. They are now `MemoryConfig::hnsw_m`,
`hnsw_ef_construction` and `hnsw_ef_search`, persisted with the store
and defaulting to exactly the previous behaviour (16, 64, and a query
list that scales with `k`).

Two things the straightforward version would have got wrong:

`clawhdf5-ann` asserts a graph degree of at least 2, so a configured 0 —
from a file, or from a caller reading 0 as "use the default" — aborted
the process inside the index builder. The store clamps instead, and a
test covers it: removing the clamp makes that test panic rather than
fail.

`ef_search` and the candidate pool handed to score fusion were the same
number. Tying the pool to the new setting would mean lowering `ef` for
speed also narrows what fusion sees, quietly degrading hybrid results
through a knob that looks like it only costs time. They are now
independent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 17:53:11 -07:00
osobhandClaude Opus 5 16c9ee0554 Merge perf/mmap-open: open a store without copying the whole file
CI / test (push) Failing after 1s
read_from_disk mapped the file and then copied the whole mapping for
File::from_bytes, which maps it itself. Store open is ~28% faster
(455 ms -> 327 ms at 100k x 384); peak memory is unchanged, because the
peak falls after the parse during the index build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 17:49:54 -07:00
osobhandClaude Opus 5 1d767e3b93 perf(agent): open a store without copying the whole file
`read_from_disk` memory-mapped the file and then copied the entire
mapping into a `Vec` to hand to `File::from_bytes` — but `File::open`
memory-maps it itself whenever the facade's `mmap` feature is on, which
it is by default. So every open mapped the file, memcpy'd all of it, and
parsed the copy.

Store open at 100k x 384: 455 ms -> 327 ms, about 28% faster (two runs
after the change, 326.8 and 328.1 ms).

Peak memory is unchanged, which is worth saying because the opposite is
the natural assumption. The footprint harness now tracks a high-water
mark next to the retained figure, and it shows the peak falling after
the parse, during the index build — so a buffer allocated and freed
inside the parse never reaches it. Confirmed rather than assumed:
holding a deliberate extra copy of the whole file across the parse
leaves the peak exactly where it was, which is also what proved the
instrument was working before trusting its answer.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 17:49:47 -07:00
osobhandClaude Opus 5 a91df3f1c3 Merge fix/array-checksums: chunk index checksums are verified
CI / test (push) Failing after 2s
Fixed and Extensible Array structures all carry a Jenkins checksum that
was ignored. A single flipped bit in a chunk address parses cleanly and
points inside the file, so without the check the reader returns another
chunk's bytes as data.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 17:41:48 -07:00
osobhandClaude Opus 5 b41272487a fix(format): verify Fixed and Extensible Array checksums
Every structure in both chunk indexes — header, index block, super
block, data block and each data block page — carries a Jenkins lookup3
checksum, and all of them were parsed past and ignored.

What that costs is not a warning but correct data. Flip one low bit of a
chunk address and the index still has the right shape, the address still
lands inside the file, and the reader returns whatever bytes now sit
there as that chunk's contents. Nothing else in the parse can tell.

Verified in both directions. The checksums accept files written by
HDF5 2.0 from 100 to 200 000 chunks — dense, sparse, gzip-filtered and
paged — which also confirms the block layouts byte for byte, since a
wrong offset would fail every file. And an interop test corrupts an
address to check the read fails instead of returning data: removing the
verification makes that test fail with "corruption produced data instead
of an error", which is what it is there to prove.

The first version of that test passed with verification disabled — it
corrupted a byte a structural check already rejected, so it proved
nothing. Worth recording, since a test that passes for the wrong reason
looks exactly like coverage.

Hand-built fixtures now stamp real checksums, as HDF5 writers do, and
the Extensible Array ones no longer describe the superseded layout.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 17:41:41 -07:00
osobhandClaude Opus 5 0bc7a293ae Merge feat/int8-kernel: int8 index is faster than f32 on AVX2
CI / test (push) Failing after 3s
The quantised HNSW index was measured against f32 with a scalar loop on
one side and clawhdf5-accel's AVX2 kernels on the other, so the ~13%
throughput cost attributed to quantisation was a missing kernel.

With clawhdf5_accel::dot_i8 in place, at equal recall the quantised
index answers 1.63x the queries per second and builds 1.8x faster,
holding a quarter of the vectors. Still off by default, now because the
kernel is AVX2-only and aarch64 falls back to scalar.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 17:32:40 -07:00
osobhandClaude Opus 5 dea02f5214 docs: the int8 index is faster than f32 on AVX2, not slower
Measured with `clawhdf5_accel::dot_i8` in place, medians of three
alternating runs at N = 100 000 x 384, same binary:

  build      f32 3197 ms   int8 1778 ms   int8+re-score 1826 ms
  ef = 64    f32 13 399 QPS @ 0.9945   int8+re-score 21 848 QPS @ 0.9940

So at equal recall the quantised index is 1.63x the queries per second
and 1.8x the build speed, holding a quarter of the vectors. The earlier
"~13% of QPS" figure compared a scalar int8 loop against hand-written
AVX2 f32 kernels and was measuring the missing kernel; it is kept in
BENCHMARKS.md with that explanation rather than quietly replaced.

Still off by default, now for portability rather than performance: the
kernel is AVX2-only and aarch64 falls back to scalar, where the original
trade applies. A NEON kernel would settle it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 17:30:56 -07:00
osobhandClaude Opus 5 97e65f2adf feat(accel): runtime-dispatched int8 dot product
The int8-quantised HNSW index compared vectors with a scalar loop that
the compiler vectorised for the x86-64 baseline (SSE2), while the f32
path it was measured against goes through `clawhdf5-accel` and runs
AVX2. So the ~13% throughput cost recorded for `quantized_index` was a
missing kernel rather than a property of int8.

`clawhdf5_accel::dot_i8` adds a scalar fallback and an AVX2 path:
sign-extend each 16-byte half to i16, then `madd_epi16`, which
multiplies and sums adjacent pairs straight into i32 lanes. It is
dispatched through the same detected backend as the f32 kernels, and
the index now calls it.

Integer arithmetic, so the SIMD path must agree with scalar bit for
bit — tested at lengths that are and are not multiples of the block,
and at the -128 extreme for overflow.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 17:30:56 -07:00
osobhandClaude Opus 5 fb58300b3f Merge fix/audit-robustness: Extensible Array corruption, B-tree v2 crash
CI / test (push) Failing after 3s
Two read-path bugs found by auditing the improvement plan against the
code, both reaching every release up to v2.6.0.

Datasets indexed by an Extensible Array — any dataset with one
unlimited dimension — returned data from the wrong chunks past their
first few dozen, silently. The only fixture in the suite had three
chunks, inside the inline limit, so no test had ever read a data block.

A crafted file could abort the process through unbounded B-tree v2
recursion, or exhaust memory through shared subtrees. Both now refuse
in under a millisecond, and the fuzz target that should have caught it
(it only fuzzed header parsing) now walks the tree.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 17:24:55 -07:00
osobhandClaude Opus 5 eb196e824f test: cover Fixed Array chunk indexes against real files
The Extensible Array bug reached a release because no fixture had more
chunks than fit inline, so its data blocks were never read. The Fixed
Array index had the same blind spot: nothing exercised it above a
handful of chunks, and nothing reached the paged layout at all.

Checked at 100, 5 000 and 200 000 chunks plus a sparse dataset that
leaves whole pages uninitialised. It is correct throughout — it does
keep its page-init bitmap inside the data block, which is the
difference from the Extensible Array that made assuming otherwise a
bug. Adding the tests so that stays true.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 17:20:12 -07:00
osobhandClaude Opus 5 367faad7f7 fix(format): read Extensible Array chunk indexes correctly
A dataset with exactly one unlimited dimension — the ordinary
append-only case — is indexed by an Extensible Array. Only its first
few chunk entries (4 by default) sit inline in the index block, and
everything past them was read with the wrong layout. In the default
shape the 37th chunk onward came back from the wrong place: a
400-chunk dataset returned 364 wrong values while reporting success,
and beyond about a thousand chunks the read failed outright. Silently
wrong data is the worse half of that.

It survived because the only Extensible Array fixture in the suite had
three chunks — inside the inline limit — so no test ever reached a data
block.

Four layout errors, each confirmed against files written by HDF5 2.0 and
against the library source rather than inferred:

- super block `u` owns 2^(u/2) data blocks, not 2^u;
- each holds 2^((u+1)/2) * data_blk_min_elmts elements — the two
  quantities double every *other* level, a half step apart;
- a super block carries a block-offset field before its data block
  addresses, which was not skipped;
- the page-init bitmap belongs to the super block, one bit per page
  packed across all of its data blocks and read MSB-first, rather than
  living inside the data block; a paged data block also ends its prefix
  with a checksum before the first page.

Where the spec left room for doubt the file settled it: decoding a
paged block's elements and reading the chunk values they address
identifies the mapping exactly, and the bitmap's 68 set bits matched
the 34 data blocks x 2 pages that 200 000 elements need, which only
holds MSB-first.

New interop tests cross every boundary — 4, 37, 400, 5 000 and 200 000
chunks, the last with paged data blocks — plus sparse (uninitialised
pages taking fill values), gzip-filtered elements and a 2-D dataset.
All three fail against the old traversal.

Writing is untouched; this was a read-path bug.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 17:18:59 -07:00
osobhandClaude Opus 5 0901fb1499 test(fuzz): fuzz B-tree v2 traversal, not just header parsing
The existing target only called `BTreeV2Header::parse`, so the
recursive walk behind it — where a node that is its own child overflowed
the stack — was never fuzzed at all. Parsing also requires a valid
Jenkins checksum, which random input essentially never produces, so
almost every input stopped at the first branch.

The target now walks the tree after a successful parse, and also builds
a header straight from the input bytes so the traversal is reachable
without forging a checksum.

Checked both ways: against the unfixed traversal libFuzzer finds the
stack overflow (ASan: stack-overflow), and against the fix that same
input executes in 0 ms and 34.7 million further runs produce no crash,
timeout or OOM.

Corpora and crash artifacts stay out of the repository; the two crafted
inputs are covered by unit tests instead.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 17:03:21 -07:00
osobhandClaude Opus 5 e9aeb110b7 fix(format): bound B-tree v2 traversal against crafted files
Traversal recursed one frame per level with the depth taken from the
file (a u16), and followed child addresses without asking whether they
were shared. Two crafted inputs, both reproduced before fixing:

- A node listing itself as its own child, under a header claiming 65 535
  levels, overflowed the stack and aborted the process — SIGABRT, not an
  error a caller can handle — from under 100 bytes.
- Levels whose children all point at one shared node below reached it
  fan-out^depth times: 29.5 million records in 8 s from ~5 KB, and one
  more level would exhaust memory.

Depth is now capped at 64, as the fractal heap already was; no real tree
approaches it, since even at the minimum fan-out of two that is over
2^64 records. And traversal stops once it has produced more records
than the file has bytes to hold them — a valid tree stores each record
once in its own bytes, so this bounds shared subtrees without trusting
the header's own `total_records`. Both inputs now fail in under a
millisecond.

Every B-tree v2 user goes through this collector: dense attributes, v2
groups, shared messages and chunk indexes. To show the budget never
refuses a real file, a new interop test has HDF5 2.0 write a depth-2
chunk index with 40 000 records and reads back all 160 000 values; it
fails when the budget is deliberately made too tight.

Also corrects `BM25Index::search`, which claimed to use Block-Max WAND.
It scores exhaustively, and pruning would not help the store:
`hybrid_search` needs every score because fusion normalises over them.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 16:56:05 -07:00
osobhandClaude Opus 5 5889b378e9 Merge release/v2.6.0
CI / test (push) Failing after 1s
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 06:01:38 -07:00
osobhandClaude Opus 5 18dc35f7e5 chore(release): v2.6.0
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 06:01:31 -07:00
osobhandClaude Opus 5 e17ab0ceef ci: name the interop interpreter instead of relying on $GITHUB_PATH
CI / test (push) Failing after 1s
The workflow already installed h5py into /opt/interop and set
CLAWHDF5_REQUIRE_INTEROP=1, but it reached the tests only by appending
that venv to $GITHUB_PATH, which Gitea's runner does not reliably
propagate into test subprocesses. If `python3` resolved to the system
interpreter instead, every interop suite would skip. Setting
CLAWHDF5_PYTHON outright removes the question: together with
REQUIRE_INTEROP the suites either run or the build goes red.

Verified both directions locally — with a venv the four suites run 94
tests green; with a bogus interpreter and REQUIRE_INTEROP=1 the facade
and netCDF4 suites fail 22 tests rather than skipping.

Also documents creating the local `.venv` that `ci-test.sh` detects.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 04:55:09 -07:00
osobhandClaude Opus 5 105cf13347 docs: record the silent interop skip in known-issues
CI / test (push) Failing after 3s
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:45:21 -07:00
osobhandClaude Opus 5 0529f72a2c Merge feat/ann-quantisation: optional int8 vector index; interop suites run again
Cuts a loaded store's memory from 2.72x to 1.74x the raw vectors at 100k
x 384 via MemoryConfig::quantized_index, with recall held at the f32
index's level by re-scoring candidates against the exact embeddings the
store already holds. Off by default: it trades ~13% of QPS for the
memory.

Also restores the Python interop suites, which had been skipping
silently on this machine because no interpreter has h5py and PEP 668
blocks installing it into the system one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:44:59 -07:00
osobhandClaude Opus 5 a29c1b224b test: let the interop suites find a Python that actually has h5py
Every Python interop suite had stopped running on this machine: the h5py
writer round-trips, the facade suite, netCDF4 and the reference files.
`python3` is 3.14, nothing on the box has h5py, and PEP 668 refuses to
install it into a system interpreter at all — so the availability probes
all returned false and each suite skipped without failing.

A silent skip here is exactly how the v5 compound-datatype bug reached a
release, so the probes now read `CLAWHDF5_PYTHON` and `ci-test.sh` picks
up `.venv/bin/python` on its own. The detection sits at the top of the
script rather than beside the interop step, because the non-ignored
suites run in the earlier `cargo test` step and would otherwise still
miss it. `CLAWHDF5_REQUIRE_INTEROP=1` continues to turn a skip into a
failure.

Verified against a venv with h5py 3.16 / HDF5 2.0.0: 94 interop tests
across the four suites, all passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:44:54 -07:00
osobhandClaude Opus 5 c0a9206703 feat(agent): optional int8 vector index, re-scored against exact embeddings
`MemoryConfig::quantized_index` stores the HNSW index's own copy of the
embeddings as i8 rather than f32. At 100k x 384 that takes the index from
266 to 123 MiB and the whole reopened store from 399 to 256 MiB — 2.72x
to 1.74x the raw vectors, the largest remaining item in the footprint.

Quantised distances are approximate and `ef` cannot compensate, because
the loss is in the distances rather than in the graph: recall@10 tops out
at 0.967 against f32's 0.9995 and does not move between ef=128 and
ef=256. The store already holds the exact embeddings, though, so when the
index is quantised the query path re-scores the candidate pool against
them before fusion. That restores recall (0.9940 vs 0.9945 at ef=64) and
costs about 13% of QPS.

Off by default: it trades query speed for memory and which side is worth
more depends on the deployment. The flag is persisted in `/meta`, so a
reopened store does not silently revert to four times the index memory,
and the sidecar graph is rehydrated into the configured storage.

Also on the CLI as `create --quantized-index`.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:40:37 -07:00
osobhandClaude Opus 5 57756e69ec feat(ann): optional int8 storage for the index's vector copy
The HNSW index keeps its own copy of every vector, which at 100K x 384
f32 is ~146 MiB — the largest single item in the 2.43x footprint now
that the agent stores embeddings once. `Storage::Int8` cuts that copy to
a quarter by scaling each row to i8.

The scale is per row, not global. Unit-length rows in d dimensions have
components around 1/sqrt(d), so a fixed [-1, 1] scale spends fewer than
12 of the 255 levels on a 128-dimensional vector; measured against an
exact ranking that gives 0.35 top-10 overlap. Scaling each row by its
own largest component uses the full range and brings it to 0.99.

Quantised distances still cost recall on their own, and `ef` does not
buy it back because the loss is in the distances rather than the graph:
at N=100K recall@10 tops out at 0.967 against f32's 0.9995. Re-scoring a
wider candidate pool against the exact vectors removes the gap
(0.9940 vs 0.9945 at ef=64) for ~13% of query throughput and ~16% of
build time. That is the intended use, so it is what the test asserts —
against ground truth, not against the f32 index, whose own mistakes a
re-scored search is entitled to get right.

Default is unchanged: `Storage::Float32`, chosen by every existing
constructor. Serialized indexes carry f32 vectors and no storage tag, so
a quantised index is rebuilt rather than loaded; `compact()` keeps the
storage it was given.

The harness grows `--int8` and `--rerank` axes, and reports the storage
in each table header.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:35:24 -07:00
osobhandClaude Opus 5 6ad8ceb426 Merge feat/vector-footprint: store embeddings once; footprint measurement
CI / test (push) Failing after 1s
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:19:43 -07:00
osobhandClaude Opus 5 2e7e0456c1 perf(agent): store embeddings once, not twice
MemoryCache held every embedding in two places: a `Vec<Vec<f32>>` and a
flattened copy for the batched kernels, kept in lock-step on every push,
update and compaction. A store loaded from disk therefore carried the corpus
twice, plus one heap allocation per entry.

A new `cache::Embeddings` owns just the flat `[N x dim]` buffer and indexes
into it, so `embeddings[i]` still reads as a `&[f32]` row. The batch kernels
take a `VectorSet` (implemented for both `Embeddings` and `Vec<Vec<f32>>`)
instead of `&[Vec<f32>]`, so their callers and tests are unchanged. Loading no
longer unflattens what it just read.

100k 384-dim entries, reopened from disk: 505 -> 357 MiB, 3.44x -> 2.43x the
raw vectors. Recall (1.0000 at ef=64) and query latency are unchanged.

Rows are now always exactly `dim` long, shorter ones zero-padded. The old
representation allowed ragged rows, which silently misaligned the flattened
copy — every row after a wrong-length embedding — and `update` carried a
comment about falling back to a rebuild to avoid exactly that. It is now
unrepresentable. A record saved without an embedding holds a zero row and is
told apart by its norm, which is what `total_embeddings` now counts.

Measured with a counting allocator rather than RSS: freeing a structure
returns its pages to the allocator's pool, not the OS, so an RSS reading from
inside the process showed the two representations as identical.

Breaking: MemoryCache::embeddings changes type, embeddings_flat is replaced by
flat_embeddings(), rebuild_flat() is a deprecated no-op.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:17:55 -07:00
osobhandClaude Opus 5 dc0113d015 Merge feat/temporal-reranking: re-ranking keeps the retrieval score; recency metric
CI / test (push) Failing after 2s
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:05:52 -07:00
osobhandClaude Opus 5 8ea455bbcb fix(agent): re-ranking threw away the retrieval score
reranker::rerank built its combined score from temporal decay, source
authority and Hebbian activation. RerankInput carried no relevance score, so
it could not have used one: re-ranking a candidate pool reordered it purely by
age and discarded the retriever's ordering. The OpenClaw backend re-ranks
every search, so that was its shipping behaviour.

Measured over the full LongMemEval haystack (500 questions, real MiniLM
embeddings), ordering by metadata alone costs 40.6pp of Hit@1 (11.0% vs 51.6%)
and two thirds of MRR (0.1829 vs 0.6430) — the results are the newest memories
in the pool rather than the ones answering the question.

RerankInput::relevance and ReRankConfig::relevance_weight (1.0 by default)
make relevance lead, with the metadata signals breaking near-ties. Retrieval
is preserved (Hit@1 52.0%, +0.4pp against no re-ranking; MRR -0.003) and
recency discrimination improves 6-7pp, from chance to ~52%.

A half-life sweep (1, 7, 30, 90 days) moves recency 1.4pp and MRR 0.003 —
inside the noise — because the temporal term is capped by its weight while
relevance gaps are larger. The 24-hour default is kept: there is no measured
reason to change it. The two ends of the trade-off are recorded in
BENCHMARKS.md rather than just the good news.

Breaking: RerankInput and ReRankConfig gained fields.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:03:48 -07:00
osobhandClaude Opus 5 1e18ff5a86 style(bench): gate the rerank-sweep flag and helper on the embeddings feature
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 19:42:05 -07:00
osobhandClaude Opus 5 306a35347c bench: use real session dates, and measure recency discrimination
Two gaps in the LongMemEval harness, both of which had to close before any
recency feature could be judged.

The store was fed a synthetic counter (ts += 1.0 per turn) and the dataset's
own `haystack_dates` were ignored. Session order happened to be chronological,
so ordering was right, but the intervals were fiction — and exponential decay
is a function of the interval, so anything time-aware was being measured
against made-up ages. Dates are now parsed (civil-from-days, pinned against
reference values) and turns are spread over the minutes after their session
start; an unparseable date falls back to position so order still holds.

`newest_gold_first` measures what recall cannot. On a `knowledge-update`
question LongMemEval labels *both* the stale session and the one that
supersedes it as gold, so returning either scores as a hit even though only
one answers the question. The new metric asks whether the newest gold session
outranked the older ones. The current retriever scores 43-45% on it across
every mode — chance — which is the gap a temporal signal is supposed to close.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 19:41:44 -07:00
58 changed files with 3220 additions and 552 deletions
+7 -1
View File
@@ -33,8 +33,14 @@ jobs:
/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__)"
run: /opt/interop/bin/python -c "import h5py, netCDF4; print('h5py', h5py.__version__, 'HDF5', h5py.version.hdf5_version, 'netCDF4', netCDF4.__version__)"
- name: Run CI script
env:
# Name the interpreter outright rather than relying on $GITHUB_PATH
# reaching the test processes: if `python3` resolved to the system
# one instead of the venv, every interop suite would skip.
# CLAWHDF5_REQUIRE_INTEROP turns that skip into a failure, so the
# two together mean the suites either run or the build goes red.
CLAWHDF5_PYTHON: /opt/interop/bin/python
CLAWHDF5_REQUIRE_INTEROP: "1"
run: bash scripts/ci-test.sh
+1
View File
@@ -4,3 +4,4 @@ benchmarks/longmemeval/*.json
# Local model weights (MiniLM etc.) — large, not committed
weights/
.venv
+160
View File
@@ -28,6 +28,126 @@
---
## Memory footprint
`cargo run --release -p clawhdf5-bench --bin search_harness -- --footprint --full`,
384-dim `f32`. The figure that matters is **reopened**: a store loaded from
disk, which is what a long-lived process holds.
Measured with a counting global allocator, not RSS. RSS cannot see this from
inside one process — freeing a large structure returns its pages to the
allocator's pool rather than to the OS, so allocating the next one shows no
change at all. Measured that way a store holding the corpus twice and one
holding it once came out *identical* (1.00x both), which is how the first
attempt at this measurement went.
| N | vectors (raw) | reopened, before | reopened, after |
|---:|---:|---:|---:|
| 1 000 | 1 MiB | 5 MiB (3.41x) | 4 MiB (2.39x) |
| 10 000 | 15 MiB | 50 MiB (3.43x) | 35 MiB (2.42x) |
| 100 000 | 146 MiB | 505 MiB (3.44x) | **357 MiB (2.43x)** |
The cache stored every embedding twice — once as a `Vec<Vec<f32>>` and once
flattened for the batched kernels, kept in lock-step on every push, update and
compaction. Storing only the flat buffer and indexing into it gives back
almost exactly one copy of the corpus (148 MiB at 100k) and one heap
allocation per entry. Recall and query latency are unchanged.
What remains at 2.43x: the flat vectors (1.0x), the HNSW index's own copy of
them (1.0x), and text, ids and graph (~0.4x). The index copy is the next
target — it is what a quantised or borrowed representation would address.
### Quantising the index copy (`quantized_index`)
`MemoryConfig::quantized_index` stores the index's copy as `i8` instead of
`f32`. Same harness, same binary, `--footprint --full` with and without
`--int8`:
| N | vectors (raw) | indexes, f32 | indexes, int8 | reopened, f32 | reopened, int8 |
|---:|---:|---:|---:|---:|---:|
| 1 000 | 1 MiB | 2 MiB | 1 MiB | 4 MiB (2.40x) | 2 MiB (1.64x) |
| 10 000 | 15 MiB | 32 MiB | 14 MiB | 44 MiB (3.03x) | 27 MiB (1.81x) |
| 100 000 | 146 MiB | 266 MiB | **123 MiB** | 399 MiB (2.72x) | **256 MiB (1.74x)** |
The scale is **per row**, not global. A unit-length row in `d` dimensions has
components around `1/sqrt(d)`, so a fixed `[-1, 1]` scale spends fewer than 12
of the 255 levels on a 128-dimensional vector: measured against an exact
ranking that gives 0.35 top-10 overlap — unusable. Scaling each row by its own
largest component brings the same measurement to 0.99.
Quantised distances still cost recall on their own, and **`ef` does not buy it
back**, because the loss is in the distances rather than in the graph
(`--ann-only --full`, N = 100 000):
| ef | recall@10, f32 | recall@10, int8 | recall@10, int8 + re-score |
|---:|---:|---:|---:|
| 32 | 0.9775 | 0.9415 | 0.9785 |
| 64 | 0.9945 | 0.9625 | 0.9940 |
| 128 | 0.9995 | 0.9670 | 0.9990 |
| 256 | 0.9995 | 0.9670 (ceiling) | 0.9990 |
Re-scoring closes the gap: the store already holds the exact embeddings, so
the query path re-scores the candidate pool against them before fusion. That
is done automatically whenever the index is quantised.
**On AVX2 this costs nothing — it pays.** The first measurement of this put
the cost at ~13% of QPS and ~16% of build time, but that compared a scalar
int8 loop against `clawhdf5-accel`'s hand-written AVX2 kernels for `f32`:
the gap was a missing kernel, not a property of int8. With
`clawhdf5_accel::dot_i8` (AVX2: sign-extend to `i16`, then `madd_epi16`),
medians of three alternating runs at N = 100 000, same binary:
| | f32 | int8 | int8 + re-score |
|---|---:|---:|---:|
| build | 3197 ms | **1778 ms** | 1826 ms |
| QPS at ef = 64 | 13 399 | 29 195 | **21 848** |
| recall@10 at ef = 64 | 0.9945 | 0.9625 | **0.9940** |
So at equal recall the quantised index answers **1.63x as many queries per
second**, builds **1.8x faster**, and holds a quarter of the vectors. (Compare
only at equal `ef`: with re-scoring the harness raises `ef` to at least the
candidate pool, so the `ef = 16` and `ef = 32` rows are not like-for-like.)
It is still **off by default**, for portability rather than performance: the
int8 kernel is AVX2-only, and on aarch64 — including `clawhdf5-android` — it
falls back to the scalar loop, where the original trade still applies. A NEON
kernel would remove that caveat. On an x86-64 deployment, turning it on is a
win on every axis measured.
A measurement trap worth recording: the synthetic `clustered` generator in the
`clawhdf5-ann` tests draws clusters far tighter than any real embedding, so
neighbours there sit closer together than the quantisation error and top-10
*identity* is noise. Scored on that fixture int8 looks catastrophic (0.57
overlap) — a fact about the fixture, not the storage. The tests use random
vectors, and recall is measured against brute-force ground truth rather than
against the f32 index, whose own approximation errors a re-scored search is
entitled to get right.
### Opening a store (`read_from_disk`)
`HDF5Memory::open` memory-mapped the file, copied the whole mapping into a
`Vec`, and handed that to `File::from_bytes` — while `File::open` memory-maps
the file itself. Dropping the copy takes **store open from 455 ms to 327 ms**
at 100 000 x 384 (`--e2e-only --full`; two runs after the change, 326.8 and
328.1 ms).
It does **not** lower the process's peak memory, which is worth stating
precisely because it is the obvious thing to assume. The harness now reports a
high-water mark alongside the retained figure:
| N | reopened MiB | peak during open MiB |
|---:|---:|---:|
| 1 000 | 4 | 5 |
| 10 000 | 44 | 61 |
| 100 000 | 399 | 562 |
The peak is set *after* the parse, by the index build, so a buffer allocated
and freed during the parse never reaches the high-water mark. Holding a
deliberate extra copy of the file across the whole parse leaves the peak
unmoved, which is how this was confirmed rather than assumed. What the change
saves is the copy itself: a full-file memcpy on every open, and the transient
that goes with it.
## Read harness
Produced by `cargo run --release -p clawhdf5-bench --bin read_harness`: a 4096 x
@@ -653,6 +773,46 @@ add. There is no case here for changing the default; `TokenFilter::Stemmed`
is available via `HDF5Memory::set_token_filter` for callers who want Hit@5/@10
over rank-1 precision.
### Re-ranking and recency — full haystack, n=500
`reranker::rerank` combines temporal decay, source authority and Hebbian
activation. Until now its combined score contained **no relevance term at
all** — `RerankInput` did not carry the retrieval score — so a caller that
re-ranked its candidates threw the retriever's ordering away and returned them
ordered by age. The OpenClaw backend did exactly that on every search.
Measuring that is unambiguous. "Recency" below is the share of
`knowledge-update` questions where the newest gold session outranked the stale
one (see `newest_gold_first`); ~45% is chance.
| Mode | Hit@1 | Hit@5 | Hit@10 | MRR | recency |
|---|---|---|---|---|---|
| Hybrid 0.4/0.6, no re-rank | 51.6% | **81.4%** | 87.8% | 0.6430 | 45.0% |
| + re-rank, **metadata only** (pre-fix) | 11.0% | 24.8% | 43.8% | 0.1829 | **87.5%** |
| + re-rank, relevance-led, half-life 1 day | **52.0%** | 79.8% | 87.8% | 0.6403 | 51.7% |
| + re-rank, relevance-led, half-life 7 days | 51.8% | 80.8% | 87.6% | **0.6437** | **52.2%** |
| + re-rank, relevance-led, half-life 30 days | 51.8% | 81.0% | 87.8% | 0.6427 | 51.4% |
| + re-rank, relevance-led, half-life 90 days | **52.0%** | 80.4% | 87.8% | 0.6425 | 50.8% |
**The pre-fix row is the finding.** Ordering candidates by recency alone costs
40.6pp of Hit@1 and two thirds of MRR: the results are the newest memories in
the pool rather than the ones that answer the question. It does ace the recency
metric, which is exactly what makes that metric worth having — a number that
only goes up when a change is good would not have caught this.
With relevance leading, retrieval is preserved (Hit@1 +0.4pp, MRR 0.003
against no re-ranking) and recency discrimination gains 67pp. That is a real
improvement but not a solved problem: recency only breaks near-ties, so it
cannot reach the 87.5% the degenerate ordering gets. Those two rows are the
ends of a trade-off, and the default sits deliberately near the relevance end.
**Half-life is not a sensitive knob.** Across 1, 7, 30 and 90 days recency
moves 1.4pp and MRR 0.003 — inside the noise of a 500-question run — because
the temporal term is capped by its weight (0.3) while relevance differences
between candidates are larger. The 24-hour default is kept; there is no
measured reason to change it, and a corpus-matched value is not the lever it
looks like.
### Weight sweep — full haystack, n=500
`0.7/0.3` was a documented default, never a searched one. Sweeping
+197
View File
@@ -1,5 +1,202 @@
# Changelog
## v2.7.0 (2026-09-20)
### Upgrade Notes
- **Two read-path bugs fixed, one of them silent.** Datasets indexed by an
Extensible Array (any dataset with one unlimited dimension) returned data
from the wrong chunks past their first few dozen. If you have readings taken
from such a dataset with an earlier release, they may be wrong; re-read them.
- **A corrupt chunk index is now an error.** Fixed and Extensible Array
structures carry checksums that were previously ignored, so damage surfaced
as plausible data from the wrong offset. Code that read a damaged file and
got numbers will now get `ChecksumMismatch` instead. That is the point.
- **Breaking:** `MemoryConfig` gained `hnsw_m`, `hnsw_ef_construction` and
`hnsw_ef_search`, so literal constructions need updating;
`..Default::default()` does not. All three default to the previous
behaviour.
### Correctness
- `clawhdf5-format`: **datasets indexed by an Extensible Array returned wrong
data beyond their first few dozen chunks.** One unlimited dimension gives a
dataset an Extensible Array chunk index, whose first elements (4 by default)
sit inline in the index block and whose rest live in data blocks sized by a
formula the reader got wrong. In the default layout everything through the
36th chunk happened to line up and the 37th onwards did not: a 400-chunk
dataset silently returned wrong values from chunk 37, and datasets past
about a thousand chunks failed outright with "invalid Extensible Array data
block signature". **Reads were wrong, not
merely refused** — the caller got plausible numbers from the wrong chunks.
Four separate layout errors, each checked against files written by HDF5 2.0
and against the library source:
- the number of data blocks in super block `u` is `2^(u/2)`, not `2^u`;
- each holds `2^((u+1)/2) * data_blk_min_elmts` elements, which doubles
every *other* level rather than every level;
- a super block carries a block-offset field before its data block
addresses, which was not skipped;
- the page-init bitmap belongs to the super block, one bit per page packed
across all its data blocks (MSB first), and was being read from inside the
data block instead; a paged data block also ends its prefix with a
checksum before the first page.
Covered now by interop tests at 4, 37, 400, 5 000 and 200 000 chunks (the
last large enough for paged data blocks), plus sparse, gzip-filtered and
2-D cases. Writing is unaffected; this is a read-path bug.
- `clawhdf5-format`: the sibling Fixed Array index (fixed dimensions written
with `libver='latest'`) was checked against the same range and is correct,
including paged data blocks and sparse datasets — it really does keep its
page-init bitmap in the data block, where the Extensible Array does not.
It had no real-file coverage above the inline sizes either, so it now has
the same tests.
### Security
- `clawhdf5-format`: **a crafted file could crash any reader through B-tree v2
traversal.** Recursion was bounded only by the depth the file claimed (a
`u16`), and child addresses were never checked for sharing. A node listing
itself as its own child under a header claiming 65 535 levels — under 100
bytes — overflowed the stack and **aborted the process** (SIGABRT, not a
catchable error). Levels whose children all point at one shared node below
reached it fan-out^depth times: 29.5 million records from ~5 KB, and one
more level would exhaust memory. Both are now errors, returned in under a
millisecond: depth is capped at 64 (as the fractal heap already was), and
traversal stops once it has produced more records than the file has bytes
to hold. Every B-tree v2 user goes through this path — dense attributes,
v2 groups, shared messages and chunk indexes. Valid files are unaffected,
including a depth-2 HDF5 2.0 chunk index with 40 000 records, now covered by
an interop test.
### Integrity
- `clawhdf5-format`: **Fixed and Extensible Array chunk indexes now verify
their checksums** (the `checksum` feature, on by default). Every structure
in both — header, index block, super block, data block and each data block
page — carries a Jenkins lookup3 checksum that was parsed past and ignored.
The consequence of skipping it is not a missing warning but wrong data: a
single flipped bit in a chunk address still parses, still points inside the
file, and the reader hands back whatever bytes now sit there as the chunk's
contents. Verified in both directions — the checksums accept files written
by HDF5 2.0 at 100 to 200 000 chunks, dense, sparse, filtered and paged,
and an interop test corrupts an address to confirm the read now fails
instead of returning data (it does return data when the check is removed).
### Performance
- `clawhdf5-agent`: **opening a store is ~28% faster** (455 ms -> 327 ms at
100k x 384). `read_from_disk` memory-mapped the file and then copied the
entire mapping into a `Vec` for `File::from_bytes`, when `File::open`
memory-maps it directly — so every open paid a full-file memcpy for nothing.
Process peak memory is unchanged: the peak falls after the parse, during the
index build, so the transient never reached the high-water mark. The
footprint harness now reports that peak next to the retained figure, which
is how this was checked rather than assumed.
- `clawhdf5-accel`: **`dot_i8`, a runtime-dispatched int8 dot product** (AVX2:
sign-extend each half to `i16`, then `madd_epi16`; scalar fallback
elsewhere). The quantised HNSW index used a scalar loop while the `f32` path
it was measured against ran AVX2, so the ~13% throughput cost recorded for
`MemoryConfig::quantized_index` was a missing kernel rather than a property
of int8. With the kernel, at N = 100 000 x 384 and equal recall, the
quantised index answers **1.63x as many queries per second** (21 848 vs
13 399 at ef=64, recall 0.9940 vs 0.9945) and builds **1.8x faster** (1778
vs 3197 ms) — on top of holding a quarter of the vectors. Medians of three
alternating runs. It remains off by default only because the kernel is
AVX2-only and aarch64 falls back to the scalar loop. Integer arithmetic, so
the SIMD path is tested to agree with scalar bit for bit.
### Tuning
- `clawhdf5-agent`: **the HNSW parameters are configurable**
`MemoryConfig::hnsw_m`, `hnsw_ef_construction` and `hnsw_ef_search`
(defaults 16, 64, and 0 meaning "scale with `k`", i.e. today's behaviour).
They were constants, so a deployment could not trade recall against memory
or query speed at all. All three are persisted with the store. Values are
clamped where the index requires it: `clawhdf5-ann` asserts a graph degree
of at least 2, so a configured 0 — from a file, or from a caller who took 0
to mean "default" — used to abort the process inside the builder. Lowering
`ef_search` also no longer narrows the candidate pool that fusion sees.
**Breaking:** `MemoryConfig` gained fields, so literal constructions need
updating; `..Default::default()` does not.
### Documentation
- `clawhdf5-agent`: `BM25Index::search` claimed to use Block-Max WAND for early
termination. It never did; it scores every match exhaustively. It now says
so, and why no pruning would help the store: `hybrid_search` uses `scores()`,
since fusion normalises over every match.
## v2.6.0 (2026-09-20)
### Upgrade Notes
- **Re-ranked results change, substantially for the better.** `RerankInput`
and `ReRankConfig` gained fields (`relevance`, `relevance_weight`), so
literal constructions need updating; `..Default::default()` does not. Any
caller that re-ranked was previously getting results ordered by age with the
retrieval score discarded — see below.
- **Breaking:** `MemoryCache::embeddings` is a `cache::Embeddings` rather than
a `Vec<Vec<f32>>` (indexing still yields a `&[f32]` row); `embeddings_flat`
is gone, replaced by `flat_embeddings()`; `rebuild_flat()` is a deprecated
no-op.
- `MemoryConfig` gained `quantized_index` (default `false`, so behaviour is
unchanged unless you opt in); literal constructions need the field.
### Retrieval quality
- `clawhdf5-agent`: **re-ranking discarded the retrieval score.**
`reranker::rerank` built its combined score from temporal decay, source
authority and Hebbian activation only — `RerankInput` had no relevance field
— so re-ranking a candidate pool reordered it by age and threw the
retriever's ordering away. The OpenClaw backend re-ranked every search, so
this was its shipping behaviour: measured over the full LongMemEval haystack
it cost **40.6pp of Hit@1** (11.0% vs 51.6%) and two thirds of MRR (0.183 vs
0.643). `RerankInput::relevance` and `ReRankConfig::relevance_weight` (1.0 by
default) fix it: relevance leads and the metadata signals break near-ties,
which restores retrieval (Hit@1 +0.4pp vs no re-ranking) and improves
recency discrimination by 67pp. **Breaking:** `RerankInput` and
`ReRankConfig` gained fields, so literal constructions need updating;
`..Default::default()` does not.
- `clawhdf5-bench`: the LongMemEval harness feeds the dataset's real session
dates to the store instead of a synthetic counter (decay needs true
intervals, not just the right order), and reports `newest_gold_first` — on a
`knowledge-update` question, did the newest gold session outrank the stale
one it supersedes? Plain recall cannot see this, because both are labelled
gold. New `--rerank-sweep`.
### Memory
- `clawhdf5-agent`: **`MemoryConfig::quantized_index`** stores the vector
index's own copy of the embeddings as `i8` rather than `f32`, which at 100k
384-dim entries takes the index from 266 to 123 MiB and the whole reopened
store from 399 to 256 MiB (2.72x -> **1.74x** the raw vectors). Quantised
distances are approximate and `ef` cannot compensate — recall@10 tops out at
0.967 against f32's 0.9995 — so the query path re-scores the candidate pool
against the exact embeddings the store already holds, which restores recall
(0.9940 vs 0.9945 at ef=64) for about 13% of QPS. **Off by default**: it
trades query speed for memory, and which side is worth more depends on the
deployment. The setting is persisted, so a reopened store does not silently
revert to four times the index memory.
- `clawhdf5-ann`: `Storage::Int8` and the `build_with` / `new_with` /
`from_graph_bytes_with` constructors that select it. The scale is per row,
not global — a fixed `[-1, 1]` scale spends fewer than 12 of the 255 levels
on a unit-length 128-dim vector and is unusable (0.35 top-10 overlap against
an exact ranking, versus 0.99 per row). `compact()` keeps the storage it was
given; serialized indexes still carry f32 vectors, so a quantised index is
rebuilt rather than loaded.
- `clawhdf5-agent`: **a loaded store holds ~30% less memory** (100k 384-dim
entries: 505 -> 357 MiB, 3.44x -> 2.43x the raw vectors). The cache kept
every embedding twice — a `Vec<Vec<f32>>` and a flattened copy for the
batched kernels, maintained in lock-step — so it now stores only the flat
buffer and indexes into it. Recall and query latency are unchanged.
**Breaking:** `MemoryCache::embeddings` is a `cache::Embeddings` rather than
a `Vec<Vec<f32>>` (indexing still yields a `&[f32]` row); `embeddings_flat`
is gone, replaced by `flat_embeddings()`; `rebuild_flat()` is a deprecated
no-op. Rows are now always exactly `dim` long — shorter ones are
zero-padded — which makes the ragged-row case that used to silently
misalign the flattened copy unrepresentable.
- `clawhdf5-bench`: `search_harness --footprint` reports live heap use per
stage, measured with a counting allocator (RSS cannot see a structure freed
into the allocator's own pool).
### Testing
- The Python interop suites honour **`CLAWHDF5_PYTHON`**, and `ci-test.sh`
picks up a `.venv/bin/python` automatically. On a PEP 668 "externally
managed" system h5py cannot be installed into the system interpreter at all,
so every interop suite — the h5py writer round-trips, the facade, netCDF4
and the reference files — was skipping silently. A silent skip here is
exactly how the v5 compound-datatype bug reached a release.
`CLAWHDF5_REQUIRE_INTEROP=1` still turns a skip into a failure.
## v2.5.0 (2026-09-19)
### Upgrade Notes
+9 -1
View File
@@ -39,7 +39,15 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
(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
ignored and the index rebuilt). `MemoryConfig::quantized_index` (off by
default, persisted) stores the index's own copy of the embeddings as `i8`,
which roughly halves a loaded store's memory (2.72x -> 1.74x the raw vectors
at 100K); because quantised distances are approximate and `ef` cannot
compensate, the query path then re-scores the candidate pool against the
exact embeddings, which holds recall at the f32 index's level. On AVX2 it is
also 1.63x the QPS and 1.8x the build speed (`clawhdf5_accel::dot_i8`); it
stays off by default only because that kernel is AVX2-only and aarch64 falls
back to scalar. `hybrid_search` keeps one incremental BM25
index for the life of the store and never writes the store: Hebbian
activation boosts are persisted by the next checkpoint (or on drop), not per
query. Measure any search-path change with
+1 -1
View File
@@ -21,7 +21,7 @@ members = [
resolver = "2"
[workspace.package]
version = "2.5.0"
version = "2.7.0"
edition = "2024"
license = "MIT"
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
+22
View File
@@ -432,6 +432,20 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
| `agent` | no | Full agent memory layer |
| `float16` | **yes** | Half-precision embedding storage (2× compression) |
| `hnsw` | **yes** | HNSW approximate vector index for `hybrid_search` (via `clawhdf5-ann`); disable for an exact linear scan |
`MemoryConfig::hnsw_m`, `hnsw_ef_construction` and `hnsw_ef_search` tune the
vector index (16 / 64 / scale-with-`k` by default) and are stored with the
file.
`MemoryConfig::quantized_index` (off by default) stores the HNSW index's own
copy of the embeddings as `i8`, roughly halving a loaded store's memory
(2.72x -> 1.74x the raw vectors at 100k x 384). Quantised distances are
approximate, so the query path re-scores the candidate pool against the exact
embeddings the store already holds, which keeps recall at the `f32` index's
level. On AVX2 it is also **faster** — 1.63x the queries per second and 1.8x
the build speed at equal recall — because the int8 kernel is SIMD too. It
stays off by default only because that kernel is AVX2-only and aarch64 falls
back to a scalar loop. See `BENCHMARKS.md`, "Quantising the index copy".
| `parallel` | no | Rayon parallel search |
| `fast-math` | no | BLAS matrix-vector multiply |
| `accelerate` | no | Apple Accelerate / AMX (macOS) |
@@ -492,6 +506,14 @@ cargo build -p clawhdf5-agent --features "agent,float16,accelerate,parallel,gpu"
# Tests
cargo test --workspace # all 1,650+ tests
cargo test -p clawhdf5-agent # agent memory tests
scripts/ci-test.sh # what CI runs: fmt, clippy matrix, tests,
# h5py/netCDF4 interop, no_std
# The interop suites need a Python with h5py; on a PEP 668 system that has to
# be a virtualenv. `ci-test.sh` finds `.venv` on its own, or set
# CLAWHDF5_PYTHON. Without one they skip — set CLAWHDF5_REQUIRE_INTEROP=1 to
# make that a failure instead.
python3 -m venv .venv && .venv/bin/pip install h5py numpy netCDF4 xarray
# Benchmarks
cargo bench -p clawhdf5-agent # agent memory suite
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-accel"
version = "2.5.0"
version = "2.7.0"
edition = "2024"
description = "SIMD-accelerated operations for rustyhdf5"
license = "MIT"
+49
View File
@@ -25,6 +25,55 @@ unsafe fn hsum_256(v: __m256) -> f32 {
_mm_cvtss_f32(result)
}
/// AVX2 dot product of two `i8` slices, widened to `i32`.
///
/// Each 16-byte half is sign-extended to sixteen `i16` lanes and multiplied
/// pairwise with `madd_epi16`, which sums adjacent products straight into
/// eight `i32` lanes — the widening that an autovectorised scalar loop does
/// in several shuffles is one instruction here. A pair sum is at most
/// `2 * 127 * 127`, far inside `i32`.
///
/// # Safety
/// Caller must verify is_x86_feature_detected!("avx2").
// SAFETY: Caller must have verified AVX2 via is_x86_feature_detected!.
#[target_feature(enable = "avx2")]
pub unsafe fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
// SAFETY: Caller guarantees AVX2 is available per the # Safety contract;
// every load reads 32 bytes at an index checked against `len` first.
unsafe {
assert_eq!(a.len(), b.len());
let len = a.len();
let mut i = 0;
let mut acc0 = _mm256_setzero_si256();
let mut acc1 = _mm256_setzero_si256();
while i + 32 <= len {
let va = _mm256_loadu_si256(a.as_ptr().add(i).cast());
let vb = _mm256_loadu_si256(b.as_ptr().add(i).cast());
let a_lo = _mm256_cvtepi8_epi16(_mm256_castsi256_si128(va));
let b_lo = _mm256_cvtepi8_epi16(_mm256_castsi256_si128(vb));
let a_hi = _mm256_cvtepi8_epi16(_mm256_extracti128_si256(va, 1));
let b_hi = _mm256_cvtepi8_epi16(_mm256_extracti128_si256(vb, 1));
acc0 = _mm256_add_epi32(acc0, _mm256_madd_epi16(a_lo, b_lo));
acc1 = _mm256_add_epi32(acc1, _mm256_madd_epi16(a_hi, b_hi));
i += 32;
}
// Horizontal sum of the eight i32 lanes.
let v = _mm256_add_epi32(acc0, acc1);
let s128 = _mm_add_epi32(_mm256_castsi256_si128(v), _mm256_extracti128_si256(v, 1));
let s64 = _mm_add_epi32(s128, _mm_unpackhi_epi64(s128, s128));
let s32 = _mm_add_epi32(s64, _mm_shuffle_epi32(s64, 0b01));
let mut sum = _mm_cvtsi128_si32(s32);
while i < len {
sum += i32::from(a[i]) * i32::from(b[i]);
i += 1;
}
sum
}
}
/// AVX2 dot product for f32 slices.
///
/// # Safety
+56
View File
@@ -122,6 +122,23 @@ pub fn dot_product(a: &[f32], b: &[f32]) -> f32 {
}
}
/// Dot product of two `i8` slices, widened to `i32`.
///
/// The kernel behind int8-quantised vector search. Uses the AVX2 path
/// whenever AVX2 is present — including on AVX-512 machines, where it is
/// what the f32 kernels use too on a default build.
pub fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
match detect_backend() {
#[cfg(target_arch = "x86_64")]
// SAFETY: both variants imply AVX2 was detected at runtime (the
// AVX-512 backend is only selected on CPUs that also have AVX2).
Backend::Avx2 | Backend::Avx512 if is_x86_feature_detected!("avx2") => unsafe {
avx2::dot_i8(a, b)
},
_ => scalar::dot_i8(a, b),
}
}
/// Compute the L2 norm (magnitude) of a vector.
pub fn vector_norm(v: &[f32]) -> f32 {
dot_product(v, v).sqrt()
@@ -713,3 +730,42 @@ mod tests {
}
}
}
#[cfg(test)]
mod dot_i8_tests {
use super::*;
fn codes(n: usize, seed: u64) -> Vec<i8> {
let mut state = seed;
(0..n)
.map(|_| {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
// Full range, including the extremes.
((state >> 56) as u8) as i8
})
.collect()
}
#[test]
fn dispatched_kernel_matches_scalar_exactly() {
// Integer arithmetic: the SIMD path must agree bit for bit, at every
// length — including ones that are not multiples of the 32-byte block,
// which exercise the tail.
for len in [0, 1, 7, 31, 32, 33, 63, 64, 100, 384, 385, 1536] {
let a = codes(len, 1 + len as u64);
let b = codes(len, 1000 + len as u64);
assert_eq!(dot_i8(&a, &b), scalar::dot_i8(&a, &b), "len {len}");
}
}
#[test]
fn extremes_do_not_overflow() {
// -128 * -128 is the largest product; a long run of it must still fit.
let a = vec![-128i8; 4096];
assert_eq!(dot_i8(&a, &a), 4096 * 128 * 128);
let b = vec![127i8; 4096];
assert_eq!(dot_i8(&a, &b), -4096 * 128 * 127);
}
}
+30
View File
@@ -140,3 +140,33 @@ fn f16_to_f32_soft(h: u16) -> f32 {
f32::from_bits(f32_bits)
}
/// Dot product of two `i8` slices, widened to `i32`.
///
/// `dim` terms of at most `127 * 127` fit an `i32` for any realistic
/// dimension (over 130 000 terms before overflow is possible).
pub fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
assert_eq!(a.len(), b.len());
// Four independent accumulators over 32-lane blocks: the widening product
// has to sit in a fixed-length chunk for the vectoriser to see it, and the
// separate accumulators keep it off one dependency chain.
const LANE: usize = 8;
let (a_blocks, a_tail) = a.as_chunks::<{ LANE * 4 }>();
let (b_blocks, b_tail) = b.as_chunks::<{ LANE * 4 }>();
let mut acc = [0i32; 4];
for (x, y) in a_blocks.iter().zip(b_blocks) {
for (lane, slot) in acc.iter_mut().enumerate() {
let mut sum = 0i32;
for k in 0..LANE {
sum += i32::from(x[lane * LANE + k]) * i32::from(y[lane * LANE + k]);
}
*slot += sum;
}
}
let tail: i32 = a_tail
.iter()
.zip(b_tail)
.map(|(&x, &y)| i32::from(x) * i32::from(y))
.sum();
acc[0] + acc[1] + acc[2] + acc[3] + tail
}
+7 -7
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-agent"
version = "2.5.0"
version = "2.7.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.5.0", features = ["parallel", "fast-checksum"] }
clawhdf5 = { path = "../clawhdf5", version = "2.5.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.5.0", features = ["mmap"] }
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.5.0" }
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.5.0", optional = true }
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.5.0", optional = true, default-features = false }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0", features = ["parallel", "fast-checksum"] }
clawhdf5 = { path = "../clawhdf5", version = "2.7.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.7.0", features = ["mmap"] }
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.7.0" }
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.7.0", optional = true }
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.7.0", optional = true, default-features = false }
serde = { workspace = true }
byteorder = "1"
half = { workspace = true, optional = true }
+4
View File
@@ -118,6 +118,10 @@ mod tests {
created_at: "2025-01-01T00:00:00Z".to_string(),
wal_enabled: false,
wal_max_entries: 500,
quantized_index: false,
hnsw_m: 16,
hnsw_ef_construction: 64,
hnsw_ef_search: 0,
}
}
+8 -4
View File
@@ -88,8 +88,11 @@ impl BM25Index {
/// Search the index for a query, returning the top `k` results
/// as `(doc_id, score)` pairs sorted by score descending.
///
/// Uses Block-Max WAND for early termination when remaining documents
/// cannot beat the current top-k threshold.
/// Scores every matching document exhaustively, then keeps the top `k`.
/// There is no early termination (WAND, MaxScore): the store's hot path
/// is [`scores`](Self::scores), because score fusion normalises over the
/// whole matching set and so needs every score, which no pruning scheme
/// can skip. This method is for BM25-only callers.
pub fn search(&self, query: &str, k: usize) -> Vec<(usize, f32)> {
if k == 0 {
return Vec::new();
@@ -561,8 +564,9 @@ mod tests {
}
#[test]
fn wand_returns_same_results_as_exhaustive() {
// WAND-style search should produce same scores as exhaustive
fn top_k_search_matches_ranking_every_score() {
// `search` must agree with ranking the full `scores` set — the
// bounded heap is an optimisation over sorting, not an approximation.
let docs: Vec<String> = (0..100)
.map(|i| {
if i % 3 == 0 {
+162 -48
View File
@@ -2,16 +2,143 @@
use crate::vector_search;
/// Every entry's embedding, in one contiguous `[N x dim]` buffer.
///
/// Rows are always exactly `dim` long: a shorter one is zero-padded, a longer
/// one truncated. The previous `Vec<Vec<f32>>` allowed ragged rows, which
/// silently misaligned the flattened copy that the batched kernels read — a
/// single wrong-length embedding shifted every row after it. Padding makes
/// that unrepresentable. A record stored without an embedding therefore holds
/// a zero row, and is told apart by its norm being zero rather than by length.
///
/// This used to be two fields — a `Vec<Vec<f32>>` and a flattened copy kept in
/// lock-step — which stored the whole corpus twice and cost one heap
/// allocation per entry on top. At 100k 384-dim entries that duplicate was
/// ~150 MiB. Indexing yields a `&[f32]` row, so `embeddings[i]` still reads
/// the same way.
#[derive(Debug, Clone, Default)]
pub struct Embeddings {
flat: Vec<f32>,
dim: usize,
}
impl Embeddings {
pub fn new(dim: usize) -> Self {
Self {
flat: Vec::new(),
dim,
}
}
/// Number of embeddings.
pub fn len(&self) -> usize {
self.flat.len().checked_div(self.dim).unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// The whole buffer, `[N x dim]` row-major — what batched kernels read.
pub fn as_flat(&self) -> &[f32] {
&self.flat
}
pub fn dim(&self) -> usize {
self.dim
}
/// Row `i`, or `None` if out of range.
pub fn get(&self, i: usize) -> Option<&[f32]> {
let start = i.checked_mul(self.dim)?;
self.flat.get(start..start.checked_add(self.dim)?)
}
pub fn iter(&self) -> impl ExactSizeIterator<Item = &[f32]> {
self.flat.chunks_exact(self.dim.max(1))
}
/// Append one embedding. A row whose length doesn't match `dim` is padded
/// or truncated, so the buffer stays rectangular whatever a caller passes.
pub fn push(&mut self, embedding: &[f32]) {
if self.dim == 0 {
return;
}
let take = embedding.len().min(self.dim);
self.flat.extend_from_slice(&embedding[..take]);
self.flat.resize(self.flat.len() + (self.dim - take), 0.0);
}
/// Replace row `i`. Out-of-range indices are ignored.
pub fn set(&mut self, i: usize, embedding: &[f32]) {
let Some(start) = i.checked_mul(self.dim) else {
return;
};
if start + self.dim > self.flat.len() {
return;
}
let take = embedding.len().min(self.dim);
self.flat[start..start + take].copy_from_slice(&embedding[..take]);
self.flat[start + take..start + self.dim].fill(0.0);
}
/// Keep only the rows `keep` returns true for, preserving order.
pub fn retain(&mut self, mut keep: impl FnMut(usize) -> bool) {
if self.dim == 0 {
return;
}
let mut write = 0usize;
for read in 0..self.len() {
if keep(read) {
if write != read {
let (dst, src) = (write * self.dim, read * self.dim);
self.flat.copy_within(src..src + self.dim, dst);
}
write += 1;
}
}
self.flat.truncate(write * self.dim);
}
/// Replace the contents with `rows`.
pub fn reset_from(&mut self, dim: usize, rows: impl IntoIterator<Item = Vec<f32>>) {
self.dim = dim;
self.flat.clear();
for row in rows {
self.push(&row);
}
}
/// Adopt an already-flat buffer, trimming any partial trailing row.
pub fn set_flat(&mut self, dim: usize, mut flat: Vec<f32>) {
self.dim = dim;
match flat.len().checked_div(dim) {
Some(rows) => flat.truncate(rows * dim),
None => flat.clear(),
}
self.flat = flat;
}
}
impl PartialEq for Embeddings {
fn eq(&self, other: &Self) -> bool {
self.dim == other.dim && self.flat == other.flat
}
}
impl std::ops::Index<usize> for Embeddings {
type Output = [f32];
fn index(&self, i: usize) -> &[f32] {
self.get(i).expect("embedding index out of range")
}
}
/// In-memory cache for the /memory group data.
#[derive(Debug, Clone)]
pub struct MemoryCache {
pub chunks: Vec<String>,
pub embeddings: Vec<Vec<f32>>,
/// `embeddings` flattened into one contiguous `[N × embedding_dim]`
/// buffer, maintained incrementally alongside `embeddings` (push/update/
/// compact) so BLAS/Accelerate batch search can read it directly instead
/// of re-flattening the whole corpus on every query.
pub embeddings_flat: Vec<f32>,
pub embeddings: Embeddings,
pub source_channels: Vec<String>,
pub timestamps: Vec<f64>,
pub session_ids: Vec<String>,
@@ -28,8 +155,7 @@ impl MemoryCache {
pub fn new(embedding_dim: usize) -> Self {
Self {
chunks: Vec::new(),
embeddings: Vec::new(),
embeddings_flat: Vec::new(),
embeddings: Embeddings::new(embedding_dim),
source_channels: Vec::new(),
timestamps: Vec::new(),
session_ids: Vec::new(),
@@ -41,15 +167,14 @@ impl MemoryCache {
}
}
/// Rebuild `embeddings_flat` from `embeddings` from scratch. Callers that
/// populate `embeddings` directly (bulk loads) must call this afterward.
pub fn rebuild_flat(&mut self) {
self.embeddings_flat.clear();
self.embeddings_flat
.reserve(self.embeddings.len() * self.embedding_dim);
for emb in &self.embeddings {
self.embeddings_flat.extend_from_slice(emb);
}
/// Kept for callers that used to have to re-flatten after a bulk load.
/// The buffer is always flat now, so there is nothing to rebuild.
#[deprecated(note = "embeddings are stored flat; this is a no-op")]
pub fn rebuild_flat(&mut self) {}
/// The embeddings as one contiguous `[N x dim]` buffer.
pub fn flat_embeddings(&self) -> &[f32] {
self.embeddings.as_flat()
}
/// Total number of entries (including tombstoned).
@@ -79,8 +204,7 @@ impl MemoryCache {
let idx = self.chunks.len();
let norm = vector_search::compute_norm(&embedding);
self.chunks.push(chunk);
self.embeddings_flat.extend_from_slice(&embedding);
self.embeddings.push(embedding);
self.embeddings.push(&embedding);
self.source_channels.push(source_channel);
self.timestamps.push(timestamp);
self.session_ids.push(session_id);
@@ -118,20 +242,7 @@ impl MemoryCache {
if idx < self.chunks.len() {
let norm = vector_search::compute_norm(&embedding);
self.chunks[idx] = chunk;
let dim = self.embedding_dim;
let flat_start = idx * dim;
let matches_dim =
embedding.len() == dim && flat_start + dim <= self.embeddings_flat.len();
self.embeddings[idx] = embedding;
if matches_dim {
self.embeddings_flat[flat_start..flat_start + dim]
.copy_from_slice(&self.embeddings[idx]);
} else {
// Embedding length doesn't match embedding_dim (shouldn't
// happen in practice) — fall back to a full rebuild rather
// than leave embeddings_flat misaligned with embeddings.
self.rebuild_flat();
}
self.embeddings.set(idx, &embedding);
self.source_channels[idx] = source_channel;
self.timestamps[idx] = timestamp;
self.session_ids[idx] = session_id;
@@ -183,7 +294,7 @@ impl MemoryCache {
new_idx += 1;
let norm = vector_search::compute_norm(&self.embeddings[i]);
new_chunks.push(self.chunks[i].clone());
new_embeddings.push(self.embeddings[i].clone());
new_embeddings.push(self.embeddings[i].to_vec());
new_source_channels.push(self.source_channels[i].clone());
new_timestamps.push(self.timestamps[i]);
new_session_ids.push(self.session_ids[i].clone());
@@ -196,7 +307,8 @@ impl MemoryCache {
let removed = old_len - new_chunks.len();
self.chunks = new_chunks;
self.embeddings = new_embeddings;
self.embeddings
.reset_from(self.embedding_dim, new_embeddings);
self.source_channels = new_source_channels;
self.timestamps = new_timestamps;
self.session_ids = new_session_ids;
@@ -204,16 +316,14 @@ impl MemoryCache {
self.tombstones = new_tombstones;
self.norms = new_norms;
self.activation_weights = new_activation_weights;
self.rebuild_flat();
(removed, index_map)
}
/// Flatten all embeddings into a single Vec<f32> for HDF5 storage.
/// `embeddings_flat` is already maintained incrementally, so this just
/// clones it — kept as a method for callers that want an owned copy.
pub fn flat_embeddings(&self) -> Vec<f32> {
self.embeddings_flat.clone()
/// All embeddings as one owned `[N x dim]` buffer, for HDF5 storage.
/// Prefer [`MemoryCache::flat_embeddings`] where a borrow will do.
pub fn flat_embeddings_owned(&self) -> Vec<f32> {
self.embeddings.as_flat().to_vec()
}
}
@@ -224,7 +334,7 @@ mod tests {
/// `embeddings_flat` must always equal a from-scratch flatten of `embeddings`.
fn assert_flat_in_sync(cache: &MemoryCache) {
let expected: Vec<f32> = cache.embeddings.iter().flatten().copied().collect();
assert_eq!(cache.embeddings_flat, expected);
assert_eq!(cache.embeddings.as_flat(), expected);
}
#[test]
@@ -247,7 +357,10 @@ mod tests {
String::new(),
);
assert_flat_in_sync(&cache);
assert_eq!(cache.embeddings_flat, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
assert_eq!(
cache.embeddings.as_flat(),
vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
);
}
#[test]
@@ -279,7 +392,7 @@ mod tests {
);
assert_flat_in_sync(&cache);
assert_eq!(
cache.embeddings_flat,
cache.embeddings.as_flat(),
vec![7.0, 8.0, 9.0, 4.0, 5.0, 6.0],
"update must overwrite the correct flat slice, not just append"
);
@@ -315,14 +428,15 @@ mod tests {
cache.mark_deleted(1);
cache.compact();
assert_flat_in_sync(&cache);
assert_eq!(cache.embeddings_flat, vec![1.0, 1.0, 3.0, 3.0]);
assert_eq!(cache.embeddings.as_flat(), vec![1.0, 1.0, 3.0, 3.0]);
}
#[test]
fn rebuild_flat_matches_manual_flatten() {
let mut cache = MemoryCache::new(2);
cache.embeddings = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
cache.rebuild_flat();
assert_eq!(cache.embeddings_flat, vec![1.0, 2.0, 3.0, 4.0]);
cache
.embeddings
.reset_from(2, vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
assert_eq!(cache.embeddings.as_flat(), vec![1.0, 2.0, 3.0, 4.0]);
}
}
+8 -8
View File
@@ -28,7 +28,7 @@ use crate::vector_search;
pub fn hybrid_search(
query_embedding: &[f32],
query_text: &str,
vectors: &[Vec<f32>],
vectors: &(impl crate::vector_search::VectorSet + Sync + ?Sized),
chunks: &[String],
tombstones: &[u8],
bm25_index: &BM25Index,
@@ -56,7 +56,7 @@ pub fn hybrid_search(
pub fn hybrid_search_fused(
query_embedding: &[f32],
query_text: &str,
vectors: &[Vec<f32>],
vectors: &(impl crate::vector_search::VectorSet + Sync + ?Sized),
_chunks: &[String],
tombstones: &[u8],
bm25_index: &BM25Index,
@@ -69,12 +69,12 @@ pub fn hybrid_search_fused(
let vec_scores = {
#[cfg(feature = "parallel")]
{
if vectors.len() > 10_000 {
if vectors.count() > 10_000 {
vector_search::parallel_cosine_batch(
query_embedding,
vectors,
tombstones,
vectors.len(),
vectors.count(),
)
} else {
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
@@ -270,7 +270,7 @@ fn normalize_scores(scores: &[(usize, f32)]) -> Vec<(usize, f32)> {
pub fn rrf_hybrid_search(
query_embedding: &[f32],
query_text: &str,
vectors: &[Vec<f32>],
vectors: &(impl crate::vector_search::VectorSet + Sync + ?Sized),
_chunks: &[String],
tombstones: &[u8],
bm25_index: &BM25Index,
@@ -282,12 +282,12 @@ pub fn rrf_hybrid_search(
let mut vec_scores = {
#[cfg(feature = "parallel")]
{
if vectors.len() > 10_000 {
if vectors.count() > 10_000 {
vector_search::parallel_cosine_batch(
query_embedding,
vectors,
tombstones,
vectors.len(),
vectors.count(),
)
} else {
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
@@ -298,7 +298,7 @@ pub fn rrf_hybrid_search(
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
}
};
let mut kw_scores = bm25_index.search(query_text, vectors.len());
let mut kw_scores = bm25_index.search(query_text, vectors.count());
// Sort both lists descending so rank 1 = best.
vec_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
+96 -17
View File
@@ -62,15 +62,9 @@ use std::path::{Path, PathBuf};
use cache::MemoryCache;
#[cfg(feature = "hnsw")]
use clawhdf5_ann::{DistanceMetric, HnswIndex};
use clawhdf5_ann::{DistanceMetric, HnswIndex, Storage};
use ephemeral::{EphemeralConfig, EphemeralStore};
/// HNSW construction parameters used for the agent's vector index. Cosine is the
/// agent's similarity metric, so the index is built with cosine distance.
#[cfg(feature = "hnsw")]
const HNSW_M: usize = 16;
#[cfg(feature = "hnsw")]
const HNSW_EF_CONSTRUCTION: usize = 64;
// EphemeralEntry and EphemeralStats are part of the crate public API via
// the `ephemeral` module; they are not needed directly in lib.rs internals.
#[allow(unused_imports)]
@@ -139,6 +133,34 @@ pub struct MemoryConfig {
pub created_at: String,
pub wal_enabled: bool,
pub wal_max_entries: usize,
/// Store the vector index's own copy of the embeddings as int8 rather than
/// f32, a quarter of the memory.
///
/// The index's copy is the single largest part of a loaded store's
/// footprint. Quantised distances are approximate, so the candidate pool
/// is re-scored against the cache's exact embeddings before fusion, which
/// restores recall; what it costs is throughput — roughly 13% of queries
/// per second and 16% of build time at 100K x 384. See `BENCHMARKS.md`.
///
/// Has no effect without the `hnsw` feature.
pub quantized_index: bool,
/// HNSW graph degree. Higher means a denser graph: better recall, more
/// memory and slower builds. Clamped to at least 2 when the index is
/// built, since a graph with fewer connections is not one.
///
/// Has no effect without the `hnsw` feature.
pub hnsw_m: usize,
/// Candidate list size while building the HNSW graph. Higher means a
/// better graph and a slower build; it does not affect query cost.
///
/// Has no effect without the `hnsw` feature.
pub hnsw_ef_construction: usize,
/// Candidate list size for a query, trading throughput for recall. `0`
/// keeps the default, which scales with the requested `k`
/// (`max(k * 8, 64)`) so that fusion still sees a useful pool.
///
/// Has no effect without the `hnsw` feature.
pub hnsw_ef_search: usize,
}
impl MemoryConfig {
@@ -160,6 +182,10 @@ impl MemoryConfig {
created_at,
wal_enabled: true,
wal_max_entries: 500,
quantized_index: false,
hnsw_m: 16,
hnsw_ef_construction: 64,
hnsw_ef_search: 0,
}
}
}
@@ -444,7 +470,17 @@ impl HDF5Memory {
#[cfg(feature = "hnsw")]
let loaded_index = if replay_only_appended {
Self::load_vector_index(path, checkpoint.ann_generation, &cache, n_checkpoint)
Self::load_vector_index(
path,
checkpoint.ann_generation,
&cache,
n_checkpoint,
if config.quantized_index {
Storage::Int8
} else {
Storage::Float32
},
)
} else {
None
};
@@ -558,6 +594,7 @@ impl HDF5Memory {
generation: Option<u64>,
cache: &MemoryCache,
n_checkpoint: usize,
storage: Storage,
) -> Option<HnswIndex> {
let generation = generation?;
let bytes = std::fs::read(Self::vector_index_path(store)).ok()?;
@@ -565,15 +602,17 @@ impl HDF5Memory {
if u64::from_le_bytes(stamp.try_into().ok()?) != generation {
return None;
}
let vectors = cache.embeddings.get(..n_checkpoint)?.to_vec();
let mut index = HnswIndex::from_graph_bytes(graph, vectors).ok()?;
let vectors: Vec<Vec<f32>> = (0..n_checkpoint)
.map(|i| cache.embeddings.get(i).map(<[f32]>::to_vec))
.collect::<Option<_>>()?;
let mut index = HnswIndex::from_graph_bytes_with(graph, vectors, storage).ok()?;
if index.dimension() != cache.embedding_dim {
return None;
}
// Records appended since (replayed from the WAL) join incrementally.
for id in n_checkpoint..cache.embeddings.len() {
if cache.embeddings[id].len() != index.dimension()
|| index.insert(cache.embeddings[id].clone()) != id
|| index.insert(cache.embeddings[id].to_vec()) != id
{
return None;
}
@@ -801,6 +840,42 @@ impl HDF5Memory {
// the index length drifts from the cache length (covering any mutation path
// that doesn't call a hook, e.g. consolidation pushes).
/// Graph degree for the index, never below the 2 the builder requires:
/// a config value of 0 or 1 would otherwise panic inside `clawhdf5-ann`.
#[cfg(feature = "hnsw")]
fn hnsw_m(&self) -> usize {
self.config.hnsw_m.max(2)
}
/// Build-time candidate list size, never below the graph degree — a
/// smaller one cannot fill a node's connections.
#[cfg(feature = "hnsw")]
fn hnsw_ef_construction(&self) -> usize {
self.config.hnsw_ef_construction.max(self.hnsw_m())
}
/// Query-time candidate list size for a `k`-result search. `0` means the
/// default, which scales with `k`.
#[cfg(feature = "hnsw")]
pub(crate) fn hnsw_ef_search(&self, k: usize) -> usize {
let default = (k * 8).max(64);
if self.config.hnsw_ef_search == 0 {
default
} else {
self.config.hnsw_ef_search.max(k)
}
}
/// How the index should store its copy of the vectors, per the config.
#[cfg(feature = "hnsw")]
fn index_storage(&self) -> Storage {
if self.config.quantized_index {
Storage::Int8
} else {
Storage::Float32
}
}
/// Build an HNSW index over the entire cache, re-applying tombstones as
/// soft-deletions so node ids stay aligned with cache indices.
///
@@ -816,11 +891,15 @@ impl HDF5Memory {
if self.cache.embeddings.iter().any(|e| e.len() != dim) {
return None;
}
let mut index = HnswIndex::build_with_metric(
&self.cache.embeddings,
HNSW_M,
HNSW_EF_CONSTRUCTION,
// The index owns its vectors, so it needs rows rather than the cache's
// flat buffer. This copy is the index's own; the cache keeps one.
let rows: Vec<Vec<f32>> = self.cache.embeddings.iter().map(<[f32]>::to_vec).collect();
let mut index = HnswIndex::build_with(
&rows,
self.hnsw_m(),
self.hnsw_ef_construction(),
DistanceMetric::Cosine,
self.index_storage(),
);
for (i, &t) in self.cache.tombstones.iter().enumerate() {
if t != 0 {
@@ -846,7 +925,7 @@ impl HDF5Memory {
let dim = index.dimension();
let appended = (self.hnsw_synced_len..n).all(|id| {
self.cache.embeddings[id].len() == dim
&& index.insert(self.cache.embeddings[id].clone()) == id
&& index.insert(self.cache.embeddings[id].to_vec()) == id
});
if appended {
for id in self.hnsw_synced_len..n {
@@ -877,7 +956,7 @@ impl HDF5Memory {
let emb_len = self.cache.embeddings[idx].len();
match self.hnsw.as_mut() {
Some(index) if emb_len == index.dimension() => {
let id = index.insert(self.cache.embeddings[idx].clone());
let id = index.insert(self.cache.embeddings[idx].to_vec());
if id == idx {
self.hnsw_synced_len = self.cache.embeddings.len();
} else {
+6 -3
View File
@@ -466,7 +466,7 @@ impl ClawhdfBackend {
let record = MemoryRecord {
id: i as u64,
chunk: cache.chunks[i].clone(),
embedding: cache.embeddings[i].clone(),
embedding: cache.embeddings[i].to_vec(),
tier: MemoryTier::Working,
importance: cache.activation_weights[i],
access_count: 0,
@@ -554,6 +554,7 @@ impl MemoryBackend for ClawhdfBackend {
timestamp: r.timestamp,
source_channel: r.source_channel.clone(),
raw_activation: r.activation,
relevance: r.score,
})
.collect();
@@ -716,11 +717,13 @@ impl MemoryBackend for ClawhdfBackend {
let total_records = cache.count_active();
// A record saved without an embedding occupies a zero row, so "has an
// embedding" is "has a non-zero norm" rather than "row is non-empty".
let total_embeddings = cache
.embeddings
.norms
.iter()
.enumerate()
.filter(|(i, emb)| cache.tombstones[*i] == 0 && !emb.is_empty())
.filter(|(i, norm)| cache.tombstones[*i] == 0 && **norm > 0.0)
.count();
let file_size_bytes = std::fs::metadata(&self.hdf5_path)
+51 -2
View File
@@ -4,8 +4,10 @@
//! into a single composite score for each retrieved result.
/// Configuration for the multi-factor re-ranker.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Copy)]
pub struct ReRankConfig {
/// Weight applied to the retrieval score the candidate arrived with.
pub relevance_weight: f32,
/// Weight applied to the temporal decay score (0.01.0).
pub temporal_weight: f32,
/// Weight applied to the source authority score (0.01.0).
@@ -20,6 +22,9 @@ pub struct ReRankConfig {
impl Default for ReRankConfig {
fn default() -> Self {
Self {
// Relevance leads: the metadata signals break ties and nudge, they
// do not decide. See `BENCHMARKS.md`, "Recency discrimination".
relevance_weight: 1.0,
temporal_weight: 0.3,
authority_weight: 0.2,
activation_weight: 0.5,
@@ -41,6 +46,8 @@ pub struct ReRankResult {
pub authority_score: f32,
/// Normalised Hebbian activation score in [0, 1].
pub activation_score: f32,
/// The retrieval score carried through from the input.
pub relevance_score: f32,
}
/// Compute an exponential decay temporal score.
@@ -105,6 +112,15 @@ pub struct RerankInput {
pub source_channel: String,
/// Raw Hebbian activation weight for this entry.
pub raw_activation: f32,
/// The retrieval score that put this entry in the candidate list.
///
/// Re-ranking is meant to *adjust* the retriever's ordering with signals
/// it does not have, not to replace it. Without this the combined score
/// was made of recency, authority and activation alone, so a candidate
/// pool came back ordered by age with its relevance ordering discarded.
/// Callers with no meaningful score can pass the same value for every
/// entry, which reduces to the old behaviour.
pub relevance: f32,
}
/// Re-rank a list of retrieval results using multi-factor scoring.
@@ -138,7 +154,8 @@ pub fn rerank(
let auth = source_authority_score(&inp.source_channel);
let act = activation_score(inp.raw_activation);
let combined = config.temporal_weight * ts
let combined = config.relevance_weight * inp.relevance
+ config.temporal_weight * ts
+ config.authority_weight * auth
+ config.activation_weight * act;
@@ -148,6 +165,7 @@ pub fn rerank(
temporal_score: ts,
authority_score: auth,
activation_score: act,
relevance_score: inp.relevance,
}
})
.collect();
@@ -253,22 +271,51 @@ mod tests {
timestamp: 0.0, // very old
source_channel: "other".to_string(),
raw_activation: 0.1,
relevance: 0.0,
},
RerankInput {
index: 1,
timestamp: 86_400.0, // one day ago
source_channel: "conversation".to_string(),
raw_activation: 0.5,
relevance: 0.0,
},
RerankInput {
index: 2,
timestamp: 172_800.0, // "now"
source_channel: "user_correction".to_string(),
raw_activation: 1.0,
relevance: 0.0,
},
]
}
#[test]
fn relevance_leads_but_recency_breaks_near_ties() {
let entry = |index, timestamp, relevance| RerankInput {
index,
timestamp,
source_channel: "conversation".to_string(),
raw_activation: 1.0,
relevance,
};
let now = 10.0 * 86_400.0;
let config = ReRankConfig::default();
// A clearly better match wins despite being much older. Before
// `relevance` existed the combined score ignored it entirely, so this
// returned the newer, irrelevant entry.
let ranked = rerank(&[entry(0, 0.0, 1.0), entry(1, now, 0.1)], &config, now);
assert_eq!(ranked[0].index, 0, "{ranked:?}");
// Between near-equal matches, the newer one wins.
let ranked = rerank(&[entry(0, 0.0, 0.80), entry(1, now, 0.79)], &config, now);
assert_eq!(ranked[0].index, 1, "{ranked:?}");
// The breakdown carries the relevance through.
assert_eq!(ranked[0].relevance_score, 0.79);
}
#[test]
fn rerank_returns_all_entries() {
let inputs = make_inputs();
@@ -302,6 +349,7 @@ mod tests {
#[test]
fn rerank_score_breakdown_matches_manual_calculation() {
let config = ReRankConfig {
relevance_weight: 0.0,
temporal_weight: 1.0,
authority_weight: 0.0,
activation_weight: 0.0,
@@ -312,6 +360,7 @@ mod tests {
timestamp: 0.0,
source_channel: "other".to_string(),
raw_activation: 0.5,
relevance: 0.0,
}];
let now = 3600.0_f64; // exactly one half-life later
let results = rerank(&inputs, &config, now);
+26 -9
View File
@@ -104,6 +104,19 @@ pub fn build_hdf5_file_with_meta(
"wal_max_entries",
AttrValue::I64(config.wal_max_entries as i64),
);
meta.set_attr(
"quantized_index",
AttrValue::I64(config.quantized_index.into()),
);
meta.set_attr("hnsw_m", AttrValue::I64(config.hnsw_m as i64));
meta.set_attr(
"hnsw_ef_construction",
AttrValue::I64(config.hnsw_ef_construction as i64),
);
meta.set_attr(
"hnsw_ef_search",
AttrValue::I64(config.hnsw_ef_search as i64),
);
meta.set_attr(
"edgehdf5_version",
AttrValue::String(ZEROCLAW_VERSION.into()),
@@ -153,7 +166,7 @@ fn build_memory_group(
{
let ds = group
.create_dataset("embeddings")
.with_f32_data(&flat)
.with_f32_data(flat)
.with_shape(&[n, d]);
// Chunk size tuning: target ~256KB per chunk for optimal I/O
@@ -484,6 +497,16 @@ pub fn validate_and_load(
wal_max_entries: optional_i64_attr(&attrs, "wal_max_entries")
.and_then(|v| usize::try_from(v).ok())
.unwrap_or(500),
quantized_index: optional_bool_attr(&attrs, "quantized_index", false),
hnsw_m: optional_i64_attr(&attrs, "hnsw_m")
.and_then(|v| usize::try_from(v).ok())
.unwrap_or(16),
hnsw_ef_construction: optional_i64_attr(&attrs, "hnsw_ef_construction")
.and_then(|v| usize::try_from(v).ok())
.unwrap_or(64),
hnsw_ef_search: optional_i64_attr(&attrs, "hnsw_ef_search")
.and_then(|v| usize::try_from(v).ok())
.unwrap_or(0),
};
// Load /memory group
@@ -563,12 +586,7 @@ fn load_memory_group(
.collect(),
};
// Unflatten embeddings
let embeddings: Vec<Vec<f32>> = flat_embeddings
.chunks(embedding_dim)
.map(|c| c.to_vec())
.collect();
// No unflattening: the cache stores the buffer as it is on disk.
// Read activation_weights if present, default to vec![1.0; N] for backward compat
let activation_weights = match read_f32_dataset(&group, "activation_weights") {
Ok(w) if w.len() == n => w,
@@ -576,7 +594,7 @@ fn load_memory_group(
};
cache.chunks = chunks;
cache.embeddings = embeddings;
cache.embeddings.set_flat(embedding_dim, flat_embeddings);
cache.source_channels = source_channels;
cache.timestamps = timestamps;
cache.session_ids = session_ids;
@@ -584,7 +602,6 @@ fn load_memory_group(
cache.tombstones = tombstones;
cache.norms = norms;
cache.activation_weights = activation_weights;
cache.rebuild_flat();
Ok(cache)
}
+23 -3
View File
@@ -28,11 +28,31 @@ impl HDF5Memory {
Some(index) if !index.is_empty() && index.dimension() == query_embedding.len() => {
// Over-fetch so the merge sees a useful vector pool; cosine
// distance from the index converts back to similarity (1 - d).
// `ef` is configurable, but the pool the fusion stage sees is
// not tied to it: a caller lowering `ef` for speed should not
// silently narrow what fusion has to work with.
let pool = (k * 8).max(64);
let vec_scores: Vec<(usize, f32)> = index
.search(query_embedding, pool, pool)
let ef = self.hnsw_ef_search(k).max(pool);
let candidates = index.search(query_embedding, pool, ef);
// A quantised index returns approximate distances, and no
// amount of `ef` fixes that — the loss is in the distances,
// not the graph. Re-score the pool against the cache's exact
// embeddings, which cost nothing extra to keep: recall then
// matches an f32 index. See `BENCHMARKS.md`.
let exact = index.storage() == clawhdf5_ann::Storage::Int8;
let vec_scores: Vec<(usize, f32)> = candidates
.into_iter()
.map(|(id, dist)| (id, 1.0 - dist))
.map(|(id, dist)| {
let score = if exact {
crate::vector_search::cosine_similarity(
query_embedding,
&self.cache.embeddings[id],
)
} else {
1.0 - dist
};
(id, score)
})
.collect();
// Fusion normalises over every keyword match, so it needs all
// the scores — but not ranked.
+6 -10
View File
@@ -113,13 +113,11 @@ 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
mmap.advise_willneed(0, mmap.len());
// Parse the HDF5 file from the mmap'd bytes
let file = clawhdf5::File::from_bytes(mmap.as_bytes().to_vec())
// `File::open` memory-maps the file itself (the facade's `mmap` feature is
// on by default). Mapping it here and handing over `as_bytes().to_vec()`
// did the same work and then copied the whole store — a second full copy
// of the file, live for the whole parse, on top of the mapping.
let file = clawhdf5::File::open(path)
.map_err(|e| MemoryError::Hdf5(format!("cannot open {}: {e}", path.display())))?;
let (mut config, cache, sessions, knowledge) = schema::validate_and_load(&file)?;
@@ -133,9 +131,7 @@ pub fn read_from_disk_with_mark(path: &Path) -> Result<(StoreState, Option<WalMa
pub fn read_from_disk_with_meta(
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())
let file = clawhdf5::File::open(path)
.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();
+75 -30
View File
@@ -4,6 +4,44 @@
//! `clawhdf5_accel`, with optional float16 support via the `half` crate.
//! Supports pre-computed norms for eliminating redundant norm computations.
/// A corpus of equal-length embeddings addressable by index.
///
/// Lets the batch kernels read either the cache's flat `[N x dim]` buffer or a
/// plain `Vec<Vec<f32>>` without either side owning a second copy.
pub trait VectorSet {
/// Number of embeddings.
fn count(&self) -> usize;
/// Embedding `i`; callers only index below [`VectorSet::count`].
fn row(&self, i: usize) -> &[f32];
}
impl VectorSet for [Vec<f32>] {
fn count(&self) -> usize {
self.len()
}
fn row(&self, i: usize) -> &[f32] {
&self[i]
}
}
impl VectorSet for Vec<Vec<f32>> {
fn count(&self) -> usize {
self.len()
}
fn row(&self, i: usize) -> &[f32] {
&self[i]
}
}
impl VectorSet for crate::cache::Embeddings {
fn count(&self) -> usize {
self.len()
}
fn row(&self, i: usize) -> &[f32] {
&self[i]
}
}
/// Compute cosine similarity between two f32 slices.
///
/// Returns 0.0 if either vector has zero magnitude.
@@ -22,7 +60,7 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
/// Returns `(index, score)` pairs sorted by score descending.
pub fn cosine_similarity_batch(
query: &[f32],
vectors: &[Vec<f32>],
vectors: &(impl VectorSet + ?Sized),
tombstones: &[u8],
) -> Vec<(usize, f32)> {
let query_norm = clawhdf5_accel::vector_norm(query);
@@ -30,7 +68,7 @@ pub fn cosine_similarity_batch(
return Vec::new();
}
let n = vectors.len();
let n = vectors.count();
let mut results: Vec<(usize, f32)> = Vec::with_capacity(n);
// Process 4 vectors at a time where possible
@@ -42,8 +80,9 @@ pub fn cosine_similarity_batch(
if i < tombstones.len() && tombstones[i] != 0 {
continue;
}
let vec_norm = clawhdf5_accel::vector_norm(&vectors[i]);
let score = crate::cosine_similarity_prenorm(query, query_norm, &vectors[i], vec_norm);
let vec_norm = clawhdf5_accel::vector_norm(vectors.row(i));
let score =
crate::cosine_similarity_prenorm(query, query_norm, vectors.row(i), vec_norm);
results.push((i, score));
}
}
@@ -53,8 +92,8 @@ pub fn cosine_similarity_batch(
if i < tombstones.len() && tombstones[i] != 0 {
continue;
}
let vec_norm = clawhdf5_accel::vector_norm(&vectors[i]);
let score = crate::cosine_similarity_prenorm(query, query_norm, &vectors[i], vec_norm);
let vec_norm = clawhdf5_accel::vector_norm(vectors.row(i));
let score = crate::cosine_similarity_prenorm(query, query_norm, vectors.row(i), vec_norm);
results.push((i, score));
}
@@ -68,7 +107,7 @@ pub fn cosine_similarity_batch(
/// collections. Uses `score = dot(query, vec) / (query_norm * stored_norm)`.
pub fn cosine_similarity_batch_prenorm(
query: &[f32],
vectors: &[Vec<f32>],
vectors: &(impl VectorSet + ?Sized),
norms: &[f32],
tombstones: &[u8],
) -> Vec<(usize, f32)> {
@@ -77,7 +116,7 @@ pub fn cosine_similarity_batch_prenorm(
return Vec::new();
}
let n = vectors.len();
let n = vectors.count();
let mut results: Vec<(usize, f32)> = Vec::with_capacity(n);
for i in 0..n {
@@ -85,7 +124,7 @@ pub fn cosine_similarity_batch_prenorm(
continue;
}
let vec_norm = norms[i];
let score = crate::cosine_similarity_prenorm(query, query_norm, &vectors[i], vec_norm);
let score = crate::cosine_similarity_prenorm(query, query_norm, vectors.row(i), vec_norm);
results.push((i, score));
}
@@ -162,7 +201,7 @@ pub fn cosine_similarity_f16(
#[cfg(feature = "parallel")]
pub fn parallel_cosine_batch(
query: &[f32],
vectors: &[Vec<f32>],
vectors: &(impl VectorSet + Sync + ?Sized),
tombstones: &[u8],
k: usize,
) -> Vec<(usize, f32)> {
@@ -174,24 +213,27 @@ pub fn parallel_cosine_batch(
}
let num_cores = rayon::current_num_threads().max(1);
let chunk_size = vectors.len().div_ceil(num_cores);
let chunk_size = vectors.count().div_ceil(num_cores);
if chunk_size == 0 {
return Vec::new();
}
let mut all_results: Vec<(usize, f32)> = vectors
.par_chunks(chunk_size)
.enumerate()
.flat_map(|(chunk_idx, chunk)| {
// Chunk over index ranges: the corpus may be one flat buffer rather than
// a slice of rows, so there is nothing to `par_chunks` over.
let n = vectors.count();
let mut all_results: Vec<(usize, f32)> = (0..n.div_ceil(chunk_size))
.into_par_iter()
.flat_map(|chunk_idx| {
let base = chunk_idx * chunk_size;
let mut local: Vec<(usize, f32)> = Vec::with_capacity(chunk.len());
for (j, vec) in chunk.iter().enumerate() {
let i = base + j;
let end = (base + chunk_size).min(n);
let mut local: Vec<(usize, f32)> = Vec::with_capacity(end - base);
for i in base..end {
if i < tombstones.len() && tombstones[i] != 0 {
continue;
}
let vec_norm = clawhdf5_accel::vector_norm(vec);
let score = crate::cosine_similarity_prenorm(query, query_norm, vec, vec_norm);
let vec_norm = clawhdf5_accel::vector_norm(vectors.row(i));
let score =
crate::cosine_similarity_prenorm(query, query_norm, vectors.row(i), vec_norm);
local.push((i, score));
}
local.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
@@ -209,7 +251,7 @@ pub fn parallel_cosine_batch(
#[cfg(feature = "parallel")]
pub fn parallel_cosine_batch_prenorm(
query: &[f32],
vectors: &[Vec<f32>],
vectors: &(impl VectorSet + Sync + ?Sized),
norms: &[f32],
tombstones: &[u8],
k: usize,
@@ -222,23 +264,26 @@ pub fn parallel_cosine_batch_prenorm(
}
let num_cores = rayon::current_num_threads().max(1);
let chunk_size = vectors.len().div_ceil(num_cores);
let chunk_size = vectors.count().div_ceil(num_cores);
if chunk_size == 0 {
return Vec::new();
}
let mut all_results: Vec<(usize, f32)> = vectors
.par_chunks(chunk_size)
.enumerate()
.flat_map(|(chunk_idx, chunk)| {
// Chunk over index ranges: the corpus may be one flat buffer rather than
// a slice of rows, so there is nothing to `par_chunks` over.
let n = vectors.count();
let mut all_results: Vec<(usize, f32)> = (0..n.div_ceil(chunk_size))
.into_par_iter()
.flat_map(|chunk_idx| {
let base = chunk_idx * chunk_size;
let mut local: Vec<(usize, f32)> = Vec::with_capacity(chunk.len());
for (j, vec) in chunk.iter().enumerate() {
let i = base + j;
let end = (base + chunk_size).min(n);
let mut local: Vec<(usize, f32)> = Vec::with_capacity(end - base);
for i in base..end {
if i < tombstones.len() && tombstones[i] != 0 {
continue;
}
let score = crate::cosine_similarity_prenorm(query, query_norm, vec, norms[i]);
let score =
crate::cosine_similarity_prenorm(query, query_norm, vectors.row(i), norms[i]);
local.push((i, score));
}
local.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
@@ -165,3 +165,122 @@ fn save_batch_then_search_is_consistent() {
);
}
}
#[test]
fn quantized_index_matches_the_f32_index_after_re_scoring() {
// A quantised index holds approximate vectors, but the store still has the
// exact ones, so the query path re-scores the candidate pool before
// fusion. The results a caller sees should therefore be the same.
let dim = 64;
let n = 400;
let mut seed = 0x5EED_1234_5678_9ABC;
let vectors: Vec<Vec<f32>> = (0..n).map(|_| make_vector(&mut seed, dim)).collect();
let queries: Vec<Vec<f32>> = (0..20).map(|_| make_vector(&mut seed, dim)).collect();
let build = |dir: &TempDir, quantized: bool| {
let mut config = MemoryConfig::new(dir.path().join("mem.h5"), "agent", dim);
config.quantized_index = quantized;
let mut mem = HDF5Memory::create(config).unwrap();
for (i, v) in vectors.iter().enumerate() {
mem.save(entry(&format!("chunk {i}"), v.clone(), &format!("k{i}")))
.unwrap();
}
mem
};
let exact_dir = TempDir::new().unwrap();
let quant_dir = TempDir::new().unwrap();
let mut exact = build(&exact_dir, false);
let mut quantized = build(&quant_dir, true);
let k = 10;
let mut agree = 0;
for q in &queries {
let want: Vec<usize> = exact
.hybrid_search(q, "", 1.0, 0.0, k)
.iter()
.map(|r| r.index)
.collect();
agree += quantized
.hybrid_search(q, "", 1.0, 0.0, k)
.iter()
.filter(|r| want.contains(&r.index))
.count();
}
let overlap = agree as f64 / (k * queries.len()) as f64;
assert!(
overlap >= 0.95,
"quantised store should match the f32 one: {overlap}"
);
}
#[test]
fn quantized_index_setting_survives_a_reopen() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("mem.h5");
let mut config = MemoryConfig::new(path.clone(), "agent", 8);
config.quantized_index = true;
let mut mem = HDF5Memory::create(config).unwrap();
let mut seed = 7;
for i in 0..30 {
mem.save(entry(&format!("c{i}"), make_vector(&mut seed, 8), "t"))
.unwrap();
}
mem.flush_wal().unwrap();
drop(mem);
// Reopening must not silently quadruple the index's memory, so the flag
// is part of the stored config rather than a per-session choice.
let reopened = HDF5Memory::open(&path).unwrap();
assert!(reopened.config().quantized_index);
}
#[test]
fn hnsw_parameters_are_configurable_and_persisted() {
// The graph degree and both candidate-list sizes used to be constants, so
// a deployment could not trade recall against memory or speed at all.
let dir = TempDir::new().unwrap();
let path = dir.path().join("mem.h5");
let mut config = MemoryConfig::new(path.clone(), "agent", 16);
config.hnsw_m = 8;
config.hnsw_ef_construction = 32;
config.hnsw_ef_search = 128;
let mut mem = HDF5Memory::create(config).unwrap();
let mut seed = 99;
let vectors: Vec<Vec<f32>> = (0..300).map(|_| make_vector(&mut seed, 16)).collect();
for (i, v) in vectors.iter().enumerate() {
mem.save(entry(&format!("c{i}"), v.clone(), "t")).unwrap();
}
// Still correct with a smaller graph: an exact match must rank first.
let top = mem.hybrid_search(&vectors[42], "", 1.0, 0.0, 1);
assert_eq!(top[0].index, 42);
mem.flush_wal().unwrap();
drop(mem);
let reopened = HDF5Memory::open(&path).unwrap();
assert_eq!(reopened.config().hnsw_m, 8);
assert_eq!(reopened.config().hnsw_ef_construction, 32);
assert_eq!(reopened.config().hnsw_ef_search, 128);
}
#[test]
fn degenerate_hnsw_parameters_do_not_panic() {
// `clawhdf5-ann` asserts m >= 2, so a zero from a config file — or from a
// caller who assumed 0 meant "default" — would abort the process inside
// the index builder. The store clamps instead.
let dir = TempDir::new().unwrap();
let mut config = MemoryConfig::new(dir.path().join("mem.h5"), "agent", 8);
config.hnsw_m = 0;
config.hnsw_ef_construction = 0;
config.hnsw_ef_search = 1;
let mut mem = HDF5Memory::create(config).unwrap();
let mut seed = 5;
let vectors: Vec<Vec<f32>> = (0..50).map(|_| make_vector(&mut seed, 8)).collect();
for (i, v) in vectors.iter().enumerate() {
mem.save(entry(&format!("c{i}"), v.clone(), "t")).unwrap();
}
let results = mem.hybrid_search(&vectors[7], "", 1.0, 0.0, 5);
assert_eq!(results[0].index, 7, "exact match should still rank first");
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-android"
version = "2.5.0"
version = "2.7.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.5.0"
version = "2.7.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.5.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.5.0" }
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.5.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.7.0" }
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.7.0" }
rayon = { version = "1", optional = true }
[features]
+412 -62
View File
@@ -154,6 +154,218 @@ impl Ord for FarCandidate {
}
}
/// How the index keeps its copy of the vectors.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Storage {
/// Exactly as given: `dim * 4` bytes per vector.
#[default]
Float32,
/// Each component scaled to an `i8`: `dim` bytes per vector, a quarter of
/// the space, at some cost in precision.
///
/// Only meaningful for [`DistanceMetric::Cosine`]: rows are stored
/// unit-length, so a quantised dot product reconstructs the similarity
/// directly. Requesting it for `L2` keeps `Float32`, because an L2
/// distance cannot be recovered from a dot product alone.
Int8,
}
/// The index's copy of the vectors, flat and row-major.
#[derive(Debug, Clone)]
enum Vectors {
F32 {
dim: usize,
flat: Vec<f32>,
},
/// `flat[i * dim + j]` is component `j` of vector `i` divided by
/// `scales[i]`; multiplying back recovers it.
///
/// The scale is per row rather than global. A unit-length row in `d`
/// dimensions has components around `1/sqrt(d)`, so a fixed `[-1, 1]`
/// scale spends fewer than 12 of the 255 levels on a 128-dimensional
/// vector and the reconstruction error swamps the gaps between near
/// neighbours — measured at 0.35 top-10 overlap with the exact ranking.
/// Scaling each row by its own largest component uses the full range.
Int8 {
dim: usize,
flat: Vec<i8>,
scales: Vec<f32>,
},
}
/// Levels either side of zero. 127, not 128, so the range is symmetric.
const INT8_LEVELS: f32 = 127.0;
/// Quantise one row, returning the codes and the scale that inverts them.
fn quantise_row(v: &[f32], out: &mut Vec<i8>) -> f32 {
let max_abs = v.iter().fold(0.0f32, |m, x| m.max(x.abs()));
if max_abs <= f32::MIN_POSITIVE {
out.extend(core::iter::repeat_n(0i8, v.len()));
return 0.0;
}
let inv = INT8_LEVELS / max_abs;
out.extend(
v.iter()
.map(|x| (x * inv).round().clamp(-INT8_LEVELS, INT8_LEVELS) as i8),
);
max_abs / INT8_LEVELS
}
impl Vectors {
fn new(dim: usize, storage: Storage, metric: DistanceMetric) -> Self {
match storage {
Storage::Int8 if metric == DistanceMetric::Cosine => Vectors::Int8 {
dim,
flat: Vec::new(),
scales: Vec::new(),
},
_ => Vectors::F32 {
dim,
flat: Vec::new(),
},
}
}
fn dim(&self) -> usize {
match self {
Vectors::F32 { dim, .. } | Vectors::Int8 { dim, .. } => *dim,
}
}
fn storage(&self) -> Storage {
match self {
Vectors::F32 { .. } => Storage::Float32,
Vectors::Int8 { .. } => Storage::Int8,
}
}
fn len(&self) -> usize {
let dim = self.dim();
if dim == 0 {
return 0;
}
match self {
Vectors::F32 { flat, .. } => flat.len() / dim,
Vectors::Int8 { flat, .. } => flat.len() / dim,
}
}
/// Set the row width, for a store seeded empty by `new`.
fn set_dim(&mut self, new_dim: usize) {
match self {
Vectors::F32 { dim, .. } | Vectors::Int8 { dim, .. } => *dim = new_dim,
}
}
fn push(&mut self, vector: &[f32]) {
match self {
Vectors::F32 { flat, .. } => flat.extend_from_slice(vector),
Vectors::Int8 { flat, scales, .. } => scales.push(quantise_row(vector, flat)),
}
}
/// Row `i` as `f32`, for callers that need the values back (serialization,
/// and the f32 fast paths). Quantised rows are reconstructed, so this is
/// lossy in exactly the way the storage is.
fn row(&self, i: usize) -> Vec<f32> {
let dim = self.dim();
let start = i * dim;
match self {
Vectors::F32 { flat, .. } => flat[start..start + dim].to_vec(),
Vectors::Int8 { flat, scales, .. } => flat[start..start + dim]
.iter()
.map(|&q| f32::from(q) * scales[i])
.collect(),
}
}
/// Distance between two stored vectors.
fn dist(&self, a: usize, b: usize, metric: DistanceMetric) -> f32 {
let dim = self.dim();
match self {
Vectors::F32 { flat, .. } => {
let (x, y) = (a * dim, b * dim);
compute_distance(&flat[x..x + dim], &flat[y..y + dim], metric)
}
Vectors::Int8 { flat, scales, .. } => {
let (x, y) = (a * dim, b * dim);
let dot = dot_i8(&flat[x..x + dim], &flat[y..y + dim]);
1.0 - dot as f32 * scales[a] * scales[b]
}
}
}
/// Distance from a prepared query to stored vector `i`.
fn dist_query(&self, query: &Query, i: usize, metric: DistanceMetric) -> f32 {
let dim = self.dim();
let start = i * dim;
match (self, query) {
(Vectors::F32 { flat, .. }, Query::F32(q)) => {
compute_distance(q, &flat[start..start + dim], metric)
}
(Vectors::Int8 { flat, scales, .. }, Query::Int8(q, q_scale)) => {
let dot = dot_i8(q, &flat[start..start + dim]);
1.0 - dot as f32 * q_scale * scales[i]
}
// Mixed forms cannot occur: `Query` is built from the same storage.
_ => f32::MAX,
}
}
/// Build a store from prepared rows.
fn from_rows(rows: &[Vec<f32>], storage: Storage, metric: DistanceMetric) -> Self {
let dim = rows.first().map_or(0, Vec::len);
let mut out = Vectors::new(dim, storage, metric);
for row in rows {
out.push(row);
}
out
}
/// Prepare `query` for comparison against this store.
fn query(&self, query: Vec<f32>) -> Query {
match self {
Vectors::F32 { .. } => Query::F32(query),
Vectors::Int8 { .. } => {
let mut codes = Vec::with_capacity(query.len());
let scale = quantise_row(&query, &mut codes);
Query::Int8(codes, scale)
}
}
}
}
/// What a layer search is measuring distance *to*: an incoming query, or a
/// node already in the index (which is what insertion compares against).
enum Target<'a> {
Query(&'a Query),
Node(usize),
}
impl Vectors {
fn dist_to(&self, target: &Target<'_>, i: usize, metric: DistanceMetric) -> f32 {
match target {
Target::Query(q) => self.dist_query(q, i, metric),
Target::Node(n) => self.dist(*n, i, metric),
}
}
}
/// A search query in whichever form the store compares against.
enum Query {
F32(Vec<f32>),
/// Codes and the scale that inverts them, as in [`Vectors::Int8`].
Int8(Vec<i8>, f32),
}
/// Sum of products, widened so it cannot overflow. Runtime-dispatched to the
/// same SIMD backend as the f32 kernels, so the two storages are compared on
/// equal terms.
#[inline]
fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
clawhdf5_accel::dot_i8(a, b)
}
/// Magic for [`HnswIndex::graph_to_bytes`].
const GRAPH_MAGIC: &[u8; 4] = b"CHG1";
@@ -174,8 +386,8 @@ pub const HNSW_FORMAT_VERSION: i64 = 2;
/// HDF5 format.
#[derive(Debug, Clone)]
pub struct HnswIndex {
/// All vectors in the index.
vectors: Vec<Vec<f32>>,
/// All vectors in the index, flat and row-major.
vectors: Vectors,
/// Adjacency lists per layer. `graph[layer][node]` = list of neighbor IDs.
graph: Vec<Vec<Vec<usize>>>,
/// Soft-deletion flags, one per node. Deleted nodes remain in the graph for
@@ -214,6 +426,20 @@ impl HnswIndex {
m: usize,
ef_construction: usize,
metric: DistanceMetric,
) -> Self {
Self::build_with(vectors, m, ef_construction, metric, Storage::default())
}
/// Build an index, choosing how the vectors are stored.
///
/// [`Storage::Int8`] keeps them at a quarter of the size; see its docs for
/// what that costs and when it applies.
pub fn build_with(
vectors: &[Vec<f32>],
m: usize,
ef_construction: usize,
metric: DistanceMetric,
storage: Storage,
) -> Self {
assert!(!vectors.is_empty(), "cannot build index from empty vectors");
assert!(m >= 2, "m must be at least 2");
@@ -224,8 +450,11 @@ 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;
let mut prepared = Vectors::new(dim, storage, metric);
for v in vectors {
prepared.push(&prepare(v.clone(), metric));
}
let vectors = &prepared;
// Assign levels to all nodes
let mut node_levels = Vec::with_capacity(n);
@@ -322,9 +551,20 @@ impl HnswIndex {
/// point for incremental [`HnswIndex::insert`] and as the result of
/// [`HnswIndex::compact`] when every vector has been deleted.
pub fn new(m: usize, ef_construction: usize, metric: DistanceMetric) -> Self {
Self::new_with(m, ef_construction, metric, Storage::default())
}
/// [`HnswIndex::new`], choosing how the vectors are stored.
pub fn new_with(
m: usize,
ef_construction: usize,
metric: DistanceMetric,
storage: Storage,
) -> Self {
assert!(m >= 2, "m must be at least 2");
Self {
vectors: Vec::new(),
// The dimension is set by the first insert.
vectors: Vectors::new(0, storage, metric),
graph: Vec::new(),
deleted: Vec::new(),
entry_point: 0,
@@ -351,7 +591,8 @@ impl HnswIndex {
// Seed an empty index.
if id == 0 {
let node_level = assign_level(0, self.m);
self.vectors.push(vector);
self.vectors.set_dim(vector.len());
self.vectors.push(&vector);
self.deleted.push(false);
self.node_levels.push(node_level);
self.graph = (0..=node_level).map(|_| vec![Vec::new(); 1]).collect();
@@ -361,12 +602,12 @@ impl HnswIndex {
assert_eq!(
vector.len(),
self.vectors[0].len(),
self.vectors.dim(),
"insert dimension mismatch"
);
let node_level = assign_level(id, self.m);
self.vectors.push(vector);
self.vectors.push(&vector);
self.deleted.push(false);
self.node_levels.push(node_level);
@@ -387,7 +628,7 @@ impl HnswIndex {
ep = greedy_closest(
&self.vectors,
&self.graph[layer],
&self.vectors[id],
&Target::Node(id),
ep,
self.metric,
);
@@ -400,7 +641,7 @@ impl HnswIndex {
let neighbors = search_layer(
&self.vectors,
&self.graph[layer],
&self.vectors[id],
&Target::Node(id),
ep,
self.ef_construction,
self.metric,
@@ -466,16 +707,25 @@ impl HnswIndex {
pub fn compact(&mut self) -> Vec<Option<usize>> {
let mut mapping = vec![None; self.vectors.len()];
let mut surviving: Vec<Vec<f32>> = Vec::with_capacity(self.active_len());
for (old, v) in self.vectors.iter().enumerate() {
for (old, slot) in mapping.iter_mut().enumerate() {
if !self.deleted[old] {
mapping[old] = Some(surviving.len());
surviving.push(v.clone());
*slot = Some(surviving.len());
surviving.push(self.vectors.row(old));
}
}
// Rebuilding must keep the storage the caller chose; a compaction is
// not the place to silently quadruple the index's memory.
let storage = self.vectors.storage();
*self = if surviving.is_empty() {
Self::new(self.m, self.ef_construction, self.metric)
Self::new_with(self.m, self.ef_construction, self.metric, storage)
} else {
Self::build_with_metric(&surviving, self.m, self.ef_construction, self.metric)
Self::build_with(
&surviving,
self.m,
self.ef_construction,
self.metric,
storage,
)
};
mapping
}
@@ -490,24 +740,22 @@ impl HnswIndex {
/// # Returns
/// A vector of `(id, distance)` pairs sorted by distance (closest first).
pub fn search(&self, query: &[f32], k: usize, ef: usize) -> Vec<(usize, f32)> {
if self.vectors.is_empty() {
if self.vectors.len() == 0 {
return Vec::new();
}
assert_eq!(
query.len(),
self.vectors[0].len(),
"query dimension mismatch"
);
assert_eq!(query.len(), self.vectors.dim(), "query dimension mismatch");
let ef = ef.max(k);
let prepared_query = prepare(query.to_vec(), self.metric);
let query = prepared_query.as_slice();
// Prepared and, for a quantised store, quantised once per search
// rather than once per comparison.
let prepared = self.vectors.query(prepare(query.to_vec(), self.metric));
let target = Target::Query(&prepared);
let mut ep = self.entry_point;
let top_layer = self.graph.len().saturating_sub(1);
// Greedy search from top layer down to layer 1
for layer in (1..=top_layer).rev() {
ep = greedy_closest(&self.vectors, &self.graph[layer], query, ep, self.metric);
ep = greedy_closest(&self.vectors, &self.graph[layer], &target, ep, self.metric);
}
// Search layer 0 for the ef nearest *live* nodes. Deleted nodes are
@@ -516,7 +764,7 @@ impl HnswIndex {
let candidates = search_layer(
&self.vectors,
&self.graph[0],
query,
&target,
ep,
ef,
self.metric,
@@ -543,14 +791,13 @@ impl HnswIndex {
pub fn to_hdf5_bytes(&self) -> Result<Vec<u8>, FormatError> {
let mut fw = FmtWriter::new();
let n = self.vectors.len();
let dim = if n > 0 { self.vectors[0].len() } else { 0 };
let dim = self.vectors.dim();
// Flatten vectors into a 1D array for storage
let flat_vectors: Vec<f32> = self
.vectors
.iter()
.flat_map(|v| v.iter().copied())
.collect();
let mut flat_vectors: Vec<f32> = Vec::with_capacity(n * dim);
for i in 0..n {
flat_vectors.extend_from_slice(&self.vectors.row(i));
}
let mut group = fw.create_group("ann");
@@ -709,7 +956,9 @@ impl HnswIndex {
};
Ok(Self {
vectors,
// Serialized files carry f32 vectors and no storage tag: a
// quantised index is rebuilt, not loaded.
vectors: Vectors::from_rows(&vectors, Storage::Float32, metric),
graph,
deleted,
entry_point,
@@ -773,6 +1022,16 @@ impl HnswIndex {
/// `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> {
Self::from_graph_bytes_with(bytes, vectors, Storage::default())
}
/// As [`from_graph_bytes`](Self::from_graph_bytes), choosing how the
/// rehydrated vectors are stored.
pub fn from_graph_bytes_with(
bytes: &[u8],
vectors: Vec<Vec<f32>>,
storage: Storage,
) -> Result<Self, FormatError> {
let bad = |what: &str| FormatError::SerializationError(format!("HNSW graph: {what}"));
let body_len = bytes
.len()
@@ -863,7 +1122,14 @@ impl HnswIndex {
}
Ok(Self {
vectors: vectors.into_iter().map(|v| prepare(v, metric)).collect(),
vectors: Vectors::from_rows(
&vectors
.into_iter()
.map(|v| prepare(v, metric))
.collect::<Vec<_>>(),
storage,
metric,
),
graph,
deleted,
entry_point,
@@ -882,16 +1148,17 @@ impl HnswIndex {
/// Returns true if the index is empty.
pub fn is_empty(&self) -> bool {
self.vectors.is_empty()
self.vectors.len() == 0
}
/// How this index stores its copy of the vectors.
pub fn storage(&self) -> Storage {
self.vectors.storage()
}
/// Returns the dimension of vectors in the index.
pub fn dimension(&self) -> usize {
if self.vectors.is_empty() {
0
} else {
self.vectors[0].len()
}
self.vectors.dim()
}
/// Returns the number of layers in the graph.
@@ -916,17 +1183,17 @@ impl HnswIndex {
/// Greedy search: find the single closest node to `query` starting from `ep`.
fn greedy_closest(
vectors: &[Vec<f32>],
vectors: &Vectors,
layer: &[Vec<usize>],
query: &[f32],
target: &Target<'_>,
mut ep: usize,
metric: DistanceMetric,
) -> usize {
let mut best_dist = compute_distance(query, &vectors[ep], metric);
let mut best_dist = vectors.dist_to(target, ep, metric);
loop {
let mut changed = false;
for &neighbor in &layer[ep] {
let d = compute_distance(query, &vectors[neighbor], metric);
let d = vectors.dist_to(target, neighbor, metric);
if d < best_dist {
best_dist = d;
ep = neighbor;
@@ -950,15 +1217,15 @@ fn greedy_closest(
/// instead meant a query whose neighbourhood had been deleted got back fewer
/// than `k` results, or none, however many live records were nearby.
fn search_layer(
vectors: &[Vec<f32>],
vectors: &Vectors,
layer: &[Vec<usize>],
query: &[f32],
target: &Target<'_>,
ep: usize,
ef: usize,
metric: DistanceMetric,
skip: Option<&[bool]>,
) -> Vec<Candidate> {
let ep_dist = compute_distance(query, &vectors[ep], metric);
let ep_dist = vectors.dist_to(target, ep, metric);
// Min-heap of candidates to explore
let mut candidates = BinaryHeap::new();
@@ -980,7 +1247,7 @@ fn search_layer(
visited.begin(vectors.len());
visited.insert(ep);
search_layer_visit(
vectors, layer, query, ef, metric, skip, visited, candidates, results,
vectors, layer, target, ef, metric, skip, visited, candidates, results,
)
})
}
@@ -1023,9 +1290,9 @@ thread_local! {
#[allow(clippy::too_many_arguments)]
fn search_layer_visit(
vectors: &[Vec<f32>],
vectors: &Vectors,
layer: &[Vec<usize>],
query: &[f32],
target: &Target<'_>,
ef: usize,
metric: DistanceMetric,
skip: Option<&[bool]>,
@@ -1044,7 +1311,7 @@ fn search_layer_visit(
continue;
}
let d = compute_distance(query, &vectors[neighbor], metric);
let d = vectors.dist_to(target, neighbor, metric);
let furthest_dist = results.peek().map_or(f32::MAX, |f| f.distance);
if d < furthest_dist || results.len() < ef {
@@ -1095,7 +1362,7 @@ fn search_layer_visit(
/// remaining slots are then filled with the closest rejected candidates, so a
/// node is never left under-connected.
fn select_neighbors(
vectors: &[Vec<f32>],
vectors: &Vectors,
candidates: &[(usize, f32)],
max_conn: usize,
metric: DistanceMetric,
@@ -1111,7 +1378,7 @@ fn select_neighbors(
}
let diverse = selected
.iter()
.all(|&s| compute_distance(&vectors[id], &vectors[s], metric) > dist_to_node);
.all(|&s| vectors.dist(id, s, metric) > dist_to_node);
if diverse {
selected.push(id);
} else {
@@ -1136,7 +1403,7 @@ fn batch_len(linked: usize) -> usize {
/// layers, found by searching the graph as it currently stands.
#[allow(clippy::too_many_arguments)]
fn plan_batch(
vectors: &[Vec<f32>],
vectors: &Vectors,
graph: &[Vec<Vec<usize>>],
node_levels: &[usize],
batch: std::ops::Range<usize>,
@@ -1150,7 +1417,7 @@ fn plan_batch(
let mut ep = entry_point;
// Phase 1: greedy descent from the top layer down to node_level + 1.
for layer in (node_level + 1..=ep_level).rev() {
ep = greedy_closest(vectors, &graph[layer], &vectors[i], ep, metric);
ep = greedy_closest(vectors, &graph[layer], &Target::Node(i), ep, metric);
}
// Phase 2: search and select on every layer the node lives on.
let mut plan = Vec::with_capacity(node_level.min(ep_level) + 1);
@@ -1159,7 +1426,7 @@ fn plan_batch(
let neighbors = search_layer(
vectors,
&graph[layer],
&vectors[i],
&Target::Node(i),
ep,
ef_construction,
metric,
@@ -1186,7 +1453,7 @@ fn plan_batch(
/// Prune every `(layer, node)` neighbour list in `overflowed` back to its
/// limit. Each list belongs to a different node, so they are independent.
fn prune_overflowed(
vectors: &[Vec<f32>],
vectors: &Vectors,
graph: &mut [Vec<Vec<usize>>],
overflowed: Vec<(usize, usize)>,
(m, m_max0): (usize, usize),
@@ -1226,7 +1493,7 @@ const PARALLEL_MIN: usize = 8;
/// prunes is too fine-grained to parallelise profitably — measured 1.45x on 16
/// cores; bulk builds batch their pruning instead, see `prune_overflowed`.)
fn link_back(
vectors: &[Vec<f32>],
vectors: &Vectors,
layer: &mut [Vec<usize>],
new_id: usize,
selected: &[usize],
@@ -1248,7 +1515,7 @@ fn link_back(
/// Trim `node`'s neighbour list back to `max_conn` with [`select_neighbors`].
fn prune_connections(
vectors: &[Vec<f32>],
vectors: &Vectors,
neighbors: &mut Vec<usize>,
node: usize,
max_conn: usize,
@@ -1259,7 +1526,7 @@ fn prune_connections(
}
let mut scored: Vec<(usize, f32)> = neighbors
.iter()
.map(|&n| (n, compute_distance(&vectors[node], &vectors[n], metric)))
.map(|&n| (n, vectors.dist(node, n, metric)))
.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);
@@ -1519,6 +1786,88 @@ mod tests {
assert!(recall >= 0.95, "incremental recall@10 = {recall}");
}
#[test]
fn int8_storage_needs_an_exact_re_score_to_match_f32() {
// Cosine only: rows are unit-length, so a quantised dot product
// reconstructs the similarity directly.
//
// Not the `clustered` generator: its clusters are far tighter than any
// real embedding, so neighbours sit closer together than the
// quantisation error and top-10 identity there is noise — that would
// measure the fixture, not the storage.
let mut vectors = make_random_vectors(3060, 128, 5);
let queries = vectors.split_off(3000);
let f32_index =
HnswIndex::build_with(&vectors, 8, 40, DistanceMetric::Cosine, Storage::Float32);
let quantised =
HnswIndex::build_with(&vectors, 8, 40, DistanceMetric::Cosine, Storage::Int8);
assert_eq!(quantised.storage(), Storage::Int8);
// Ground truth, not the f32 index's answers: re-scoring can beat that
// index, and measuring against it would score being right as drift.
let truth: Vec<Vec<usize>> = queries
.iter()
.map(|q| {
let mut d: Vec<(usize, f32)> = vectors
.iter()
.enumerate()
.map(|(i, v)| (i, compute_distance(q, v, DistanceMetric::Cosine)))
.collect();
d.sort_by(|a, b| a.1.total_cmp(&b.1));
d[..10].iter().map(|x| x.0).collect()
})
.collect();
let recall = |got: &dyn Fn(&[f32]) -> Vec<usize>| -> f64 {
let mut hits = 0;
for (q, want) in queries.iter().zip(&truth) {
hits += got(q).iter().filter(|id| want.contains(id)).count();
}
hits as f64 / (10 * queries.len()) as f64
};
let exact_recall = recall(&|q| f32_index.search(q, 10, 64).iter().map(|r| r.0).collect());
let raw_recall = recall(&|q| quantised.search(q, 10, 64).iter().map(|r| r.0).collect());
// Quantised distances alone cost recall, and `ef` cannot buy it back:
// the loss is in the distances, not in the graph.
assert!(
raw_recall < exact_recall,
"int8 alone should cost recall: {raw_recall} vs {exact_recall}"
);
// Re-scoring a wider candidate pool against the exact vectors — what a
// caller holding them (the agent's embedding cache) does — puts it
// back, because only the *ordering* was approximate.
let rescored_recall = recall(&|q| {
let mut pool: Vec<(usize, f32)> = quantised
.search(q, 40, 64)
.into_iter()
.map(|(id, _)| {
(
id,
compute_distance(q, &vectors[id], DistanceMetric::Cosine),
)
})
.collect();
pool.sort_by(|a, b| a.1.total_cmp(&b.1));
pool.truncate(10);
pool.into_iter().map(|p| p.0).collect()
});
assert!(
rescored_recall >= exact_recall - 0.01,
"int8 + exact re-score should match f32: {rescored_recall} vs {exact_recall} (raw {raw_recall})"
);
}
#[test]
fn int8_storage_falls_back_to_f32_for_non_cosine_metrics() {
// L2 distance is not recoverable from a quantised dot product, so the
// store silently stays f32 rather than returning wrong distances.
let vectors = clustered(100, 8, 5, 3);
let index = HnswIndex::build_with(&vectors, 8, 40, DistanceMetric::L2, Storage::Int8);
assert_eq!(index.storage(), Storage::Float32);
}
#[test]
fn deletions_near_the_query_do_not_shrink_or_degrade_results() {
let mut vectors = clustered(2040, 16, 20, 11);
@@ -1650,6 +1999,7 @@ mod tests {
vec![1.2, 0.0], // 3
vec![-2.0, 0.0], // 4
];
let store = Vectors::from_rows(&vectors, Storage::Float32, DistanceMetric::L2);
let scored: Vec<(usize, f32)> = (1..5)
.map(|i| {
(
@@ -1659,12 +2009,12 @@ mod tests {
})
.collect();
assert_eq!(
select_neighbors(&vectors, &scored, 2, DistanceMetric::L2),
select_neighbors(&store, &scored, 2, DistanceMetric::L2),
[1, 4]
);
// Spare capacity is filled with the closest rejected candidates.
assert_eq!(
select_neighbors(&vectors, &scored, 3, DistanceMetric::L2),
select_neighbors(&store, &scored, 3, DistanceMetric::L2),
[1, 4, 2]
);
}
@@ -1790,7 +2140,7 @@ mod tests {
// Verify vectors match
for i in 0..loaded.len() {
assert_eq!(loaded.vectors[i], index.vectors[i]);
assert_eq!(loaded.vectors.row(i), index.vectors.row(i));
}
}
+1 -1
View File
@@ -5,4 +5,4 @@
mod hnsw;
pub use hnsw::{DistanceMetric, HnswIndex};
pub use hnsw::{DistanceMetric, HnswIndex, Storage};
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-bench"
version = "2.5.0"
version = "2.7.0"
edition = "2024"
description = "Benchmark harnesses for clawhdf5-agent (Track 8)"
license = "MIT"
@@ -57,7 +57,8 @@ mod embedder;
use clawhdf5_agent::bm25::TokenFilter;
use clawhdf5_agent::hybrid::Fusion;
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
use clawhdf5_agent::reranker::{ReRankConfig, RerankInput, rerank};
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry, SearchResult};
use serde::Deserialize;
use tempfile::TempDir;
@@ -69,9 +70,17 @@ fn describe(mode: Mode) -> String {
Fusion::Weighted { vector, keyword } => format!("vector_{vector:.1}_keyword_{keyword:.1}"),
Fusion::Rrf { k } => format!("rrf_k{k:.0}"),
};
match mode.tokens {
let tokens = match mode.tokens {
TokenFilter::Plain => fusion,
TokenFilter::Stemmed => format!("{fusion}_stemmed"),
};
match mode.rerank {
None => tokens,
Some(cfg) if cfg.relevance_weight == 0.0 => format!("{tokens}_rerank_metadata"),
Some(cfg) => format!(
"{tokens}_rerank_blended_hl{:.0}d",
cfg.temporal_half_life_secs / 86_400.0
),
}
}
@@ -83,6 +92,9 @@ struct Mode {
fusion: Fusion,
/// How keyword tokens are normalised before indexing and querying.
tokens: TokenFilter,
/// Re-rank the retrieved candidates with recency and friends, relative to
/// the question's own date.
rerank: Option<ReRankConfig>,
}
impl Mode {
@@ -91,9 +103,17 @@ impl Mode {
label,
fusion: Fusion::Weighted { vector, keyword },
tokens: TokenFilter::Plain,
rerank: None,
}
}
#[cfg_attr(not(feature = "embeddings"), allow(dead_code))]
fn reranked(mut self, label: &'static str, rerank: ReRankConfig) -> Self {
self.label = label;
self.rerank = Some(rerank);
self
}
const fn stemmed(mut self, label: &'static str) -> Self {
self.label = label;
self.tokens = TokenFilter::Stemmed;
@@ -121,11 +141,61 @@ const RRF: Mode = Mode {
label: "Hybrid (reciprocal rank fusion, k=60)",
fusion: Fusion::Rrf { k: 60.0 },
tokens: TokenFilter::Plain,
rerank: None,
};
/// The same two configurations with stemmed keyword tokens, so the tokenizer's
/// effect is isolated from everything else.
const BM25_STEMMED: Mode = BM25_ONLY.stemmed("BM25 only, stemmed tokens");
/// Re-ranking as it behaved before `relevance` was an input: the combined
/// score was recency + authority + activation only, so the retriever's own
/// ordering was discarded.
#[cfg(feature = "embeddings")]
fn hybrid_rerank_metadata_only() -> Mode {
HYBRID.reranked(
"Hybrid + rerank (metadata only, pre-fix)",
ReRankConfig {
relevance_weight: 0.0,
..ReRankConfig::default()
},
)
}
/// Re-ranking as it behaves now: relevance leads, recency nudges.
#[cfg(feature = "embeddings")]
fn hybrid_rerank_blended() -> Mode {
HYBRID.reranked(
"Hybrid + rerank (relevance + recency)",
ReRankConfig::default(),
)
}
/// The same blend at several half-lives. Decay is `2^(-age / half_life)`, so a
/// half-life far shorter than the gaps between memories sends every score to
/// zero and the signal vanishes; far longer and everything scores ~1 and it
/// vanishes the other way. The right value tracks how far apart the memories
/// actually are.
#[cfg(feature = "embeddings")]
fn hybrid_rerank_half_lives() -> Vec<Mode> {
[
("1 day", 86_400.0),
("7 days", 7.0 * 86_400.0),
("30 days", 30.0 * 86_400.0),
("90 days", 90.0 * 86_400.0),
]
.into_iter()
.map(|(label, half_life)| {
HYBRID.reranked(
Box::leak(format!("Hybrid + rerank, half-life {label}").into_boxed_str()),
ReRankConfig {
temporal_half_life_secs: half_life,
..ReRankConfig::default()
},
)
})
.collect()
}
#[cfg(feature = "embeddings")]
const HYBRID_STEMMED: Mode = HYBRID.stemmed("Hybrid 0.4/0.6, stemmed tokens");
@@ -217,6 +287,37 @@ struct Question {
haystack_session_ids: Vec<String>,
haystack_sessions: Vec<Vec<Turn>>,
answer_session_ids: Vec<String>,
/// One timestamp per haystack session, e.g. "2023/05/25 (Thu) 20:21".
#[serde(default)]
haystack_dates: Vec<String>,
}
/// Seconds since the epoch for a LongMemEval session date, which looks like
/// `2023/05/25 (Thu) 20:21`. Sessions are stored in chronological order, so a
/// date that cannot be parsed falls back to its position — order is preserved
/// even if the interval is not.
fn session_time(date: &str, position: usize) -> f64 {
let stamp = |y: i64, mo: i64, d: i64, h: i64, mi: i64| -> f64 {
// Days since 1970-01-01 via the civil-from-days algorithm.
let (y, mo) = if mo <= 2 { (y - 1, mo + 12) } else { (y, mo) };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * (mo - 3) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
(days * 86_400 + h * 3_600 + mi * 60) as f64
};
let parse = || -> Option<f64> {
let (ymd, rest) = date.split_once(' ')?;
let mut ymd = ymd.split('/');
let y = ymd.next()?.parse().ok()?;
let mo = ymd.next()?.parse().ok()?;
let d = ymd.next()?.parse().ok()?;
let hm = rest.rsplit(' ').next()?;
let (h, mi) = hm.split_once(':')?;
Some(stamp(y, mo, d, h.parse().ok()?, mi.parse().ok()?))
};
parse().unwrap_or(1_000_000.0 + position as f64 * 86_400.0)
}
// ---------------------------------------------------------------------------
@@ -235,11 +336,21 @@ struct Metrics {
rr_turn: f64,
abstention_correct: u32,
abstention_total: u32,
/// Questions where the newest gold session outranked the older ones, out
/// of those with more than one gold session and at least one retrieved.
newest_gold_first: u32,
newest_gold_total: u32,
latency_ns: Vec<u64>,
count: u32,
}
impl Metrics {
/// `None` when no question in this bucket had multiple gold sessions.
fn newest_gold_first_pct(&self) -> Option<f64> {
(self.newest_gold_total > 0)
.then(|| self.newest_gold_first as f64 / self.newest_gold_total as f64 * 100.0)
}
fn hit1_session_pct(&self) -> f64 {
self.hit1_session as f64 / self.count.max(1) as f64 * 100.0
}
@@ -297,6 +408,16 @@ struct EvalResult {
hit5_turn: bool,
hit10_turn: bool,
rr_turn: Option<f64>,
/// For a question whose evidence spans several dated sessions (a
/// `knowledge-update`, where an earlier fact is superseded by a later
/// one): did the *newest* gold session outrank every older gold session
/// that was returned? `None` when the question has one gold session, or
/// when none were retrieved, so there is nothing to discriminate.
///
/// Plain recall cannot see this. LongMemEval labels *both* the stale and
/// the updated session as gold, so returning either counts as a hit — yet
/// only one of them answers the question correctly.
newest_gold_first: Option<bool>,
latency: Duration,
}
@@ -317,15 +438,21 @@ fn evaluate_question(
// Build MemoryEntry list from all haystack sessions
let mut entries: Vec<MemoryEntry> = Vec::new();
let mut turn_has_answer: Vec<bool> = Vec::new();
let mut ts = 1_000_000.0f64;
for (sess_idx, session) in q.haystack_sessions.iter().enumerate() {
let sess_id = q
.haystack_session_ids
.get(sess_idx)
.map(String::as_str)
.unwrap_or("unknown");
for turn in session {
// Real session dates, not a synthetic counter: anything that decays
// with age needs true intervals, not just the right order.
let session_start = q
.haystack_dates
.get(sess_idx)
.map_or(sess_idx as f64 * 86_400.0, |d| session_time(d, sess_idx));
for (turn_idx, turn) in session.iter().enumerate() {
// Spread a session's turns over the minutes following its start.
let ts = session_start + turn_idx as f64 * 60.0;
entries.push(MemoryEntry {
chunk: turn.content.clone(),
embedding: embedding_for(embeddings, &turn.content),
@@ -339,7 +466,6 @@ fn evaluate_question(
},
});
turn_has_answer.push(turn.has_answer);
ts += 1.0;
}
}
@@ -356,11 +482,87 @@ fn evaluate_question(
// Set of session IDs that contain the answer
let answer_sess_set: HashSet<&str> = q.answer_session_ids.iter().map(String::as_str).collect();
// When each gold session was recorded, so "newest" is by date rather than
// by position (the two agree in this dataset, but the metric should not
// depend on that).
let gold_times: HashMap<&str, f64> = q
.haystack_session_ids
.iter()
.enumerate()
.filter(|(_, sid)| answer_sess_set.contains(sid.as_str()))
.map(|(i, sid)| {
let t = q
.haystack_dates
.get(i)
.map_or(i as f64 * 86_400.0, |d| session_time(d, i));
(sid.as_str(), t)
})
.collect();
let query_emb = embedding_for(embeddings, &q.question);
let t0 = Instant::now();
let results = memory.hybrid_search_with(&query_emb, &q.question, mode.fusion, top_k);
// Re-ranking only reorders; it needs a candidate pool larger than `top_k`
// to have anything to promote.
let pool = if mode.rerank.is_some() {
top_k * 4
} else {
top_k
};
let mut results = memory.hybrid_search_with(&query_emb, &q.question, mode.fusion, pool);
if let Some(config) = mode.rerank {
// "Now" is the moment the question was asked, so decay measures how
// stale each memory was at that point.
let now = session_time(&q.question_date, q.haystack_sessions.len());
let inputs: Vec<RerankInput> = results
.iter()
.map(|r| RerankInput {
index: r.index,
timestamp: r.timestamp,
source_channel: r.source_channel.clone(),
raw_activation: r.activation,
relevance: r.score,
})
.collect();
let order: Vec<usize> = rerank(&inputs, &config, now)
.into_iter()
.map(|r| r.index)
.collect();
let by_index: HashMap<usize, SearchResult> =
results.into_iter().map(|r| (r.index, r)).collect();
results = order
.into_iter()
.filter_map(|i| by_index.get(&i).cloned())
.collect();
}
results.truncate(top_k);
let latency = t0.elapsed();
// Rank of the best-placed result from each gold session.
let mut first_rank: HashMap<&str, usize> = HashMap::new();
for (rank, result) in results.iter().enumerate() {
let sid = memory.cache.session_ids[result.index].as_str();
if let Some((gold_sid, _)) = gold_times.get_key_value(sid) {
first_rank.entry(gold_sid).or_insert(rank);
}
}
let newest_gold_first = if gold_times.len() < 2 || first_rank.is_empty() {
None
} else {
// The newest gold session must be retrieved, and no older gold session
// may outrank it.
let newest = gold_times
.iter()
.max_by(|a, b| a.1.total_cmp(b.1))
.map(|(sid, _)| *sid)
.expect("at least two gold sessions");
Some(match first_rank.get(newest) {
Some(&newest_rank) => first_rank
.iter()
.all(|(sid, &rank)| *sid == newest || rank > newest_rank),
None => false,
})
};
// Session-level recall
let mut hit1_session = false;
let mut hit5_session = false;
@@ -415,6 +617,7 @@ fn evaluate_question(
hit5_turn,
hit10_turn,
rr_turn,
newest_gold_first,
latency,
}
}
@@ -566,6 +769,24 @@ fn print_report(
);
println!();
if let Some(pct) = overall.newest_gold_first_pct() {
println!(
"## Recency Discrimination (n={})",
overall.newest_gold_total
);
println!(
" Newest gold session ranked first: {}/{} ({pct:.1}%)",
overall.newest_gold_first, overall.newest_gold_total
);
println!(
" Questions whose evidence spans several dated sessions — a fact and\n \
its later correction. Both sessions are labelled gold, so recall\n \
scores either as a hit; this asks whether the *current* one came\n \
first. A retriever with no sense of time scores near chance."
);
println!();
}
if overall.abstention_total > 0 {
println!("## Abstention Accuracy");
println!(
@@ -679,6 +900,14 @@ fn print_report(
} else {
println!(" \"abstention_accuracy\": null,");
}
match overall.newest_gold_first_pct() {
Some(pct) => println!(
" \"newest_gold_first\": {:.4}, \"newest_gold_n\": {},",
pct / 100.0,
overall.newest_gold_total
),
None => println!(" \"newest_gold_first\": null,"),
}
println!(" \"latency_us\": {{");
println!(
" \"avg\": {:.1}, \"p50\": {:.1}, \"p95\": {:.1}, \"p99\": {:.1}",
@@ -701,6 +930,8 @@ fn main() {
let mut limit: Option<usize> = None;
let mut weights_dir: Option<String> = None;
let mut sweep = false;
#[cfg_attr(not(feature = "embeddings"), allow(unused_mut, unused_variables))]
let mut rerank_sweep = false;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
@@ -709,6 +940,16 @@ fn main() {
limit = Some(v.parse().expect("--limit must be a positive integer"));
}
"--sweep" => sweep = true,
"--rerank-sweep" => {
// Re-ranking needs the vector stage to have candidates worth
// reordering, so this is an embeddings-only comparison.
#[cfg(feature = "embeddings")]
{
rerank_sweep = true;
}
#[cfg(not(feature = "embeddings"))]
eprintln!("warning: --rerank-sweep needs --features embeddings; ignoring");
}
"--embeddings" => {
weights_dir = Some(args.next().expect("--embeddings needs a directory"));
}
@@ -727,6 +968,9 @@ fn main() {
BM25-only, vector-only, and hybrid separately. Requires\n\
--features embeddings; without it the vector stage is\n\
inert and only the BM25 row is produced.\n\
--rerank-sweep\n\
compare re-ranking off, metadata-only (the old\n\
behaviour) and blended at several half-lives.\n\
--sweep instead of the three named modes, sweep vector_weight\n\
from 0.0 to 1.0 in 0.1 steps. The 0.7/0.3 default was\n\
never searched; this is what searches it."
@@ -792,6 +1036,10 @@ fn main() {
{
if sweep {
sweep_modes()
} else if rerank_sweep {
let mut modes = vec![HYBRID, hybrid_rerank_metadata_only()];
modes.extend(hybrid_rerank_half_lives());
modes
} else {
vec![
BM25_ONLY,
@@ -800,6 +1048,8 @@ fn main() {
RRF,
BM25_STEMMED,
HYBRID_STEMMED,
hybrid_rerank_metadata_only(),
hybrid_rerank_blended(),
]
}
}
@@ -916,6 +1166,14 @@ fn run_mode(
entry.rr_turn += rr;
overall.rr_turn += rr;
}
if let Some(newest_first) = result.newest_gold_first {
entry.newest_gold_total += 1;
overall.newest_gold_total += 1;
if newest_first {
entry.newest_gold_first += 1;
overall.newest_gold_first += 1;
}
}
let ns = result.latency.as_nanos() as u64;
entry.latency_ns.push(ns);
@@ -927,3 +1185,30 @@ fn run_mode(
eprintln!();
print_report(&overall, &by_type, profile, mode);
}
#[cfg(test)]
mod tests {
use super::session_time;
#[test]
fn session_dates_parse_to_the_right_instant() {
// Reference values from Python's datetime, UTC.
for (date, expected) in [
("2023/05/25 (Thu) 20:21", 1_685_046_060.0),
("1970/01/01 (Thu) 00:00", 0.0),
("2000/02/29 (Tue) 12:00", 951_825_600.0),
("2023/12/31 (Sun) 23:59", 1_704_067_140.0),
("2024/03/01 (Fri) 00:00", 1_709_251_200.0),
] {
assert_eq!(session_time(date, 0), expected, "{date}");
}
}
#[test]
fn unparseable_dates_fall_back_to_position_order() {
let a = session_time("not a date", 0);
let b = session_time("", 1);
let c = session_time("2023/13/99 (???) 99:99", 2);
assert!(a < b && b < c, "fallback must preserve session order");
}
}
+203 -5
View File
@@ -24,7 +24,7 @@
use std::time::{Duration, Instant};
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
use clawhdf5_ann::{DistanceMetric, HnswIndex};
use clawhdf5_ann::{DistanceMetric, HnswIndex, Storage};
const DIM: usize = 384;
const K: usize = 10;
@@ -84,6 +84,22 @@ struct Dataset {
/// that appears only on clustered data points at graph connectivity.
static UNIFORM: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// `--int8`: build the HNSW index over int8-quantised vectors (a quarter of
/// the memory) instead of f32, to price the recall it costs.
static INT8: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// `--rerank`: re-score the candidate pool against the exact vectors before
/// taking the top K.
static RERANK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn storage() -> Storage {
if INT8.load(std::sync::atomic::Ordering::Relaxed) {
Storage::Int8
} else {
Storage::Float32
}
}
fn make_dataset(n: usize, seed: u64) -> Dataset {
let mut rng = Rng(seed);
if UNIFORM.load(std::sync::atomic::Ordering::Relaxed) {
@@ -169,6 +185,11 @@ fn text_for(cluster: usize, i: usize, rng: &mut Rng) -> String {
// Measurement helpers
// ---------------------------------------------------------------------------
/// Exact cosine distance between unit-length vectors.
fn exact_dist(a: &[f32], b: &[f32]) -> f32 {
1.0 - a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>()
}
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
@@ -198,6 +219,83 @@ fn summarize(mut samples: Vec<Duration>) -> Latency {
}
}
/// Counts live heap bytes, so a structure's cost can be measured by
/// difference.
///
/// RSS cannot do this from inside one process: freeing a large structure
/// returns its pages to the allocator's pool rather than to the OS, so
/// allocating the next one shows no change. Measured that way, a store that
/// holds the corpus twice and one that holds it once look identical.
struct CountingAllocator;
static LIVE_BYTES: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
/// High-water mark of [`LIVE_BYTES`] since it was last reset.
///
/// Live bytes at a checkpoint cannot see a buffer that was allocated and
/// freed in between, and that is exactly the shape of a transient copy —
/// which still has to fit in memory while it exists.
static PEAK_BYTES: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
fn note_peak(live: i64) {
PEAK_BYTES.fetch_max(live, std::sync::atomic::Ordering::Relaxed);
}
// SAFETY: every method forwards to the system allocator with the same layout
// it was given, and only adds bookkeeping around it.
unsafe impl std::alloc::GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
let ptr = unsafe { std::alloc::System.alloc(layout) };
if !ptr.is_null() {
let live = LIVE_BYTES
.fetch_add(layout.size() as i64, std::sync::atomic::Ordering::Relaxed)
+ layout.size() as i64;
note_peak(live);
}
ptr
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: std::alloc::Layout) {
LIVE_BYTES.fetch_sub(layout.size() as i64, std::sync::atomic::Ordering::Relaxed);
unsafe { std::alloc::System.dealloc(ptr, layout) }
}
unsafe fn realloc(&self, ptr: *mut u8, layout: std::alloc::Layout, new_size: usize) -> *mut u8 {
let new_ptr = unsafe { std::alloc::System.realloc(ptr, layout, new_size) };
if !new_ptr.is_null() {
let delta = new_size as i64 - layout.size() as i64;
let live = LIVE_BYTES.fetch_add(delta, std::sync::atomic::Ordering::Relaxed) + delta;
note_peak(live);
}
new_ptr
}
}
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;
/// Live heap bytes right now.
fn heap_bytes() -> u64 {
LIVE_BYTES.load(std::sync::atomic::Ordering::Relaxed).max(0) as u64
}
/// Start watching for a new high-water mark from the current live total.
fn reset_peak() {
PEAK_BYTES.store(
LIVE_BYTES.load(std::sync::atomic::Ordering::Relaxed),
std::sync::atomic::Ordering::Relaxed,
);
}
/// The highest live total seen since [`reset_peak`].
fn peak_bytes() -> u64 {
PEAK_BYTES.load(std::sync::atomic::Ordering::Relaxed).max(0) as u64
}
fn mib(bytes: u64) -> f64 {
bytes as f64 / (1 << 20) as f64
}
fn micros(d: Duration) -> f64 {
d.as_secs_f64() * 1e6
}
@@ -219,11 +317,12 @@ fn bench_ann(n: usize, json: &mut Vec<serde_json::Value>) {
.collect();
let started = Instant::now();
let index = HnswIndex::build_with_metric(
let index = HnswIndex::build_with(
&data.vectors,
HNSW_M,
HNSW_EF_CONSTRUCTION,
DistanceMetric::Cosine,
storage(),
);
let build = started.elapsed();
@@ -240,7 +339,8 @@ fn bench_ann(n: usize, json: &mut Vec<serde_json::Value>) {
);
println!(
"\n### HNSW, N = {n}, dim = {DIM}, M = {HNSW_M}, ef_construction = {HNSW_EF_CONSTRUCTION}\n"
"\n### HNSW, N = {n}, dim = {DIM}, M = {HNSW_M}, ef_construction = {HNSW_EF_CONSTRUCTION}, storage = {:?}\n",
index.storage()
);
println!(
"build: {:.1} ms ({:.0} vectors/s) · exact scan: {:.0} QPS, p50 {:.0} µs\n",
@@ -251,12 +351,26 @@ fn bench_ann(n: usize, json: &mut Vec<serde_json::Value>) {
);
println!("| ef | recall@{K} | QPS | p50 µs | p99 µs |");
println!("|---:|---:|---:|---:|---:|");
// With a quantised index the distances it returns are approximate, so
// the candidates are re-scored against the exact vectors the caller
// already holds (in the agent, the embedding cache) before taking the
// top K. `--rerank` prices that: it costs one exact distance per
// candidate and is what decides whether int8 is usable.
let rerank = RERANK.load(std::sync::atomic::Ordering::Relaxed);
let pool = if rerank { K * 4 } else { K };
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);
let mut got = index.search(q, pool, ef.max(pool));
if rerank {
for cand in &mut got {
cand.1 = exact_dist(&data.vectors[cand.0], q);
}
got.select_nth_unstable_by(K - 1, |a, b| a.1.total_cmp(&b.1));
got.truncate(K);
}
samples.push(t.elapsed());
hits += got.iter().filter(|(id, _)| want.contains(id)).count();
}
@@ -394,11 +508,12 @@ fn fusion_study(n: usize) {
.map(|(i, c)| text_for(*c, i, &mut rng))
.collect();
let bm25 = BM25Index::build(&texts, &vec![0u8; n]);
let index = HnswIndex::build_with_metric(
let index = HnswIndex::build_with(
&data.vectors,
HNSW_M,
HNSW_EF_CONSTRUCTION,
DistanceMetric::Cosine,
storage(),
);
let vec_pool = (K * 8).max(64);
@@ -450,6 +565,69 @@ fn fusion_study(n: usize) {
}
}
/// What an in-memory store costs, stage by stage. The vectors are the floor:
/// everything above it is bookkeeping that could in principle be shared.
fn bench_footprint(n: usize) {
let data = make_dataset(n, 0xF007 ^ n as u64);
let mut rng = Rng(11);
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("footprint.h5");
let base = heap_bytes();
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 after_entries = heap_bytes();
let mut config = MemoryConfig::new(path, "bench", DIM);
config.quantized_index = INT8.load(std::sync::atomic::Ordering::Relaxed);
let mut mem = HDF5Memory::create(config).unwrap();
mem.save_batch(entries).unwrap();
let after_store = heap_bytes();
// First query builds the vector and keyword indexes.
std::hint::black_box(mem.hybrid_search(&data.queries[0], "record", 0.7, 0.3, K));
let after_indexes = heap_bytes();
// Reopening is the figure that matters for a long-lived process, and the
// only one RSS reports honestly: memory freed when the ingest buffers went
// away stays in the allocator's pool, so the stage deltas above understate
// what was given back.
let path = mem.config().path.clone();
drop(mem);
let before_open = heap_bytes();
reset_peak();
let reopened = HDF5Memory::open(&path).unwrap();
let after_open = heap_bytes();
let loaded = after_open.saturating_sub(before_open);
// Peak over the open, not just what it leaves behind: a buffer allocated
// and freed during the parse never shows up in the live total.
let peak = peak_bytes().saturating_sub(before_open);
drop(reopened);
let raw = (n * DIM * 4) as u64;
println!(
"| {n} | {:.0} | {:.0} | {:.0} | {:.0} | {:.0} | {:.0} | {:.2}x |",
mib(raw),
mib(after_entries.saturating_sub(base)),
mib(after_store.saturating_sub(after_entries)),
mib(after_indexes.saturating_sub(after_store)),
mib(loaded),
mib(peak),
loaded as f64 / raw as f64,
);
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let full = args.iter().any(|a| a == "--full");
@@ -464,6 +642,14 @@ fn main() {
}
return;
}
if args.iter().any(|a| a == "--int8") {
INT8.store(true, std::sync::atomic::Ordering::Relaxed);
println!("(int8-quantised index vectors)");
}
if args.iter().any(|a| a == "--rerank") {
RERANK.store(true, std::sync::atomic::Ordering::Relaxed);
println!("(candidates re-scored against exact vectors)");
}
if args.iter().any(|a| a == "--uniform") {
UNIFORM.store(true, std::sync::atomic::Ordering::Relaxed);
println!("(uniform random data)");
@@ -485,6 +671,18 @@ fn main() {
let mut json = Vec::new();
println!("## Search harness");
if args.iter().any(|a| a == "--footprint") {
println!("\n### Resident memory, {DIM}-dim f32\n");
println!(
"| N | vectors (raw) | entries MiB | store MiB | indexes MiB | reopened MiB | peak during open MiB | reopened / raw |"
);
println!("|---:|---:|---:|---:|---:|---:|---:|---:|");
for &n in sizes {
bench_footprint(n);
}
return;
}
// `--e2e-only` skips the index benchmarks, so the end-to-end section runs
// in a process that has not already spun up a thread pool.
if !args.iter().any(|a| a == "--e2e-only") {
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-cli"
version = "2.5.0"
version = "2.7.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.5.0" }
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.7.0" }
clap = { version = "4", features = ["derive", "env"] }
serde_json = "1"
serde = { workspace = true }
+12 -1
View File
@@ -28,6 +28,10 @@ enum Commands {
/// Enable write-ahead log
#[arg(long)]
wal: bool,
/// Store the vector index's copy of the embeddings as int8, roughly
/// halving a loaded store's memory at about 13% fewer queries/second
#[arg(long)]
quantized_index: bool,
},
/// Save a memory entry (reads JSON from stdin or --json)
Save {
@@ -88,9 +92,15 @@ fn main() {
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
match cli.command {
Commands::Create { agent_id, dim, wal } => {
Commands::Create {
agent_id,
dim,
wal,
quantized_index,
} => {
let mut config = MemoryConfig::new(cli.path.clone(), &agent_id, dim);
config.wal_enabled = wal;
config.quantized_index = quantized_index;
let mem = HDF5Memory::create(config)?;
let j = serde_json::json!({
"status": "created",
@@ -98,6 +108,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
"agent_id": agent_id,
"embedding_dim": dim,
"wal_enabled": wal,
"quantized_index": quantized_index,
"count": mem.count(),
});
println!("{}", serde_json::to_string_pretty(&j)?);
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-derive"
version = "2.5.0"
version = "2.7.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.5.0"
version = "2.7.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.5.0"
version = "2.7.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.5.0" }
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.7.0" }
[[bench]]
name = "bench"
+3
View File
@@ -1 +1,4 @@
target/
corpus/
artifacts/
coverage/
@@ -1,15 +1,36 @@
#![no_main]
use clawhdf5_format::btree_v2::{BTreeV2Header, collect_btree_v2_records};
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
for &offset_size in &[4u8, 8] {
for &length_size in &[4u8, 8] {
let _ = clawhdf5_format::btree_v2::BTreeV2Header::parse(
data,
0,
offset_size,
length_size,
);
if let Ok(header) = BTreeV2Header::parse(data, 0, offset_size, length_size) {
let _ = collect_btree_v2_records(data, &header, offset_size, length_size);
}
}
}
// Parsing a header requires a valid checksum, which random input almost
// never has, so the traversal behind it went unfuzzed — and that is where
// a node listing itself as its own child overflowed the stack. Take the
// header fields straight from the input instead and walk the rest.
let Some((fields, file)) = data.split_first_chunk::<20>() else {
return;
};
let header = BTreeV2Header {
tree_type: fields[0],
node_size: u32::from_le_bytes([fields[1], fields[2], fields[3], fields[4]]),
record_size: u16::from_le_bytes([fields[5], fields[6]]),
depth: u16::from_le_bytes([fields[7], fields[8]]),
root_node_address: u64::from(u32::from_le_bytes([
fields[9], fields[10], fields[11], fields[12],
])),
num_records_in_root: u16::from_le_bytes([fields[13], fields[14]]),
total_records: u64::from(u32::from_le_bytes([
fields[15], fields[16], fields[17], fields[18],
])),
};
let offset_size = if fields[19] & 1 == 0 { 4 } else { 8 };
let _ = collect_btree_v2_records(file, &header, offset_size, 8);
});
+151
View File
@@ -172,6 +172,17 @@ fn max_records_leaf(node_size: u32, record_size: u16) -> u64 {
((node_size - overhead) / record_size as u32) as u64
}
/// Deepest B-tree v2 accepted. See [`collect_btree_v2_records`].
const MAX_DEPTH: u16 = 64;
/// Take `n` records from the traversal's budget, or refuse the tree.
fn spend(budget: &mut usize, n: usize) -> Result<(), FormatError> {
*budget = budget
.checked_sub(n)
.ok_or(FormatError::NestingDepthExceeded)?;
Ok(())
}
/// Collect all records from a B-tree v2 by traversing from the root.
pub fn collect_btree_v2_records(
file_data: &[u8],
@@ -182,6 +193,22 @@ pub fn collect_btree_v2_records(
if header.total_records == 0 || header.num_records_in_root == 0 {
return Ok(Vec::new());
}
// Recursion is one frame per level, and the depth is read from the file:
// a crafted header claiming 65 535 levels over a node that is its own
// child overflowed the stack. 64 matches the fractal heap's guard, and no
// real tree comes close — even at the minimum fan-out of two it would
// hold more than 2^64 records.
if header.depth > MAX_DEPTH {
return Err(FormatError::NestingDepthExceeded);
}
// A valid tree stores each record once, in its own bytes, so it cannot
// hold more records than the file has room for. Children are addresses,
// though, and nothing makes them distinct: levels whose children all
// point at one shared node below reach it fan-out^depth times, which is
// millions of records from a few kilobytes. Counting against what the
// file could physically contain bounds that without trusting the
// header's own `total_records`.
let mut budget = file_data.len() / usize::from(header.record_size.max(1));
let max_leaf_nrec = max_records_leaf(header.node_size, header.record_size);
@@ -206,6 +233,7 @@ pub fn collect_btree_v2_records(
offset_size,
length_size,
max_leaf_nrec,
&mut budget,
&mut records,
)?;
Ok(records)
@@ -273,6 +301,7 @@ fn collect_internal_records(
offset_size: u8,
length_size: u8,
max_leaf_nrec: u64,
budget: &mut usize,
out: &mut Vec<BTreeV2Record>,
) -> Result<(), FormatError> {
// signature(4) + version(1) + type(1) = 6
@@ -350,6 +379,8 @@ fn collect_internal_records(
// We collect child[0] records, then record[0], then child[1], etc.
for (i, &(child_addr, child_nrec)) in children.iter().enumerate() {
if child_depth == 0 {
// Before parsing, so a refused tree is not also a large allocation.
spend(budget, usize::from(child_nrec))?;
let leaf_recs =
parse_leaf_records(file_data, child_addr as usize, child_nrec, record_size)?;
out.extend(leaf_recs);
@@ -364,6 +395,7 @@ fn collect_internal_records(
offset_size,
length_size,
max_leaf_nrec,
budget,
out,
)?;
}
@@ -393,6 +425,7 @@ fn collect_internal_records(
available: file_data.len(),
});
}
spend(budget, 1)?;
out.push(BTreeV2Record {
data: file_data[rec_start..rec_end].to_vec(),
});
@@ -466,6 +499,124 @@ mod tests {
buf
}
/// An internal node laid out exactly as `collect_internal_records` will
/// read it at `depth`: `records` zeroed records, then `children` pointers,
/// all to `child_addr` claiming `child_nrec` records.
fn internal_node(
depth: u16,
node_size: u32,
record_size: u16,
records: usize,
children: usize,
child_addr: u64,
child_nrec: u64,
) -> Vec<u8> {
let max_leaf = max_records_leaf(node_size, record_size);
let nrec_width = bytes_for_max_records(if depth == 1 { max_leaf } else { max_leaf * 2 });
let total_width = if depth > 1 {
bytes_for_max_records(header_max_total_records(max_leaf, depth - 1))
} else {
0
};
let mut buf = b"BTIN".to_vec();
buf.extend_from_slice(&[0, 5]);
buf.resize(buf.len() + records * record_size as usize, 0);
for _ in 0..children {
buf.extend_from_slice(&child_addr.to_le_bytes());
buf.extend_from_slice(&child_nrec.to_le_bytes()[..nrec_width]);
buf.resize(buf.len() + total_width, 0);
}
buf
}
fn header(depth: u16, root: u64, root_nrec: u16, total: u64) -> BTreeV2Header {
BTreeV2Header {
tree_type: 5,
node_size: 512,
record_size: 8,
depth,
root_node_address: root,
num_records_in_root: root_nrec,
total_records: total,
}
}
#[test]
fn a_node_that_is_its_own_child_is_rejected_not_recursed() {
// One internal node whose two children are itself, under a header
// claiming the deepest tree a u16 allows. The layout stops depending
// on depth once the subtree-total width saturates, so every level
// parses cleanly and recursion runs ~65 000 frames deep: before the
// cap this overflowed the stack and aborted the process, from a file
// of under 100 bytes.
let mut data = internal_node(u16::MAX, 512, 8, 1, 2, 0, 1);
data.resize(4096, 0);
let result = collect_btree_v2_records(&data, &header(u16::MAX, 0, 1, 1), 8, 8);
assert!(result.is_err(), "{result:?}");
}
#[test]
fn a_shared_subtree_cannot_multiply_the_work() {
// A chain of distinct levels, each node's children all pointing at the
// single node below, ending in a real leaf. Every node parses and
// nothing is cyclic, yet the leaf is reached fan-out^depth times: 62
// children over 4 levels is ~15 million leaf visits from a few
// kilobytes. A valid tree cannot hold more records than the file has
// room for, so that bounds the traversal instead.
let (node_size, record_size) = (512u32, 8u16);
let fanout = 62usize;
let depth = 4u16;
let leaf = build_leaf_node(5, &[&[0u8; 8][..]]);
// Lay out root first, then each lower level, then the leaf.
let mut nodes: Vec<Vec<u8>> = Vec::new();
let mut addrs = Vec::new();
let mut at = 0u64;
let mut sizes = Vec::new();
for d in (1..=depth).rev() {
let n = internal_node(d, node_size, record_size, fanout - 1, fanout, 0, 0);
sizes.push(n.len());
}
for size in &sizes {
addrs.push(at);
at += *size as u64;
}
let leaf_addr = at;
for (i, d) in (1..=depth).rev().enumerate() {
let (child, child_nrec) = if d == 1 {
(leaf_addr, 1)
} else {
(addrs[i + 1], fanout as u64 - 1)
};
nodes.push(internal_node(
d,
node_size,
record_size,
fanout - 1,
fanout,
child,
child_nrec,
));
}
let mut data: Vec<u8> = nodes.concat();
data.extend_from_slice(&leaf);
data.resize(data.len() + 64, 0);
let started = std::time::Instant::now();
let result =
collect_btree_v2_records(&data, &header(depth, 0, fanout as u16 - 1, u64::MAX), 8, 8);
assert!(
result.is_err(),
"expected a refusal, got {} records",
result.map_or(0, |r| r.len())
);
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"took {:?}",
started.elapsed()
);
}
#[test]
fn parse_header() {
let data = build_btree_v2_header(5, 512, 11, 0, 0x1000, 3, 3, 8, 8);
+15 -4
View File
@@ -15,7 +15,6 @@ use crate::filter_pipeline::{
FilterDescription, FilterPipeline,
};
use crate::filters::compress_chunk;
/// Round a file offset up to the next cache-line boundary.
///
/// This ensures chunk data starts at an address that is a multiple of the
@@ -928,6 +927,7 @@ pub fn write_selection_to_buffer(
#[cfg(test)]
mod tests {
use super::*;
use crate::chunked_read::read_chunked_data;
use crate::data_layout::DataLayout;
@@ -1512,9 +1512,20 @@ mod tests {
// ---- h5py round-trip tests for chunked writes ----
/// The Python interpreter to drive interop checks with.
///
/// `CLAWHDF5_PYTHON` lets these run against a virtualenv holding h5py,
/// which on a PEP 668 "externally managed" system is the only place it
/// can be installed. Without it the suite silently skips, and a silent
/// skip here is how a datatype bug once reached a release.
#[cfg(feature = "std")]
fn python() -> String {
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
}
#[cfg(feature = "std")]
fn h5py_available() -> bool {
std::process::Command::new("python3")
std::process::Command::new(python())
.args(["-c", "import h5py"])
.output()
.map(|o| o.status.success())
@@ -1526,10 +1537,10 @@ mod tests {
if !h5py_available() {
panic!("h5py not installed — skipping interop test");
}
let o = std::process::Command::new("python3")
let o = std::process::Command::new(python())
.args(["-c", script])
.output()
.expect("python3");
.expect("python interpreter");
if !o.status.success() {
panic!("h5py: {}", String::from_utf8_lossy(&o.stderr));
}
+352 -272
View File
@@ -12,6 +12,31 @@ use alloc::{format, vec, vec::Vec};
use crate::chunked_read::ChunkInfo;
use crate::error::FormatError;
/// Verify the Jenkins lookup3 checksum stored immediately after
/// `data[start..end]`, as every Extensible Array structure carries one.
///
/// A corrupt chunk index yields addresses pointing at the wrong bytes, so a
/// mismatch is an error: otherwise the damage surfaces as plausible data read
/// from the wrong chunk.
#[cfg(feature = "checksum")]
fn verify_checksum(data: &[u8], start: usize, end: usize) -> Result<(), FormatError> {
ensure_len(data, end, 4)?;
let stored = u32::from_le_bytes([data[end], data[end + 1], data[end + 2], data[end + 3]]);
let computed = crate::checksum::jenkins_lookup3(&data[start..end]);
if computed != stored {
return Err(FormatError::ChecksumMismatch {
expected: stored,
computed,
});
}
Ok(())
}
#[cfg(not(feature = "checksum"))]
fn verify_checksum(_data: &[u8], _start: usize, _end: usize) -> Result<(), FormatError> {
Ok(())
}
/// Parsed Extensible Array header (AEHD).
#[derive(Debug, Clone)]
pub struct ExtensibleArrayHeader {
@@ -145,6 +170,8 @@ impl ExtensibleArrayHeader {
pos += ls; // skip nelmts
pos += ls; // skip max_idx_set (6th stats field)
let index_block_address = read_offset(d, pos, offset_size)?;
pos += offset_size as usize;
verify_checksum(file_data, offset, offset + pos)?;
Ok(ExtensibleArrayHeader {
client_id,
@@ -270,6 +297,40 @@ fn index_to_chunk_offsets(
/// Collect elements from a data block at the given offset.
#[allow(clippy::too_many_arguments)]
/// Layout of super block `u`, per the HDF5 spec: the number of data blocks it
/// owns and how many elements each of them holds.
///
/// `ndblks` and `dblk_nelmts` each double every *other* level, a half-step
/// apart, so the blocks grow as 1x16, 1x32, 2x32, 2x64, 4x64 ... for a
/// 16-element minimum. Treating either as doubling every level (the previous
/// implementation) puts every element after the first data block at the wrong
/// index.
fn sblk_info(u: usize, data_blk_min_elmts: usize) -> Option<(usize, usize)> {
let ndblks = 1usize.checked_shl((u / 2) as u32)?;
let dblk_nelmts = 1usize
.checked_shl(u.div_ceil(2) as u32)?
.checked_mul(data_blk_min_elmts)?;
Some((ndblks, dblk_nelmts))
}
/// Width of the "offset of the block in the array" field carried by super and
/// data blocks (`hdr->arr_off_size`).
fn arr_off_size(header: &ExtensibleArrayHeader) -> usize {
(header.max_nelmts_bits as usize).div_ceil(8)
}
/// Elements per data block page, once a data block is large enough to be paged.
fn page_nelmts(header: &ExtensibleArrayHeader) -> Option<usize> {
1usize.checked_shl(u32::from(header.max_dblk_nelmts_bits))
}
/// Read the elements of one data block (EADB).
///
/// `page_init` is the owning super block's page-init bitmap and `first_page`
/// this block's first bit in it; both are only consulted when the block is
/// paged. The bitmap lives in the super block, not here — a paged data block
/// stores only its prefix, then one slot per page.
#[allow(clippy::too_many_arguments)]
fn read_data_block_elements(
file_data: &[u8],
db_offset: usize,
@@ -280,117 +341,101 @@ fn read_data_block_elements(
start_index: usize,
num_chunks_per_dim: &[u64],
chunk_dimensions: &[u32],
page_init: &[u8],
first_page: usize,
) -> Result<Vec<ChunkInfo>, FormatError> {
// AEDB: signature(4) + version(1) + client_id(1) + header_address(offset_size)
let db_header_size = 4 + 1 + 1 + offset_size as usize;
// EADB: signature(4) + version(1) + client_id(1) + header_address(offset_size)
// + block offset(arr_off_size)
let db_header_size = 4 + 1 + 1 + offset_size as usize + arr_off_size(header);
ensure_len(file_data, db_offset, db_header_size)?;
let d = &file_data[db_offset..];
if &d[0..4] != b"EADB" {
if &file_data[db_offset..db_offset + 4] != b"EADB" {
return Err(FormatError::ChunkedReadError(
"invalid Extensible Array data block signature".into(),
));
}
// Skip version(1) + client_id(1) + header_address(offset_size) + block_offset
// Block offset is encoded in ceil(max_nelmts_bits/8) bytes
let blk_off_size = (header.max_nelmts_bits as usize).div_ceil(8);
let mut pos = db_offset + db_header_size + blk_off_size;
// Check if paged
if header.max_nelmts_bits >= usize::BITS as u8 {
return Err(FormatError::Overflow(
"max_nelmts_bits exceeds usize bit width".into(),
));
}
let page_nelmts = 1usize << header.max_nelmts_bits;
let is_paged = nelmts > page_nelmts;
let mut pos = db_offset + db_header_size;
let page = page_nelmts(header).ok_or_else(|| {
FormatError::Overflow("Extensible Array page element count overflows usize".into())
})?;
let mut chunks = Vec::new();
if !is_paged {
for i in 0..nelmts {
let read_run = |from: usize,
count: usize,
first_index: usize,
chunks: &mut Vec<ChunkInfo>|
-> Result<usize, FormatError> {
let mut p = from;
for i in 0..count {
let (info, consumed) = read_element(
file_data,
pos,
p,
header.client_id,
header.element_size,
offset_size,
chunk_byte_size,
start_index + i,
first_index + i,
num_chunks_per_dim,
chunk_dimensions,
)?;
if let Some(ci) = info {
chunks.push(ci);
}
pos += consumed;
p += consumed;
}
} else {
// Paged: elements are split into pages of page_nelmts.
// After the data block header comes a page bitmap, then each page
// has page_nelmts elements followed by a 4-byte checksum.
let npages = nelmts.div_ceil(page_nelmts);
// Page bitmap: ceil(npages / 8) bytes
let bitmap_size = npages.div_ceil(8);
// Read bitmap
if pos + bitmap_size > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: pos + bitmap_size,
available: file_data.len(),
});
}
let bitmap = &file_data[pos..pos + bitmap_size];
pos += bitmap_size;
Ok(p)
};
if nelmts <= page {
// Prefix and elements are covered by one checksum.
let elem_bytes = if header.client_id == 0 {
offset_size as usize
} else {
header.element_size as usize
};
let end = nelmts
.checked_mul(elem_bytes)
.and_then(|b| pos.checked_add(b))
.ok_or_else(|| FormatError::Overflow("Extensible Array data block span".into()))?;
verify_checksum(file_data, db_offset, end)?;
read_run(pos, nelmts, start_index, &mut chunks)?;
return Ok(chunks);
}
let mut global_idx = start_index;
for page_idx in 0..npages {
let byte_idx = page_idx / 8;
let bit_idx = page_idx % 8;
let page_has_data = (bitmap[byte_idx] >> bit_idx) & 1 != 0;
let elems_this_page = if page_idx == npages - 1 {
let remainder = nelmts % page_nelmts;
if remainder == 0 {
page_nelmts
} else {
remainder
}
} else {
page_nelmts
};
if page_has_data {
for i in 0..elems_this_page {
let (info, consumed) = read_element(
file_data,
pos,
header.client_id,
header.element_size,
offset_size,
chunk_byte_size,
global_idx + i,
num_chunks_per_dim,
chunk_dimensions,
)?;
if let Some(ci) = info {
chunks.push(ci);
}
pos += consumed;
}
// Skip page checksum (4 bytes)
pos += 4;
} else {
// Empty page: skip all elements + checksum
pos += elems_this_page * elem_bytes + 4;
}
global_idx += elems_this_page;
// Paged: the prefix ends with its own checksum, then one slot per page,
// each holding `page` elements followed by a checksum. Pages whose bit is
// clear were never written; their slot still occupies the file, so stride
// over it rather than reading zeros as addresses.
verify_checksum(file_data, db_offset, pos)?;
pos += 4;
let elem_bytes = if header.client_id == 0 {
offset_size as usize
} else {
header.element_size as usize
};
let page_stride = page
.checked_mul(elem_bytes)
.and_then(|b| b.checked_add(4))
.ok_or_else(|| FormatError::Overflow("Extensible Array page stride".into()))?;
let npages = nelmts.div_ceil(page);
for p in 0..npages {
// One bit per page across the whole super block, packed contiguously
// and MSB-first within each byte, as H5VM_bit_get reads it.
let bit = first_page + p;
let initialised = page_init
.get(bit / 8)
.is_some_and(|byte| byte & (0x80 >> (bit % 8)) != 0);
if initialised {
let count = core::cmp::min(page, nelmts - p * page);
// Each page carries its own checksum, over a full page's worth of
// slots even when the last one holds fewer live elements.
verify_checksum(file_data, pos, pos + page * elem_bytes)?;
read_run(pos, count, start_index + p * page, &mut chunks)?;
}
pos = pos
.checked_add(page_stride)
.ok_or_else(|| FormatError::Overflow("Extensible Array page offset".into()))?;
}
Ok(chunks)
@@ -427,30 +472,83 @@ pub fn read_extensible_array_chunks(
let chunk_byte_size: u64 =
chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64;
// Parse index block (AEIB)
// Parse index block (EAIB): signature(4) + version(1) + client_id(1)
// + header address(offset_size), then the inline elements, then the
// direct data block addresses, then the super block addresses.
let ib_offset = header.index_block_address as usize;
let ib_header_size = 4 + 1 + 1 + offset_size as usize; // sig + ver + client + hdr_addr
let ib_header_size = 4 + 1 + 1 + os;
ensure_len(file_data, ib_offset, ib_header_size)?;
let ib = &file_data[ib_offset..];
if &ib[0..4] != b"EAIB" {
if &file_data[ib_offset..ib_offset + 4] != b"EAIB" {
return Err(FormatError::ChunkedReadError(
"invalid Extensible Array index block signature".into(),
));
}
// Skip version(1) + client_id(1) + header_address(offset_size)
let mut pos = ib_offset + ib_header_size;
let mut chunks = Vec::new();
let mut global_index = 0usize;
let total_elements = header.num_elements as usize;
// 1. Read inline elements in index block
let n_inline = header.idx_blk_elmts as usize;
for i in 0..n_inline {
if global_index + i >= total_elements {
break;
let dmin = header.min_dblk_nelmts as usize;
if dmin == 0 || !dmin.is_power_of_two() {
return Err(FormatError::ChunkedReadError(
"Extensible Array data block minimum is not a power of two".into(),
));
}
// nsblks = 1 + (max_nelmts_bits - log2(data_blk_min_elmts)), and the index
// block holds 2 * (sup_blk_min_data_ptrs - 1) data block addresses.
let log2_dmin = dmin.trailing_zeros() as usize;
let nsblks = 1 + (header.max_nelmts_bits as usize).saturating_sub(log2_dmin);
let ndblk_addrs = 2 * (header.super_blk_min_nelmts as usize).saturating_sub(1);
// The data blocks listed directly in the index block are the first
// `ndblk_addrs` in super-block order, each sized by the level it belongs
// to; the super block addresses that follow resume at the next level.
let mut direct: Vec<usize> = Vec::with_capacity(ndblk_addrs);
let mut level = 0usize;
while direct.len() < ndblk_addrs {
if level >= nsblks {
return Err(FormatError::ChunkedReadError(
"Extensible Array index block claims more data blocks than the array has".into(),
));
}
let (ndblks, dblk_nelmts) = sblk_info(level, dmin).ok_or_else(|| {
FormatError::Overflow("Extensible Array super block layout overflows usize".into())
})?;
for _ in 0..ndblks {
direct.push(dblk_nelmts);
}
level += 1;
}
if direct.len() != ndblk_addrs {
// A partial level in the index block is not a layout HDF5 produces,
// and guessing where the super blocks resume would misplace elements.
return Err(FormatError::ChunkedReadError(
"Extensible Array index block ends mid super block".into(),
));
}
// One checksum covers the prefix, every inline element slot, and every
// data block and super block address.
let elem_bytes = if header.client_id == 0 {
os
} else {
header.element_size as usize
};
let ib_end = (header.idx_blk_elmts as usize)
.checked_mul(elem_bytes)
.and_then(|b| pos.checked_add(b))
.and_then(|p| {
ndblk_addrs
.checked_add(nsblks - level)
.and_then(|n| n.checked_mul(os).and_then(|b| p.checked_add(b)))
})
.ok_or_else(|| FormatError::Overflow("Extensible Array index block span".into()))?;
verify_checksum(file_data, ib_offset, ib_end)?;
// 1. Elements stored inline in the index block.
let n_inline = (header.idx_blk_elmts as usize).min(total_elements);
for i in 0..n_inline {
let (info, consumed) = read_element(
file_data,
pos,
@@ -458,7 +556,7 @@ pub fn read_extensible_array_chunks(
header.element_size,
offset_size,
chunk_byte_size,
global_index + i,
i,
&num_chunks_per_dim,
chunk_dimensions,
)?;
@@ -467,154 +565,90 @@ pub fn read_extensible_array_chunks(
}
pos += consumed;
}
global_index += n_inline.min(total_elements);
// If all elements were inline, we're done
let mut global_index = n_inline;
if global_index >= total_elements {
return Ok(chunks);
}
// Compute data block and super block counts
let min_dblk = header.min_dblk_nelmts as usize;
let sblk_min = header.super_blk_min_nelmts as usize;
// The first sblk_min super block levels have their data blocks listed directly
// in the index block. Compute their sizes.
let mut n_direct_dblks = 0usize;
let mut dblk_sizes: Vec<usize> = Vec::new();
{
let mut nelmts = min_dblk;
for sb_level in 0..sblk_min {
if sb_level >= usize::BITS as usize {
return Err(FormatError::Overflow(
"sb_level exceeds usize bit width".into(),
// 2. Data blocks listed directly in the index block.
for &dblk_nelmts in &direct {
if global_index >= total_elements {
return Ok(chunks);
}
ensure_len(file_data, pos, os)?;
let addr = read_offset(file_data, pos, offset_size)?;
pos += os;
if !is_undefined_addr(addr, offset_size) {
if dblk_nelmts > page_nelmts(header).unwrap_or(usize::MAX) {
// Would need a page-init bitmap, which only a super block
// carries. HDF5 never pages these small early blocks.
return Err(FormatError::ChunkedReadError(
"Extensible Array index block references a paged data block".into(),
));
}
let ndblks = 1usize << sb_level;
for _ in 0..ndblks {
dblk_sizes.push(nelmts);
n_direct_dblks += 1;
}
if sb_level > 0 {
nelmts *= 2;
}
chunks.extend(read_data_block_elements(
file_data,
addr as usize,
dblk_nelmts,
header,
offset_size,
chunk_byte_size,
global_index,
&num_chunks_per_dim,
chunk_dimensions,
&[],
0,
)?);
}
global_index += dblk_nelmts;
}
// Read direct data block addresses from index block
let mut dblk_addrs: Vec<u64> = Vec::with_capacity(n_direct_dblks);
for _ in 0..n_direct_dblks {
if pos + os > file_data.len() {
// 3. Everything else lives in super blocks, one address per remaining
// level, starting at the level after the direct data blocks.
for u in level..nsblks {
if global_index >= total_elements {
break;
}
let addr = read_offset(file_data, pos, offset_size)?;
dblk_addrs.push(addr);
ensure_len(file_data, pos, os)?;
let sb_addr = read_offset(file_data, pos, offset_size)?;
pos += os;
}
// Read elements from direct data blocks
for (i, &addr) in dblk_addrs.iter().enumerate() {
if i >= dblk_sizes.len() {
break;
let (ndblks, dblk_nelmts) = sblk_info(u, dmin).ok_or_else(|| {
FormatError::Overflow("Extensible Array super block layout overflows usize".into())
})?;
if !is_undefined_addr(sb_addr, offset_size) {
chunks.extend(read_super_block(
file_data,
sb_addr as usize,
ndblks,
dblk_nelmts,
header,
offset_size,
chunk_byte_size,
global_index,
&num_chunks_per_dim,
chunk_dimensions,
)?);
}
let nelmts = dblk_sizes[i];
if is_undefined_addr(addr, offset_size) {
global_index += nelmts;
continue;
}
let block_chunks = read_data_block_elements(
file_data,
addr as usize,
nelmts,
header,
offset_size,
chunk_byte_size,
global_index,
&num_chunks_per_dim,
chunk_dimensions,
)?;
chunks.extend(block_chunks);
global_index += nelmts;
}
// Remaining elements are in super blocks
let total_in_ib_and_direct: usize = n_inline + dblk_sizes.iter().sum::<usize>();
if total_elements <= total_in_ib_and_direct {
return Ok(chunks);
}
let remaining_elements = total_elements - total_in_ib_and_direct;
// Compute super block layout
let mut sb_addrs: Vec<u64> = Vec::new();
let mut sb_infos: Vec<(usize, usize)> = Vec::new();
{
let mut covered = 0usize;
let mut sb_level = sblk_min;
let mut nelmts_per_dblk = min_dblk;
for lev in 0..sblk_min {
if lev > 0 {
nelmts_per_dblk *= 2;
}
}
while covered < remaining_elements {
if sb_level >= usize::BITS as usize {
return Err(FormatError::Overflow(
"sb_level exceeds usize bit width".into(),
));
}
let ndblks = 1usize << sb_level;
nelmts_per_dblk *= 2;
let total_in_sb = ndblks * nelmts_per_dblk;
sb_infos.push((ndblks, nelmts_per_dblk));
covered += total_in_sb;
sb_level += 1;
}
}
// Read super block addresses from index block
for _ in 0..sb_infos.len() {
if pos + os > file_data.len() {
break;
}
let addr = read_offset(file_data, pos, offset_size)?;
sb_addrs.push(addr);
pos += os;
}
// Process each super block
for (sb_idx, &sb_addr) in sb_addrs.iter().enumerate() {
let (ndblks, nelmts_per_dblk) = sb_infos[sb_idx];
if is_undefined_addr(sb_addr, offset_size) {
global_index += ndblks * nelmts_per_dblk;
continue;
}
let sb_chunks = read_super_block(
file_data,
sb_addr as usize,
ndblks,
nelmts_per_dblk,
header,
offset_size,
chunk_byte_size,
global_index,
&num_chunks_per_dim,
chunk_dimensions,
)?;
chunks.extend(sb_chunks);
global_index += ndblks * nelmts_per_dblk;
global_index =
global_index.saturating_add(ndblks.checked_mul(dblk_nelmts).ok_or_else(|| {
FormatError::Overflow("Extensible Array super block span".into())
})?);
}
Ok(chunks)
}
/// Read a super block (AESB) and its data blocks.
/// Read a super block (EASB) and the data blocks it owns.
///
/// On disk: signature(4) + version(1) + client_id(1) + header address
/// + block offset + the page-init bitmap for every data block it owns
/// + one address per data block + checksum.
#[allow(clippy::too_many_arguments)]
fn read_super_block(
file_data: &[u8],
sb_offset: usize,
ndblks: usize,
nelmts_per_dblk: usize,
dblk_nelmts: usize,
header: &ExtensibleArrayHeader,
offset_size: u8,
chunk_byte_size: u64,
@@ -623,9 +657,7 @@ fn read_super_block(
chunk_dimensions: &[u32],
) -> Result<Vec<ChunkInfo>, FormatError> {
let os = offset_size as usize;
// AESB: signature(4) + version(1) + client_id(1) + header_address(offset_size)
let sb_header_size = 4 + 1 + 1 + os;
let sb_header_size = 4 + 1 + 1 + os + arr_off_size(header);
ensure_len(file_data, sb_offset, sb_header_size)?;
if &file_data[sb_offset..sb_offset + 4] != b"EASB" {
@@ -634,43 +666,57 @@ fn read_super_block(
));
}
let mut pos = sb_offset + sb_header_size;
// Read data block addresses
let mut dblk_addrs: Vec<u64> = Vec::with_capacity(ndblks);
for _ in 0..ndblks {
if pos + os > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: pos + os,
available: file_data.len(),
});
}
let addr = read_offset(file_data, pos, offset_size)?;
dblk_addrs.push(addr);
pos += os;
}
// Page-init bitmap: one bit per page, `npages` bits per data block, packed
// contiguously. HDF5 sizes the buffer `ndblks * ceil(npages / 8)`, which
// is bigger than the bits need when `npages` is not a multiple of eight.
// Zero-sized unless this level's data blocks are paged.
let page = page_nelmts(header).ok_or_else(|| {
FormatError::Overflow("Extensible Array page element count overflows usize".into())
})?;
let npages = if dblk_nelmts > page {
dblk_nelmts / page
} else {
0
};
let per_dblk_bitmap = npages.div_ceil(8);
let bitmap_bytes = per_dblk_bitmap
.checked_mul(ndblks)
.ok_or_else(|| FormatError::Overflow("Extensible Array page bitmap size".into()))?;
let bitmap_start = sb_offset + sb_header_size;
ensure_len(file_data, bitmap_start, bitmap_bytes)?;
let bitmap = &file_data[bitmap_start..bitmap_start + bitmap_bytes];
let mut pos = bitmap_start + bitmap_bytes;
let mut chunks = Vec::new();
let mut global_idx = start_index;
for &addr in &dblk_addrs {
if is_undefined_addr(addr, offset_size) {
global_idx += nelmts_per_dblk;
continue;
// One checksum covers the prefix, the bitmap and every data block address.
let sb_end = ndblks
.checked_mul(os)
.and_then(|b| pos.checked_add(b))
.ok_or_else(|| FormatError::Overflow("Extensible Array super block span".into()))?;
verify_checksum(file_data, sb_offset, sb_end)?;
for i in 0..ndblks {
ensure_len(file_data, pos, os)?;
let addr = read_offset(file_data, pos, offset_size)?;
pos += os;
if !is_undefined_addr(addr, offset_size) {
chunks.extend(read_data_block_elements(
file_data,
addr as usize,
dblk_nelmts,
header,
offset_size,
chunk_byte_size,
global_idx,
num_chunks_per_dim,
chunk_dimensions,
bitmap,
i * npages,
)?);
}
let block_chunks = read_data_block_elements(
file_data,
addr as usize,
nelmts_per_dblk,
header,
offset_size,
chunk_byte_size,
global_idx,
num_chunks_per_dim,
chunk_dimensions,
)?;
chunks.extend(block_chunks);
global_idx += nelmts_per_dblk;
global_idx += dblk_nelmts;
}
Ok(chunks)
@@ -679,6 +725,14 @@ fn read_super_block(
#[cfg(test)]
mod tests {
use super::*;
/// Stamp the Jenkins checksum a real file would carry over
/// `data[start..end]`, writing it at `end`. Hand-built fixtures need this
/// now that the reader validates it, exactly as HDF5 writes it.
fn stamp_checksum(data: &mut [u8], start: usize, end: usize) {
let sum = crate::checksum::jenkins_lookup3(&data[start..end]);
data[end..end + 4].copy_from_slice(&sum.to_le_bytes());
}
#[test]
fn index_to_offsets_1d() {
let num_chunks = vec![5u64];
@@ -734,6 +788,7 @@ mod tests {
buf[44..52].copy_from_slice(&5u64.to_le_bytes()); // stat[4] = num_elements
buf[52..60].copy_from_slice(&0u64.to_le_bytes()); // stat[5]
buf[60..68].copy_from_slice(&0x1000u64.to_le_bytes()); // index_block_address
stamp_checksum(&mut buf, 0, 68);
let hdr = ExtensibleArrayHeader::parse(&buf, 0, os, ls).unwrap();
assert_eq!(hdr.client_id, 0);
@@ -819,6 +874,7 @@ mod tests {
.copy_from_slice(&(num_chunks as u64).to_le_bytes());
file_data[aehd_offset + 60..aehd_offset + 68]
.copy_from_slice(&(aeib_offset as u64).to_le_bytes());
stamp_checksum(&mut file_data, aehd_offset, aehd_offset + 68);
// checksum (4 bytes at +68) — not validated
// Build AEIB at aeib_offset
@@ -836,6 +892,23 @@ mod tests {
let p = elem_start + i * osv;
file_data[p..p + osv].copy_from_slice(&addr.to_le_bytes());
}
// The index block's checksum covers its prefix, every inline element
// slot, and every data block and super block address slot:
// ndblk_addrs = 2 * (sup_blk_min_data_ptrs - 1), and the super block
// pointers make up the rest of nsblks levels.
let sup_ptrs = file_data[aehd_offset + 10] as usize;
let dmin = file_data[aehd_offset + 9] as usize;
let nsblks = 1 + 10 - dmin.trailing_zeros() as usize;
let ndblk_addrs = 2 * (sup_ptrs - 1);
// Levels consumed by those direct data blocks (1, 1, 2, 2, ... per level).
let mut consumed = 0usize;
let mut levels = 0usize;
while consumed < ndblk_addrs {
consumed += 1 << (levels / 2);
levels += 1;
}
let ib_end = elem_start + num_chunks * osv + (ndblk_addrs + nsblks - levels) * osv;
stamp_checksum(&mut file_data, aeib_offset, ib_end);
let header = ExtensibleArrayHeader::parse(&file_data, aehd_offset, os, ls).unwrap();
let ds_dims = vec![40u64]; // 2 chunks × 20 elements
@@ -885,6 +958,7 @@ mod tests {
// idx_blk_addr at offset 12 + 6*8 = 60
file_data[aehd_offset + 60..aehd_offset + 68]
.copy_from_slice(&(aeib_offset as u64).to_le_bytes());
stamp_checksum(&mut file_data, aehd_offset, aehd_offset + 68);
// AEIB
file_data[aeib_offset..aeib_offset + 4].copy_from_slice(b"EAIB");
@@ -903,42 +977,48 @@ mod tests {
pos += osv;
}
// Direct data block addresses: first sb_level=0 has 1 dblk, sb_level=1 has 1 dblk
// Total direct dblks for sblk_min=2: 2^0 + 2^1 = 1 + 2 = 3 (oops)
// Actually: sblk_min levels. level 0: 2^0=1 dblk, level 1: 2^1=2 dblks => 3 dblks
// But we only have 2 remaining elements.
// dblk sizes: level 0: 1 dblk of min_dblk=2; level 1: 2 dblks of 2 each (nelmts doubles at level > 0)
// Wait, re-reading the code: at level 0, nelmts=min_dblk=2, 1 dblk.
// At level 1, 1 dblk, nelmts still 2 (doubles only at level > 0... but the code says
// `if sb_level > 0 { nelmts *= 2 }` after pushing). Let me re-check.
// After push at level 0: nelmts=2. Then if 0>0 false, no double. Push 1 dblk of 2.
// Level 1: ndblks=2. Push 2 dblks of 2. Then 1>0 true, nelmts=4.
// Total: 3 dblks with sizes [2, 2, 2]. Total = 6.
// We only need 2 more elements. So only the first dblk has data.
let n_direct_dblks = 3;
// Direct data block addresses. With sup_blk_min_data_ptrs = 2 the index
// block holds 2 * (2 - 1) = 2 of them, which are the data blocks of
// super block levels 0 and 1: one of `min_dblk_nelmts` elements, then
// one of twice that (ndblks = 2^(u/2), dblk_nelmts = 2^((u+1)/2) * min).
// Only the first is allocated here; the rest of the array is empty.
let ndblk_addrs = 2 * (sblk_min as usize - 1);
file_data[pos..pos + osv].copy_from_slice(&(aedb_offset as u64).to_le_bytes());
pos += osv;
// 2 more dblk addresses - undefined
for _ in 1..n_direct_dblks {
for _ in 1..ndblk_addrs {
file_data[pos..pos + osv].copy_from_slice(&u64::MAX.to_le_bytes());
pos += osv;
}
// Super block addresses fill the remaining levels; all unallocated.
let nsblks = 1 + 10 - (min_dblk_nelmts as usize).trailing_zeros() as usize;
let mut consumed = 0usize;
let mut levels = 0usize;
while consumed < ndblk_addrs {
consumed += 1 << (levels / 2);
levels += 1;
}
for _ in 0..(nsblks - levels) {
file_data[pos..pos + osv].copy_from_slice(&u64::MAX.to_le_bytes());
pos += osv;
}
stamp_checksum(&mut file_data, aeib_offset, pos);
// EADB at aedb_offset (min_dblk_nelmts elements)
// EADB holding the first data block's `min_dblk_nelmts` elements.
file_data[aedb_offset..aedb_offset + 4].copy_from_slice(b"EADB");
file_data[aedb_offset + 4] = 0;
file_data[aedb_offset + 5] = 0;
file_data[aedb_offset + 6..aedb_offset + 14]
.copy_from_slice(&(aehd_offset as u64).to_le_bytes());
// block_offset: ceil(max_nelmts_bits/8) = ceil(10/8) = 2 bytes
// block_offset = 0 for first data block
let blk_off_size = (10usize).div_ceil(8); // max_nelmts_bits=10
let mut dbpos = aedb_offset + 6 + osv + blk_off_size;
// Block offset field: ceil(max_nelmts_bits / 8) bytes, zero here.
let blk_off_size = (10usize).div_ceil(8);
let db_elems = aedb_offset + 6 + osv + blk_off_size;
let mut dbpos = db_elems;
for i in 0..min_dblk_nelmts as usize {
let addr = base_addr + (idx_blk_elmts as u64 + i as u64) * chunk_byte_size;
file_data[dbpos..dbpos + osv].copy_from_slice(&addr.to_le_bytes());
dbpos += osv;
}
stamp_checksum(&mut file_data, aedb_offset, dbpos);
let header = ExtensibleArrayHeader::parse(&file_data, aehd_offset, os, ls).unwrap();
let ds_dims = vec![40u64];
+106 -2
View File
@@ -9,6 +9,31 @@ use alloc::{format, vec, vec::Vec};
use crate::chunked_read::ChunkInfo;
use crate::error::FormatError;
/// Verify the Jenkins lookup3 checksum stored immediately after
/// `data[start..end]`, as every Fixed Array structure carries one.
///
/// A corrupt chunk index silently yields addresses pointing at the wrong
/// bytes, so a mismatch has to be an error rather than a shrug: without this
/// the damage surfaces as plausible-looking data from the wrong chunk.
#[cfg(feature = "checksum")]
fn verify_checksum(data: &[u8], start: usize, end: usize) -> Result<(), FormatError> {
ensure_len(data, end, 4)?;
let stored = u32::from_le_bytes([data[end], data[end + 1], data[end + 2], data[end + 3]]);
let computed = crate::checksum::jenkins_lookup3(&data[start..end]);
if computed != stored {
return Err(FormatError::ChecksumMismatch {
expected: stored,
computed,
});
}
Ok(())
}
#[cfg(not(feature = "checksum"))]
fn verify_checksum(_data: &[u8], _start: usize, _end: usize) -> Result<(), FormatError> {
Ok(())
}
/// Parsed Fixed Array header (FAHD).
#[derive(Debug, Clone)]
pub struct FixedArrayHeader {
@@ -103,6 +128,8 @@ impl FixedArrayHeader {
let num_elements = read_length(d, pos, length_size)?;
pos += length_size as usize;
let data_block_address = read_offset(d, pos, offset_size)?;
pos += offset_size as usize;
verify_checksum(file_data, offset, offset + pos)?;
Ok(FixedArrayHeader {
client_id,
@@ -223,7 +250,8 @@ pub fn read_fixed_array_chunks(
if !is_paged {
// Non-paged: prefix, then `num_elements` elements packed directly,
// then a trailing checksum (which we don't validate).
// then a checksum over both.
verify_checksum(file_data, db_offset, elem_at(elements_start, num_elements)?)?;
for i in 0..num_elements {
push_element(i, elem_at(elements_start, i)?, &mut chunks)?;
}
@@ -254,6 +282,9 @@ pub fn read_fixed_array_chunks(
available: file_data.len(),
});
}
// The prefix and page bitmap are covered by their own checksum, and each
// initialised page by one of its own.
verify_checksum(file_data, db_offset, bitmap_start + bitmap_size)?;
for p in 0..npages {
let page_first = p * page_nelmts; // < num_elements, cannot overflow
@@ -270,6 +301,7 @@ pub fn read_fixed_array_chunks(
.checked_mul(page_stride)
.and_then(|o| pages_start.checked_add(o))
.ok_or_else(stride_overflow)?;
verify_checksum(file_data, page_off, elem_at(page_off, page_count)?)?;
for e in 0..page_count {
push_element(page_first + e, elem_at(page_off, e)?, &mut chunks)?;
}
@@ -374,6 +406,14 @@ fn read_variable_length(data: &[u8], size: usize) -> Result<u64, FormatError> {
mod tests {
use super::*;
/// Stamp the Jenkins checksum a real file would carry over
/// `data[start..end]`, writing it at `end`. Fixtures built by hand need
/// this now that the reader validates it — as every HDF5 writer does.
fn stamp_checksum(data: &mut [u8], start: usize, end: usize) {
let sum = crate::checksum::jenkins_lookup3(&data[start..end]);
data[end..end + 4].copy_from_slice(&sum.to_le_bytes());
}
#[test]
fn index_to_offsets_1d() {
let num_chunks = vec![5u64];
@@ -439,7 +479,7 @@ mod tests {
buf[8..16].copy_from_slice(&5u64.to_le_bytes());
// data_block_address (offset_size=8)
buf[16..24].copy_from_slice(&0x1000u64.to_le_bytes());
// checksum (4 bytes, we don't validate in parse)
stamp_checksum(&mut buf, 0, 24);
let header = FixedArrayHeader::parse(&buf, 0, 8, 8).unwrap();
assert_eq!(header.client_id, 1);
@@ -449,6 +489,54 @@ mod tests {
assert_eq!(header.data_block_address, 0x1000);
}
/// Corruption anywhere in the index must be an error, not a wrong
/// address. Every structure carries a checksum; flipping a bit in each in
/// turn must be caught, because the alternative is reading a chunk from
/// the wrong offset and returning it as data.
#[test]
fn corrupting_any_fixed_array_structure_is_detected() {
let build = || -> (Vec<u8>, usize) {
let (os, fahd, db) = (8usize, 0x100usize, 0x200usize);
let mut f = vec![0u8; 0x3000];
f[fahd..fahd + 4].copy_from_slice(b"FAHD");
f[fahd + 6] = os as u8;
f[fahd + 7] = 10;
f[fahd + 8..fahd + 16].copy_from_slice(&3u64.to_le_bytes());
f[fahd + 16..fahd + 24].copy_from_slice(&(db as u64).to_le_bytes());
stamp_checksum(&mut f, fahd, fahd + 24);
f[db..db + 4].copy_from_slice(b"FADB");
f[db + 6..db + 14].copy_from_slice(&(fahd as u64).to_le_bytes());
let elems = db + 6 + os;
for i in 0..3usize {
let addr = 0x1000u64 + i as u64 * 0x100;
f[elems + i * os..elems + (i + 1) * os].copy_from_slice(&addr.to_le_bytes());
}
stamp_checksum(&mut f, db, elems + 3 * os);
(f, fahd)
};
let read = |f: &[u8], fahd: usize| -> Result<Vec<ChunkInfo>, FormatError> {
let h = FixedArrayHeader::parse(f, fahd, 8, 8)?;
read_fixed_array_chunks(f, &h, &[60], &[20], 8, 8, 8)
};
let (clean, fahd) = build();
assert!(read(&clean, fahd).is_ok(), "the intact fixture must read");
// A byte inside the header, and one inside a data block element.
for &at in &[0x108usize, 0x210usize] {
let (mut damaged, fahd) = build();
damaged[at] ^= 0x01;
assert!(
matches!(
read(&damaged, fahd),
Err(FormatError::ChecksumMismatch { .. })
),
"corruption at {at:#x} went undetected"
);
}
}
#[test]
fn parse_fixed_array_header_invalid_signature() {
let mut buf = vec![0u8; 256];
@@ -469,6 +557,7 @@ mod tests {
buf[fahd + 7] = 200; // max_nelmts_bits — absurd, would overflow a shift
buf[fahd + 8..fahd + 16].copy_from_slice(&3u64.to_le_bytes()); // num_elements
buf[fahd + 16..fahd + 24].copy_from_slice(&0x100u64.to_le_bytes());
stamp_checksum(&mut buf, fahd, fahd + 24);
// FADB so parsing reaches the paged check
let db = 0x100usize;
buf[db..db + 4].copy_from_slice(b"FADB");
@@ -486,6 +575,8 @@ mod tests {
buf[fahd + 7] = 10;
buf[fahd + 8..fahd + 16].copy_from_slice(&u64::MAX.to_le_bytes()); // absurd count
buf[fahd + 16..fahd + 24].copy_from_slice(&0x80u64.to_le_bytes());
// Valid checksum, so it is the element count that must be rejected.
stamp_checksum(&mut buf, fahd, fahd + 24);
buf[0x80..0x84].copy_from_slice(b"FADB");
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
@@ -545,6 +636,7 @@ mod tests {
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_chunks.to_le_bytes());
file_data[fahd_offset + 16..fahd_offset + 24]
.copy_from_slice(&(db_offset as u64).to_le_bytes());
stamp_checksum(&mut file_data, fahd_offset, fahd_offset + 24);
// Build FADB at db_offset
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
@@ -562,6 +654,7 @@ mod tests {
let pos = elem_start + i * os;
file_data[pos..pos + os].copy_from_slice(&addr.to_le_bytes());
}
stamp_checksum(&mut file_data, db_offset, elem_start + 5 * os);
let header =
FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap();
@@ -611,6 +704,7 @@ mod tests {
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_chunks.to_le_bytes());
file_data[fahd_offset + 16..fahd_offset + 24]
.copy_from_slice(&(db_offset as u64).to_le_bytes());
stamp_checksum(&mut file_data, fahd_offset, fahd_offset + 24);
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
file_data[db_offset + 4] = 0;
@@ -632,6 +726,11 @@ mod tests {
file_data[pos + os..pos + os + 4].copy_from_slice(&csize.to_le_bytes());
file_data[pos + os + 4..pos + os + 8].copy_from_slice(&fmask.to_le_bytes());
}
stamp_checksum(
&mut file_data,
db_offset,
elem_start + test_chunks.len() * elem_size,
);
let header =
FixedArrayHeader::parse(&file_data, fahd_offset, offset_size, length_size).unwrap();
@@ -696,6 +795,7 @@ mod tests {
file_data[fahd_offset + 8..fahd_offset + 16].copy_from_slice(&num_elements.to_le_bytes());
file_data[fahd_offset + 16..fahd_offset + 24]
.copy_from_slice(&(db_offset as u64).to_le_bytes());
stamp_checksum(&mut file_data, fahd_offset, fahd_offset + 24);
// FADB prefix
file_data[db_offset..db_offset + 4].copy_from_slice(b"FADB");
@@ -715,6 +815,9 @@ mod tests {
let base_addr = 0x1000u64;
// Page 0 (elements 0..4) and page 2 (elements 8..11) carry addresses;
// page 1's slot is left zero-filled and must be skipped.
// The prefix and bitmap carry one checksum, each initialised page
// another — as a real file does.
stamp_checksum(&mut file_data, db_offset, bitmap_off + bitmap_size);
for &p in &[0usize, 2usize] {
let page_off = pages_start + p * page_total;
let count = core::cmp::min(page_nelmts, num_elements as usize - p * page_nelmts);
@@ -724,6 +827,7 @@ mod tests {
let pos = page_off + e * os;
file_data[pos..pos + os].copy_from_slice(&addr.to_le_bytes());
}
stamp_checksum(&mut file_data, page_off, page_off + count * os);
}
let header =
@@ -2,6 +2,15 @@
use clawhdf5_format::data_read::{read_object_references, read_region_references};
use clawhdf5_format::datatype::{Datatype, ReferenceType};
/// The Python interpreter to drive interop checks with.
///
/// `CLAWHDF5_PYTHON` lets these run against a virtualenv holding h5py, which
/// on a PEP 668 "externally managed" system is the only place it can be
/// installed. Without it the suite silently skips, and a silent skip here is
/// how a datatype bug once reached a release.
fn python() -> String {
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
}
#[test]
fn object_ref_single_valid() {
@@ -173,7 +182,7 @@ print('ok')
"#,
path.display()
);
let output = std::process::Command::new("python3")
let output = std::process::Command::new(python())
.args(["-c", &script])
.output();
@@ -4,9 +4,18 @@
//! (and vice versa). They require python3 + h5py to be installed.
use clawhdf5_format::file_writer::{AttrValue, CompoundTypeBuilder, EnumTypeBuilder, FileWriter};
/// The Python interpreter to drive interop checks with.
///
/// `CLAWHDF5_PYTHON` lets these run against a virtualenv holding h5py, which
/// on a PEP 668 "externally managed" system is the only place it can be
/// installed. Without it the suite silently skips, and a silent skip here is
/// how a datatype bug once reached a release.
fn python() -> String {
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
}
fn h5py_available() -> bool {
std::process::Command::new("python3")
std::process::Command::new(python())
.args(["-c", "import h5py"])
.output()
.map(|o| o.status.success())
@@ -17,10 +26,10 @@ fn h5py_read(_path: &std::path::Path, script: &str) -> String {
if !h5py_available() {
panic!("h5py not installed — skipping interop test");
}
let o = std::process::Command::new("python3")
let o = std::process::Command::new(python())
.args(["-c", script])
.output()
.expect("python3");
.expect("python interpreter");
if !o.status.success() {
panic!("h5py: {}", String::from_utf8_lossy(&o.stderr));
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-gpu"
version = "2.5.0"
version = "2.7.0"
edition = "2024"
description = "GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders"
license = "MIT"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-io"
version = "2.5.0"
version = "2.7.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.5.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.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.5.0"
version = "2.7.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.5.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.5.0" }
clawhdf5 = { path = "../clawhdf5", version = "2.5.0" }
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.7.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0" }
clawhdf5 = { path = "../clawhdf5", version = "2.7.0" }
rusqlite = { version = "0.31", features = ["bundled"] }
clap = { version = "4", features = ["derive"] }
half = { workspace = true }
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-napi"
version = "2.5.0"
version = "2.7.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.5.0" }
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.7.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.5.0"
version = "2.7.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.5.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.5.0" }
clawhdf5 = { path = "../clawhdf5", version = "2.7.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0" }
[dev-dependencies]
tempfile = { workspace = true }
+12 -3
View File
@@ -9,6 +9,15 @@ use clawhdf5_netcdf4::{AttrValue, NetCDF4File};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// The Python interpreter to drive interop checks with.
///
/// `CLAWHDF5_PYTHON` lets these run against a virtualenv holding h5py, which
/// on a PEP 668 "externally managed" system is the only place it can be
/// installed. Without it the suite silently skips, and a silent skip here is
/// how a datatype bug once reached a release.
fn python() -> String {
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
}
/// When `CLAWHDF5_REQUIRE_INTEROP=1` (set in CI), a missing Python dependency
/// is a test failure instead of a silent skip.
@@ -17,7 +26,7 @@ fn interop_required() -> bool {
}
fn netcdf4_python_available() -> bool {
Command::new("python3")
Command::new(python())
.args(["-c", "import netCDF4; print(netCDF4.__version__)"])
.output()
.map(|o| o.status.success())
@@ -25,7 +34,7 @@ fn netcdf4_python_available() -> bool {
}
fn xarray_available() -> bool {
Command::new("python3")
Command::new(python())
.args(["-c", "import xarray; print(xarray.__version__)"])
.output()
.map(|o| o.status.success())
@@ -59,7 +68,7 @@ macro_rules! skip_if_no_xarray {
}
fn run_python(script: &str) {
let output = Command::new("python3")
let output = Command::new(python())
.args(["-c", script])
.output()
.expect("failed to run python3");
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-py"
version = "2.5.0"
version = "2.7.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.5.0", package = "clawhdf5" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.5.0" }
clawhdf5_rs = { path = "../clawhdf5", version = "2.7.0", package = "clawhdf5" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0" }
pyo3 = "0.29"
numpy = "0.29"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "rustyhdf5"
version = "2.5.0"
version = "2.7.0"
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
requires-python = ">=3.8"
license = { text = "MIT" }
+6 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5"
version = "2.5.0"
version = "2.7.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.5.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.5.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.7.0" }
rayon = { version = "1", optional = true }
[dev-dependencies]
tempfile = { workspace = true }
criterion = { workspace = true }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.5.0", features = ["mmap"] }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.5.0", features = ["parallel", "fast-checksum"] }
clawhdf5-filters = { path = "../clawhdf5-filters", version = "2.5.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.7.0", features = ["mmap"] }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0", features = ["parallel", "fast-checksum"] }
clawhdf5-filters = { path = "../clawhdf5-filters", version = "2.7.0" }
[[bench]]
name = "mmap_bench"
+352 -3
View File
@@ -9,6 +9,15 @@ use clawhdf5::{AttrValue, CompoundTypeBuilder, DType, File, FileBuilder};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// The Python interpreter to drive interop checks with.
///
/// `CLAWHDF5_PYTHON` lets these run against a virtualenv holding h5py, which
/// on a PEP 668 "externally managed" system is the only place it can be
/// installed. Without it the suite silently skips, and a silent skip here is
/// how a datatype bug once reached a release.
fn python() -> String {
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
}
/// When `CLAWHDF5_REQUIRE_INTEROP=1` (set in CI), a missing Python dependency
/// is a test failure instead of a silent skip.
@@ -17,7 +26,7 @@ fn interop_required() -> bool {
}
fn python_available() -> bool {
Command::new("python3")
Command::new(python())
.args(["-c", "import h5py; print(h5py.__version__)"])
.output()
.map(|o| o.status.success())
@@ -39,7 +48,7 @@ macro_rules! skip_if_no_python {
/// Run a Python script and panic if it fails.
fn run_python(script: &str) {
let output = Command::new("python3")
let output = Command::new(python())
.args(["-c", script])
.output()
.expect("failed to run python3");
@@ -52,7 +61,7 @@ fn run_python(script: &str) {
/// Run a Python script and return stdout as a trimmed string.
fn run_python_output(script: &str) -> String {
let output = Command::new("python3")
let output = Command::new(python())
.args(["-c", script])
.output()
.expect("failed to run python3");
@@ -1038,3 +1047,343 @@ with h5py.File("{path_str}", "r") as f:
data[start..start + cols as usize]
);
}
#[test]
fn h5py_deep_btree_v2_chunk_index_clawhdf5_reads() {
// Two unlimited dimensions give a B-tree v2 chunk index, and 2x2 chunks
// over 400x400 give 40 000 index records — enough for HDF5 to build a
// tree of depth 2. Small h5py files only ever produce depth-0 trees, so
// this is the one fixture that walks internal nodes: the path where the
// traversal's record budget (the guard against crafted shared-subtree
// trees) is spent, which must never refuse a real file.
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("deep_btree.h5");
let path_str = path.display().to_string();
let script = format!(
r#"
import h5py, numpy as np
with h5py.File("{path_str}", "w", libver="latest") as f:
d = f.create_dataset("x", shape=(400, 400), maxshape=(None, None),
chunks=(2, 2), dtype="i4")
d[...] = np.arange(160000, dtype="i4").reshape(400, 400)
"#
);
run_python(&script);
// The fixture is only meaningful if HDF5 really built internal nodes.
let bytes = std::fs::read(&path).unwrap();
let at = bytes
.windows(4)
.position(|w| w == b"BTHD")
.expect("expected a B-tree v2 chunk index");
let depth = u16::from_le_bytes([bytes[at + 12], bytes[at + 13]]);
assert!(
depth >= 1,
"fixture tree has depth {depth}; it tests nothing"
);
let file = File::open(&path).unwrap();
let values = file.dataset("x").unwrap().read_i32().unwrap();
assert_eq!(values.len(), 160_000);
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as i32, "element {i}");
}
}
#[test]
fn h5py_extensible_array_chunk_index_clawhdf5_reads() {
// One unlimited dimension means an Extensible Array chunk index. Only its
// first few elements live inline in the index block (4 by default), and
// every other fixture here is small enough to stop there — which is how
// the data block and super block layouts came to be wrong without a test
// noticing. The counts below step over each boundary in turn:
// 4 inline elements only
// 37 past the first direct data block
// 400 into the first super block
// 5000 several super block levels
// 200000 data blocks large enough to be paged
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
for n in [4usize, 37, 400, 5_000, 200_000] {
let path = dir.path().join(format!("ea_{n}.h5"));
let path_str = path.display().to_string();
run_python(&format!(
r#"
import h5py, numpy as np
with h5py.File("{path_str}", "w", libver="latest") as f:
d = f.create_dataset("x", shape=({n},), maxshape=(None,), chunks=(1,), dtype="i4")
d[...] = np.arange({n}, dtype="i4")
"#
));
let bytes = std::fs::read(&path).unwrap();
assert!(
bytes.windows(4).any(|w| w == b"EAHD"),
"n={n}: fixture is not indexed by an Extensible Array"
);
let file = File::open(&path).unwrap();
let values = file.dataset("x").unwrap().read_i32().unwrap();
assert_eq!(values.len(), n, "n={n}");
let wrong = values
.iter()
.enumerate()
.filter(|&(i, &v)| v != i as i32)
.count();
assert_eq!(wrong, 0, "n={n}: {wrong} of {n} elements read back wrong");
}
}
#[test]
fn h5py_sparse_extensible_array_leaves_pages_uninitialised() {
// Writing a scattered subset leaves whole pages of a paged data block
// never initialised. Those pages still occupy their slot on disk, so the
// reader has to skip them by stride and take the fill value instead —
// driven by the page-init bitmap, which is packed one bit per page across
// the whole super block, MSB first.
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ea_sparse.h5");
let path_str = path.display().to_string();
let n = 200_000usize;
let step = 997usize;
run_python(&format!(
r#"
import h5py
with h5py.File("{path_str}", "w", libver="latest") as f:
d = f.create_dataset("x", shape=({n},), maxshape=(None,), chunks=(1,),
dtype="i4", fillvalue=-1)
for i in list(range(0, {n}, {step})) + list(range(0, 40)):
d[i] = i
"#
));
let file = File::open(&path).unwrap();
let values = file.dataset("x").unwrap().read_i32().unwrap();
assert_eq!(values.len(), n);
let wrong = values
.iter()
.enumerate()
.filter(|&(i, &v)| {
let expected = if i % step == 0 || i < 40 {
i as i32
} else {
-1
};
v != expected
})
.count();
assert_eq!(wrong, 0, "{wrong} of {n} elements read back wrong");
}
#[test]
fn h5py_filtered_and_2d_extensible_array_clawhdf5_reads() {
// Filtered elements carry a size and filter mask beside the address, and
// a second (fixed) dimension changes how a linear index maps back to
// chunk offsets. Both run through the same traversal.
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let gz = dir.path().join("ea_gzip.h5");
let gz_str = gz.display().to_string();
run_python(&format!(
r#"
import h5py, numpy as np
with h5py.File("{gz_str}", "w", libver="latest") as f:
d = f.create_dataset("x", shape=(5000,), maxshape=(None,), chunks=(1,),
dtype="i4", compression="gzip", compression_opts=4)
d[...] = np.arange(5000, dtype="i4")
"#
));
let values = File::open(&gz)
.unwrap()
.dataset("x")
.unwrap()
.read_i32()
.unwrap();
assert_eq!(values.len(), 5000);
assert_eq!(
values
.iter()
.enumerate()
.filter(|&(i, &v)| v != i as i32)
.count(),
0
);
let two_d = dir.path().join("ea_2d.h5");
let two_d_str = two_d.display().to_string();
run_python(&format!(
r#"
import h5py, numpy as np
with h5py.File("{two_d_str}", "w", libver="latest") as f:
d = f.create_dataset("x", shape=(3000, 4), maxshape=(None, 4), chunks=(1, 4), dtype="i4")
d[...] = np.arange(12000, dtype="i4").reshape(3000, 4)
"#
));
let values = File::open(&two_d)
.unwrap()
.dataset("x")
.unwrap()
.read_i32()
.unwrap();
assert_eq!(values.len(), 12_000);
assert_eq!(
values
.iter()
.enumerate()
.filter(|&(i, &v)| v != i as i32)
.count(),
0
);
}
#[test]
fn h5py_fixed_array_chunk_index_clawhdf5_reads() {
// Fixed dimensions plus libver='latest' give a Fixed Array chunk index.
// Its data blocks are paged above 2^page_bits elements (1024 by default),
// and unlike the Extensible Array it keeps the page-init bitmap in the
// data block itself — a difference worth pinning down, since assuming
// otherwise is exactly what made the Extensible Array reader wrong. The
// sparse case leaves whole pages uninitialised so the bitmap is actually
// consulted rather than being all ones.
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
for n in [100usize, 5_000, 200_000] {
let path = dir.path().join(format!("fa_{n}.h5"));
let path_str = path.display().to_string();
run_python(&format!(
r#"
import h5py, numpy as np
with h5py.File("{path_str}", "w", libver="latest") as f:
d = f.create_dataset("x", shape=({n},), chunks=(1,), dtype="i4")
d[...] = np.arange({n}, dtype="i4")
"#
));
let bytes = std::fs::read(&path).unwrap();
assert!(
bytes.windows(4).any(|w| w == b"FAHD"),
"n={n}: fixture is not indexed by a Fixed Array"
);
let values = File::open(&path)
.unwrap()
.dataset("x")
.unwrap()
.read_i32()
.unwrap();
assert_eq!(values.len(), n, "n={n}");
let wrong = values
.iter()
.enumerate()
.filter(|&(i, &v)| v != i as i32)
.count();
assert_eq!(wrong, 0, "n={n}: {wrong} elements read back wrong");
}
let sparse = dir.path().join("fa_sparse.h5");
let sparse_str = sparse.display().to_string();
let (n, step) = (200_000usize, 997usize);
run_python(&format!(
r#"
import h5py
with h5py.File("{sparse_str}", "w", libver="latest") as f:
d = f.create_dataset("x", shape=({n},), chunks=(1,), dtype="i4", fillvalue=-1)
for i in list(range(0, {n}, {step})) + list(range(0, 40)):
d[i] = i
"#
));
let values = File::open(&sparse)
.unwrap()
.dataset("x")
.unwrap()
.read_i32()
.unwrap();
assert_eq!(values.len(), n);
let wrong = values
.iter()
.enumerate()
.filter(|&(i, &v)| {
let expected = if i % step == 0 || i < 40 {
i as i32
} else {
-1
};
v != expected
})
.count();
assert_eq!(wrong, 0, "sparse: {wrong} of {n} elements read back wrong");
}
#[test]
fn corrupting_a_chunk_index_is_an_error_not_wrong_data() {
// Every Fixed/Extensible Array structure carries a Jenkins checksum, and
// the reader now verifies it. The point is not the checksum itself but
// what it prevents: a damaged index otherwise yields addresses pointing
// at the wrong bytes, and the caller receives another chunk's data as if
// it were the one asked for.
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
for (name, maxshape) in [("fixed", "None"), ("extensible", "(None,)")] {
let path = dir.path().join(format!("{name}.h5"));
let path_str = path.display().to_string();
let shape_arg = if maxshape == "None" {
String::new()
} else {
format!(", maxshape={maxshape}")
};
run_python(&format!(
r#"
import h5py, numpy as np
with h5py.File("{path_str}", "w", libver="latest") as f:
d = f.create_dataset("x", shape=(400,), chunks=(1,), dtype="i4"{shape_arg})
d[...] = np.arange(400, dtype="i4")
"#
));
let clean = std::fs::read(&path).unwrap();
assert_eq!(
File::open(&path)
.unwrap()
.dataset("x")
.unwrap()
.read_i32()
.unwrap()
.len(),
400,
"{name}: the intact file must read"
);
// Flip a low bit of a chunk address inside a data block. Structurally
// everything still parses — the index still has the right shape and
// the address still lands inside the file — so nothing but the
// checksum can notice. Without it the read succeeds and hands back
// whatever bytes now sit at that address.
let sig: &[u8] = if name == "fixed" { b"FADB" } else { b"EADB" };
let block = clean
.windows(4)
.position(|w| w == sig)
.unwrap_or_else(|| panic!("{name}: no data block in the fixture"));
// Past the prefix (signature, version, client id, header address, and
// for the Extensible Array a block offset), into the first address.
let at = block + 4 + 1 + 1 + 8 + if name == "fixed" { 0 } else { 4 } + 1;
let mut damaged = clean.clone();
damaged[at] ^= 0x10;
let damaged_path = dir.path().join(format!("{name}_damaged.h5"));
std::fs::write(&damaged_path, &damaged).unwrap();
let result = File::open(&damaged_path)
.unwrap()
.dataset("x")
.and_then(|d| d.read_i32());
assert!(
result.is_err(),
"{name}: corruption produced data instead of an error"
);
}
}
+5
View File
@@ -364,6 +364,11 @@ cargo install --path crates/clawhdf5-cli
clawhdf5 --path agent.h5 create --agent-id my-agent --dim 384 --wal
```
Add `--quantized-index` to store the vector index's copy of the embeddings as
int8. That roughly halves a loaded store's memory at about 13% fewer queries
per second, with recall unchanged — the query path re-scores candidates
against the exact embeddings. The setting is recorded in the file.
Output:
```json
{
+81
View File
@@ -146,3 +146,84 @@ 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.
---
## Python interop suites skip silently when no interpreter has h5py
**Status:** fixed on `main` in `a29c1b2` (2026-09-19).
On a system where `python3` is a PEP 668 "externally managed" interpreter,
h5py cannot be installed into it at all, and every interop suite — the h5py
writer round-trips, the facade suite, netCDF4, and the reference files —
returned `false` from its availability probe and skipped without failing. CI
reported `SKIP` and a green run. This is the same class of gap that let the
compound-datatype v5 bug above reach a release.
The probes now read `CLAWHDF5_PYTHON`, and `scripts/ci-test.sh` picks up
`.venv/bin/python` automatically. To restore the coverage on a fresh checkout:
```bash
python3 -m venv .venv && .venv/bin/pip install h5py numpy netCDF4
```
Set `CLAWHDF5_REQUIRE_INTEROP=1` in any automated runner so a missing
interpreter is a failure rather than a skip.
---
## Crafted B-tree v2 structures crash or exhaust the reader
**Status:** fixed on `main` (2026-09-20), after v2.6.0. **Every release up to
and including v2.6.0 is affected.**
B-tree v2 traversal (`clawhdf5-format`, `btree_v2::collect_btree_v2_records`)
recursed one frame per level with the depth taken from the file, and followed
child addresses without checking whether they were shared. Two consequences
for anyone reading untrusted files:
- A node that is its own child, under a header claiming 65 535 levels, overflows
the stack and aborts the process. The file is under 100 bytes.
- Levels whose children all point at one node below make the traversal visit it
fan-out^depth times: ~30 million records from ~5 KB, and memory exhaustion one
level deeper.
B-tree v2 backs dense attribute storage, v2 groups, shared object header
messages and chunk indexes, so opening an object that uses any of them is
enough. Both are now errors: depth is capped at 64, and traversal stops once it
has produced more records than the file could physically hold.
---
## Extensible Array chunk indexes read back wrong data past the inline elements
**Status:** fixed on `main` (2026-09-20), after v2.6.0. **Every release up to
and including v2.6.0 is affected.**
A dataset created with exactly one unlimited dimension (`maxshape=(None, ...)`,
the usual append-only/resizable case) is indexed by an Extensible Array. Its
index block holds the first `idx_blk_elmts` chunk entries inline — 4 by
default — and everything after that lives in data blocks and super blocks whose
layout `clawhdf5-format` computed incorrectly.
Consequences, by dataset size (1 chunk per element):
| chunks | result before the fix |
|---|---|
| <= 36 | correct (inline, plus two data blocks that happened to line up) |
| 37 | 1 element wrong |
| 400 | 364 elements wrong |
| >= ~1000 | `invalid Extensible Array data block signature` |
The dangerous case is the middle one: values were returned from the wrong
chunks rather than an error being raised. Any reader that accepted the data at
face value saw plausible but incorrect numbers.
The root causes were the super block sizing formulas (`ndblks` and
`dblk_nelmts` each double every *other* level, a half-step apart), a missing
block-offset field in the super block, and a page-init bitmap read from the
wrong structure. All four are fixed and covered by interop tests against
HDF5 2.0 at sizes that cross each boundary, including paged data blocks.
Files written by this crate are unaffected — this was purely a read-path bug.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@redclaw/clawhdf5",
"version": "2.5.0",
"version": "2.7.0",
"description": "Node.js bindings for clawhdf5 — HDF5-backed agent memory with hippocampal consolidation",
"main": "index.js",
"types": "index.d.ts",
+15 -2
View File
@@ -20,6 +20,13 @@
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Interop suites drive a Python interpreter. On a PEP 668 "externally managed"
# system h5py can only live in a virtualenv, so pick one up here — before any
# test step, since the non-ignored interop suites read the same variable.
if [ -z "${CLAWHDF5_PYTHON:-}" ] && [ -x "$SCRIPT_DIR/../.venv/bin/python" ]; then
export CLAWHDF5_PYTHON="$SCRIPT_DIR/../.venv/bin/python"
fi
PASS=0
FAIL=0
STEPS=()
@@ -85,12 +92,18 @@ run_step "cargo test (ann parallel)" cargo test \
# 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
# On a PEP 668 "externally managed" system h5py can only live in a
# virtualenv, so honour CLAWHDF5_PYTHON (and a local .venv) rather than
# skipping — the tests read the same variable.
PYTHON="${CLAWHDF5_PYTHON:-python3}"
if "$PYTHON" -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"
echo "==> [h5py interop] SKIPPED: no h5py in $PYTHON"
echo " (set CLAWHDF5_PYTHON=/path/to/venv/bin/python, or create .venv;"
echo " CLAWHDF5_REQUIRE_INTEROP=1 makes this a failure instead)"
STEPS+=("SKIP: h5py interop (format, ignored tests)")
fi