Author SHA1 Message Date
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
osobhandClaude Opus 5 4c60398b30 Merge release/v2.5.0
CI / test (push) Failing after 3s
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 18:25:26 -07:00
osobhandClaude Opus 5 7155409202 chore(release): v2.5.0
Bump all workspace crates, the node package and pyproject to 2.5.0, fold the
two unreleased sections together and add upgrade notes for the behaviour
changes.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 18:22:56 -07:00
osobhandClaude Opus 5 64d9c5f171 Merge feat/bm25-tokenizer: optional keyword stemming, measured and left off by default
CI / test (push) Failing after 2s
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 18:22:27 -07:00
osobhandClaude Opus 5 84a39ef3c5 feat(agent): optional keyword stemming, measured and left off by default
The keyword stage had no stemming, so "training" and "trains" were unrelated
terms. bm25::TokenFilter::Stemmed strips common English inflections (plurals,
-ing/-ed, with consonant un-doubling) from documents and queries alike;
BM25Index::build_with and HDF5Memory::set_token_filter select it, and the
index records which filter built it so a stale one is rebuilt rather than
mixed.

Measured over the full LongMemEval haystack (500 questions, real MiniLM
embeddings) rather than adopted on principle — and it is a trade, not a win:

  BM25 only         Hit@1 53.8%  Hit@5 75.0%  Hit@10 81.6%  MRR 0.6320
  BM25 stemmed      Hit@1 52.0%  Hit@5 77.8%  Hit@10 84.0%  MRR 0.6320
  Hybrid 0.4/0.6    Hit@1 51.6%  Hit@5 81.4%  Hit@10 87.8%  MRR 0.6430
  Hybrid stemmed    Hit@1 50.2%  Hit@5 81.4%  Hit@10 88.2%  MRR 0.6394

Conflation buys depth and costs the top rank: on BM25 alone MRR is unchanged
to four decimal places, the deeper gains exactly offsetting the rank-1 loss.
On the shipping hybrid configuration the vector stage already supplies most of
that recall, so the trade is narrower and slightly negative. Default stays
Plain; Stemmed is there for callers who want Hit@5/@10 over rank-1 precision.

The stemmer is deliberately conservative — it only strips inflections, and
only when the stem stays long enough to be meaningful, since an aggressive one
also conflates unrelated words. Tests pin both the pairs that must meet and
the pairs that must not.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 18:20:19 -07:00
osobhandClaude Opus 5 55ed87d2e8 Merge feat/retrieval-quality: tuned fusion defaults, RRF measured, query-expansion fixes
CI / test (push) Failing after 2s
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 17:40:46 -07:00
osobhandClaude Opus 5 6531158d9f fix(agent): query expansion panicked on non-ASCII and rewrote text inside words
Probing what QueryExpander::expand actually produces for LongMemEval questions
turned up two defects in the same helper.

`replace_word_case_insensitive` did a plain substring replace despite its
name, so acronym expansion fired inside ordinary words: "training" became
"trArtificial Intelligencening" ("ai") and "programming" became
"Pull Requestogramming" ("pr"). Nearly every acronym expansion of prose was
corrupt. Matching now requires word boundaries at both ends; real acronyms
(API, database) still expand in both directions.

The same helper searched `text.to_lowercase()` and then sliced `text` with the
offsets it found. That holds only while lowercasing preserves byte length, and
it does not — Turkish 'İ' is 2 bytes and lowercases to 3. Offsets after such a
character drifted, so output was silently corrupted ("İstanbul AI trip" lost a
character) or the slice landed inside a character or past the end and
panicked: `expand("İ AI")` was enough, from a plain query string. Matching now
walks the original string, comparing case-insensitively char by char, so
offsets are always valid.

Regression tests cover both, plus whole-word matching at string edges. The
morphological rules remain crude ("during" -> "dured"); that is a quality
limit, not a correctness bug, and is now documented as a reason to measure
before enabling expansion on a retrieval path.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 17:25:20 -07:00
osobhandClaude Opus 5 aa92fef7bb bench: measure RRF against the weighted sum — weighted wins, RRF not adopted
Reciprocal rank fusion was implemented but reachable only as a free function
over a linear scan, so its merits had never been tested. With both running
over the same HNSW + BM25 candidates on the full LongMemEval haystack (500
questions, real MiniLM embeddings):

  weighted 0.4/0.6   turn Hit@1 51.6%  Hit@5 81.4%  MRR 0.6430
  RRF k=60           turn Hit@1 45.0%  Hit@5 78.8%  MRR 0.5967

RRF lands almost exactly where the old 0.7/0.3 weighting did, and for the same
reason: it combines the stages by rank with equal influence, but on this corpus
BM25 alone beats the vector stage by 17.8pp at Hit@1, so treating them as peers
costs rank-1 accuracy. RRF's advantage is robustness when the stages' scores
are not comparable and there is nothing to tune against; here there is, so the
weighted sum stays the default. Recorded in BENCHMARKS.md with the reasoning,
including that this is a property of the corpus rather than a defect in RRF.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 17:18:09 -07:00
osobhandClaude Opus 5 29baabbed2 feat(agent): selectable fusion; adopt the measured 0.4/0.6 default weights
BENCHMARKS.md has recorded since the weight sweep that the 0.7/0.3 default is
strictly dominated by 0.4/0.6 over the full LongMemEval haystack, but the
shipping code never adopted it: unified_search and the OpenClaw backend both
passed 0.7/0.3. Re-running the sweep here (500 questions, real MiniLM
embeddings on a GPU) reproduces it — turn-level Hit@1 51.6% vs 44.2%, Hit@5
81.4% vs 79.2%, Hit@10 87.8% vs 85.8%, MRR 0.6430 vs 0.5856 — so both now use
hybrid::DEFAULT_FUSION, which is that operating point and carries the
reasoning. A unit test pins it.

Fusion is also selectable now. hybrid::Fusion is either Weighted { vector,
keyword } or Rrf { k }; hybrid::fuse applies either to one candidate list per
stage, and merge_vector_keyword / hybrid_search delegate to it, so the public
API is unchanged. New HDF5Memory::hybrid_search_with and
hybrid::hybrid_search_fused take a Fusion. Reciprocal rank fusion was
implemented but reachable only as a free function over a linear scan, so it
had never been compared with the weighted sum on equal terms; it is now a mode
in the LongMemEval bench (measurement to follow).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 16:57:51 -07:00
osobhandClaude Fable 5.1 b23946e62d Merge feat/hdf5-read-path: partial reads, B-tree v2 chunk index, faster full reads, auto-chunking, H5T_STD_REF
CI / test (push) Failing after 2s
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 16:17:26 -07:00
osobhandClaude Fable 5.1 52cfcf20b2 feat(format): parse H5T_STD_REF references and decode object references
HDF5 1.12 revised the reference datatype (class 7) in datatype message version
4: reference types 2-4 are the new H5T_STD_REF object / dataset-region /
attribute references. Datatype::parse rejected them with
InvalidReferenceType, so any dataset of that type was unreadable.

h5py cannot write this type, which is why it had never been tested. A real
file was produced by calling the libhdf5 bundled in the h5py wheel through
ctypes (H5T_STD_REF_g, H5Rcreate_object, H5Dwrite); the 2 KB result is
committed as tests/fixtures/std_ref_hdf5_2_0.h5 with its generator,
gen_std_ref.py.

- ReferenceType gains Object2, DatasetRegion2 and Attribute, accepted only
  from datatype version 4.
- read_object_references decodes Object2 elements: type(1) flags(1)
  token_size(1) token, zero-padded to the element size; the token is the
  target's object header address. A null reference decodes to the undefined
  address; an external reference, a wrong type byte or a token that doesn't
  fit is an error.

The fixture test follows both references and checks they resolve to the
objects they were created from.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 14:24:04 -07:00
osobhandClaude Fable 5.1 05c665a898 feat(format): choose chunk dimensions automatically for large datasets
Requesting a filter without chunk dimensions made the whole dataset a single
chunk. Any read, even one row, then decompresses everything, and a large
dataset cannot be decoded in parallel — which also made the new partial reads
pointless for such files.

auto_chunk_dims keeps datasets up to 1 MiB as one chunk (unchanged behaviour)
and splits larger ones by halving the dimensions in turn, so chunks keep
roughly the dataset's proportions, until a chunk is at most 1 MiB — h5py's
approach. An empty (unlimited, unwritten) dimension is treated as 1024. The
writer passes the element size through resolve_chunk_dims_for; the old
resolve_chunk_dims assumes 8-byte elements. Explicit with_chunks always wins.

Interop test: h5py reads an auto-chunked 13 MB deflate dataset, sees chunks
between 128 KiB and 1 MiB, and a small dataset still has one chunk.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 14:21:04 -07:00
osobhandClaude Fable 5.1 b36c6ec2af style(format): as_chunks_mut in the un-shuffle interleave (clippy)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 14:06:36 -07:00
osobhandClaude Fable 5.1 f3d63dbdcd perf(format): un-shuffle by interleaving fixed-width byte planes
shuffle_decompress — on the read path of every compressed dataset, since
shuffle is applied automatically before compression — was the naive
`result[i * es + j] = data[j * n + i]`: a multiply and two bounds checks per
byte. It now interleaves fixed-width arrays of byte planes for element sizes
2/4/8/16 (bounds checks hoisted, vectorisable), with a chunked generic
fallback. The write-side shuffle was already optimised; this was the asymmetry
the survey flagged. Modest wall-clock effect now that decode is parallel
(chunked+deflate full read ~70 -> ~66 ms). Round-trip test over element sizes
1-24 and several lengths.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 14:06:10 -07:00
osobhandClaude Fable 5.1 0addf328bc perf(format): parallel cached decode and fewer copies on full reads
Same-moment A/B on a 64 MB f64 dataset: chunked+deflate 110 -> 69 ms, chunked
72 -> 60 ms, contiguous 56 -> 30 ms.

- read_chunked_data_cached — the path the facade uses — decompressed chunks
  one at a time; only the uncached reader was parallel. Cache misses are now
  decoded in bounded batches (128), in parallel with the `parallel` feature.
- Every chunk was pushed into the 16 MiB chunk cache, which a larger dataset
  just churns (insert, evict moments later). Chunks are cached only when the
  whole dataset fits (new ChunkCache::max_bytes).
- Unfiltered chunks went file -> Vec -> aligned cache buffer -> output. They
  are copied straight from the file bytes.
- The facade's typed reads convert a contiguous dataset straight from the
  borrowed file bytes instead of copying it into a Vec first.
- The native little-endian fast paths allocated vec![0; n] and then overwrote
  it; they now fill an uninitialised buffer in one copy (native_le_to_vec).
  alloc_output requests zeroed memory from the allocator instead of reserving
  and filling.

The unit test that expected unfiltered chunks to land in the decompressed
cache now asserts the new design (index reused, cache not involved).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 14:05:15 -07:00
osobhandClaude Fable 5.1 d668e45ab5 feat(format): read chunked datasets indexed by a version-2 B-tree
With libver='latest', a chunked dataset with two or more unlimited dimensions
indexes its chunks with a v2 B-tree (layout v4, index type 5). Reading one
failed with "unsupported chunked layout version=4, index_type=Some(5)".

read_btree_v2_chunks decodes record types 10 (address + scaled offsets) and 11
(address, stored size, filter mask, scaled offsets). The width of the
stored-size field is taken from the record size the tree header declares
rather than re-deriving the library's formula. Scaled offsets are multiplied
back by the chunk dimensions with overflow checks.

The chunk-index dispatch existed four times (uncached, cached, sweep and
indexed readers). The three copies outside list_chunks now call it, so every
read path — and fill-value handling and partial reads — supports every index
type from one place.

h5py interop test: plain, gzip+shuffle, a 2500-chunk tree with internal nodes,
a sparse dataset with a fill value, and a strided hyperslab.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 13:59:18 -07:00
osobhandClaude Fable 5.1 c6a7bbfc67 perf(format): partial selection reads; out-of-range selections are errors
read_raw_data_selection computed which chunks a selection intersects, threw
the answer away, decoded the entire dataset and picked elements out of it —
for contiguous layouts too. A 64x64 window of a 64 MB deflate dataset cost
105 ms, about half a full read; every selection cost the same whatever its
size.

New partial_read module: materialise only the selection's bounding box — the
overlapping rows of a contiguous dataset (straight from the file bytes) or the
overlapping chunks (only those are decompressed) — then run the existing
extractor over that buffer with the selection translated to the box origin, so
extraction semantics are exactly the full-read ones. It declines (falling back
to the old path) for All/None, compact/virtual/storage-less layouts, and boxes
covering more than half the dataset. That window now takes 0.39 ms, one row
2.7 ms, one column 5.2 ms.

Selections are validated against the dataset shape first. They were not: a
hyperslab past an edge came back padded with zeros and a point with an
out-of-range column wrapped into the next row, returning the wrong element
with no error. Now FormatError::SelectionOutOfBounds (also rank mismatch and
overlapping blocks); the facade's fill-aware path validates too.

Tests: equivalence against a reference extraction from a full read over 60
random hyperslabs/point lists per layout (contiguous, chunked, deflate) for
ranks 1-3. New read_harness bench binary with before/after in BENCHMARKS.md.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 13:57:14 -07:00
osobhandClaude Fable 5.1 3027380979 Merge fix/hnsw-deleted-topk: live-only search results, batched parallel index build
CI / test (push) Failing after 2s
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 13:52:24 -07:00
osobhandClaude Fable 5.1 f507803ec1 feat(agent): build the vector index in parallel by default
`parallel` joins the agent's default features, so the HNSW bulk build uses the
thread pool: cold index build at 10K records 1152 -> ~380 ms in a same-moment
A/B (the graph is identical either way). Nothing else on the measured paths
changes — ingest, checkpoint, open and steady-state query times are the same
with the feature on or off. Adds rayon to the default dependency set; opt out
with `--no-default-features --features float16,hnsw`.

Harness: `--e2e-only` runs the end-to-end section without the index
benchmarks. Note for anyone comparing numbers: this machine's absolute timings
drifted ~1.5x over a long session, so only same-moment A/B runs are
comparable.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 13:52:23 -07:00
osobhandClaude Fable 5.1 8803d0754b ci: lint and test clawhdf5-ann with its parallel feature
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 13:44:18 -07:00
osobhandClaude Fable 5.1 42c3872ec9 style(ann): iterate levels directly in the batch entry-point update (clippy)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 13:44:09 -07:00
osobhandClaude Fable 5.1 c19199f3eb perf(ann): batched bulk build, parallel with the parallel feature
Profiling the build showed 90% of all distance evaluations are in back-link
pruning (40.8M of 44.9M at 10K): every overflow re-runs the diversity
heuristic pairwise over ~max_conn candidates.

The bulk build now inserts in batches: plan every node's neighbours against
the graph as it stood when the batch began (read-only, so plans are
independent), link, then prune each overflowing list once. A node gaining
several back-links in a batch is pruned once rather than once per link, so
this is faster even single-threaded (10K: 1676 -> 1074 ms). With `parallel`,
planning and pruning use rayon (10K: 388 ms; 100K: ~21 s -> 5.9 s on 16
cores). Batches start at one node and are capped at 1/16 of the linked graph
and 512 nodes; a node that raises the top layer gets a batch to itself. The
result is deterministic and identical with or without the feature (one code
path; test compares two builds byte for byte).

Parallelising within a single insert was tried first: 1.45x on 16 cores, tasks
too small. Incremental insert() stays sequential.

Recall on clustered data is unchanged or slightly better; uniform random data
dips slightly (10K, ef=64: 0.474 -> 0.444).

clawhdf5-agent's `parallel` feature now passes through to the index.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 13:43:49 -07:00
osobhandClaude Fable 5.1 41db450c92 fix(ann): deletions near the query no longer shrink search results
search() collected ef candidates, then filtered out soft-deleted nodes, then
took k. When the records nearest a query had been deleted, every candidate was
a tombstone and the search returned fewer than k results — 39 of 40 queries in
the new test, which deletes each query's 40 nearest neighbours.

search_layer takes an optional skip mask: a skipped node is still pushed onto
the candidate queue (a tombstone is a valid waypoint) but never into the
result heap, so the ef result slots hold live nodes only. Build and insert
pass no mask. Recall and speed without deletions are unchanged.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 05:36:22 -07:00
osobhandClaude Fable 5.1 a8ab9ca054 Merge release/v2.2.0: native complex datatype fix, v2.2.0 release, repository URL
CI / test (push) Failing after 11s
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-18 20:58:34 -07:00
osobhandClaude Fable 5.1 2053b69f07 chore(release): v2.2.0, point repository URLs at git.redclaw.dev
Bump all workspace crates, the node package and pyproject to 2.2.0 and
finalize the changelog. The repository URL in every manifest pointed at a
GitHub location that does not resolve; use the real origin.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-18 20:58:29 -07:00
osobhandClaude Fable 5.1 b55b7dbac5 fix(format): parse HDF5 2.0 native complex datatypes (class 11)
Class 11 (datatype version 5) properties are a single base floating-point
datatype message, not a compound-style member list. The old parser read the
base type's bytes as member names, yielding a garbage datatype, and failed
with UnexpectedEof when a complex type was nested in a compound.

Parse the base type and surface the type as the equivalent {r, i} compound
(the shape h5py writes for numpy complex dtypes), with a size check against
the base type. Covered by byte-level tests taken from HDF5 2.0 output and an
h5py end-to-end test (writer_h5py_tests is now 27/27 against HDF5 2.0.0).

Found while validating a user report of InvalidDatatypeVersion
{ class: 6, version: 5 } against v2.1.0 (already fixed on main in a13ff51,
never released). Add docs/known-issues.md recording that report, this bug,
the open reference-v4 gap and a gpu_tests parallel-run hang; credit the
reporter in the changelog.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-18 20:58:29 -07:00
osobh 48c745a960 Merge PR #2: performance, security and provenance hardening + two audit fixes
CI / test (push) Canceled after 0s
ann/io/migrate/agent work from three agent missions, an independent audit, and the two defects it found: the cosine near-zero guard weakened during the SIMD migration, and WAL appends after a torn tail being silently unreplayable. 52 suites green; both fixes proven by negative control.
2026-08-17 14:22:13 +00:00
clawhdf5 committer agentandClaude Sonnet 5 377c8b6f17 fix(accel): restore f32::EPSILON near-zero-denom guard in cosine_similarity
CI / test (pull_request) Canceled after 0s
The SIMD migration weakened the near-zero-norm guard in all four
clawhdf5-accel cosine_similarity backends (scalar/avx2/avx512/neon)
from `denom < f32::EPSILON` to `denom == 0.0`. Vectors with a tiny
but nonzero norm (denom in (0, 1.19e-7)) fell through to dot/denom
and scored as identical instead of maximally dissimilar, diverging
from the pre-SIMD scalar loop's documented fallback behavior.

Restores the epsilon threshold in all four backends so
`1.0 - cosine_similarity(...)` in hnsw.rs::compute_distance
reproduces the old fallback exactly. Adds regression tests in
clawhdf5-accel and clawhdf5-ann locking in the near-zero-norm case.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-17 13:28:43 +00:00
Omar SobhandClaude Opus 5 07b7301ded merge: combine the v3 (ann/io/migrate) and v6 (agent/format) mission work
Two missions found largely disjoint ground — v3 in clawhdf5-ann, -io and
-migrate, v6 in -agent and -format — so this merge is mostly additive.

One genuine collision: BOTH runs independently implemented
`Dataset::verify_provenance` in clawhdf5/src/reader.rs, and git kept both,
producing `E0592 duplicate definitions`. They were functionally identical
apart from `self.file.data.as_bytes()` (v3) vs `self.file.as_bytes()` (v6).
Kept v6's — it is the variant that compiles against the current tree and
passed 52 suites, and its doc comment is the more honest one, stating both the
full-read cost and that an unkeyed hash stored beside its data is not a
tamper-evidence guarantee.

Verified present after the merge:
  P1  clawhdf5-ann now depends on clawhdf5-accel; compute_distance calls
      l2_distance / cosine_similarity instead of a scalar loop
  P2  AsyncFileReader caches its handle and length behind a mutex
  PR1 clawhdf5-migrate writes SHINES provenance, and validate checks it
  v6  WAL torn-tail truncation, char-boundary truncate, agent hardening

52 suites pass, 0 failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 06:19:23 -07:00
Omar Sobh d3c65ccb58 Merge remote-tracking branch 'origin/clawmates/mission-01a00c41-421200ee' into verify/v3-plus-v6
# Conflicts:
#	crates/clawhdf5/Cargo.toml
2026-08-17 06:15:19 -07:00
Omar SobhandClaude Opus 5 c137302f04 fix(agent): a WAL append after a torn tail was silently unreplayable
`WalFile::open` scanned the chained entries to resume the CRC chain, then
seeked to END OF FILE to append. After a crash mid-append — the ordinary way a
WAL ends up damaged — that puts the next entry BEHIND the torn bytes:

    [1..N verified][torn tail][N+1, chained to N]

`read_chained_entries` stops at the torn tail, so N+1 is unreachable forever
even though its `append` returned Ok and synced. Silent loss of an acknowledged
write, in the one situation a WAL exists for.

`read_chained_entries` now also returns the byte length of the verified prefix,
and `open` truncates to it and appends there. The torn tail was never
acknowledged to any caller, so discarding it loses nothing, and the file offset
then matches the `running_crc` the chain continues from.

Verified by negative control: with the previous `seek(End(0))` the new test
fails with "got 1 entr(y/ies) — the post-crash write was silently lost".

Introduced by neither this branch nor the chaining work — v2 seeked to EOF too.
What changed is that `open` now scans and therefore KNOWS where the verified
prefix ends, which is what makes the fix a two-line consequence of information
already in hand.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 21:34:01 -07:00
ClawHDF5 Coding Agent f23363cde5 fix(agent): update e2e_tests.rs call site for search_with_metrics's new vectors_flat parameter
Missed in the INT-16 flat-embedding-buffer commit — this integration
test call site lives under tests/, outside the src/ tree that was
grepped for callers.
2026-08-17 01:02:09 +00:00
ClawHDF5 Coding Agent 3a30327f35 security(agent): chain WAL entry CRCs and restrict the legacy no-CRC reader
Two related gaps in the WAL format, both closed:

1. Each entry's CRC32 covered only its own bytes, with no sequence number
   or chaining — entries could be reordered, duplicated, or spliced (e.g.
   a Tombstone moved before/after its target Save) while every individual
   entry still passed its own CRC check, silently changing replayed cache
   state. Bump to WAL_VERSION 3: each entry's CRC32 trailer is now computed
   over its own bytes chained with the previous entry's stored CRC
   (crc32(entry_bytes ++ prev_crc)), seeded at 0 after a truncation. Moving,
   duplicating, or reordering an entry breaks the chain at that point, and
   replay stops there — same handling as a bit-flip or truncation. The
   previous per-entry-CRC-only format becomes WAL_VERSION_CRC_UNCHAINED (2)
   and remains fully readable (not restricted, since it still verifies each
   entry); WalFile::open migrates it to v3 by recreating the file fresh,
   same as the existing v1 migration.

   WalFile::open() on an existing v3 file scans it once to resume the CRC
   chain correctly for further appends — required because a process
   restart without an intervening flush reopens the same (non-truncated)
   WAL and keeps appending to it, so new entries must chain against the
   real last entry already on disk, not restart from 0.

2. WAL_VERSION_LEGACY_NO_CRC (v1, no integrity verification at all) was
   reachable through the public WalFile::read_entries — a version byte
   flipped from 2/3 down to 1 silently downgraded every entry to the
   fully-unverified pre-hardening parser for any caller, not just the
   one-time migration path. Split into WalFile::read_entries (rejects v1
   with a typed error; still reads v2/v3) and the pub(crate)
   read_entries_for_migration (accepts v1 too), used exclusively by
   HDF5Memory::open's migration flow.

INT-09
2026-08-17 01:01:29 +00:00
ClawHDF5 Coding Agent 5db1008eb7 security(format): wire provenance verify_dataset into the clawhdf5 read path
verify_dataset existed and was tested, but was only ever called from
clawhdf5-format's own test files — no reader path in clawhdf5-io or the
clawhdf5 facade called it, so a corrupted dataset was silently readable
even though the write-side SHA-256 hash machinery (gated on the
provenance feature) had already written what it needed to detect that.

Add Dataset::verify_provenance() to the clawhdf5 facade, gated behind a
new `provenance` feature (on by default, forwarding to
clawhdf5-format/provenance which is already default-on). It surfaces a
typed VerifyResult (Ok/Mismatch/NoHash) via the existing Error type
rather than panicking. Deliberately NOT called automatically on
open()/dataset() — it decodes and hashes the entire dataset, which would
regress every read path (including the zero-copy/mmap ones) if run
unconditionally; callers opt in per dataset where the cost is
acceptable (e.g. a periodic integrity sweep).

Also re-export clawhdf5_format::provenance from the facade crate so
VerifyResult is reachable without depending on clawhdf5-format directly.

INT-08
2026-08-17 00:54:44 +00:00
ClawHDF5 Coding Agent ab283d2759 security(agent): attribute the shared rate window to its top contributor
check_rate_anomaly's 60s window is shared across all sessions/sources —
when it trips, the alert reported only the anonymous aggregate count,
unlike the separate cumulative max_writes_per_session check, which does
name the offending session. A session's write count can never exceed the
window's aggregate count, so whenever the window trips, name the
top-contributing session and source within it in the same alert instead
of adding a second, redundant per-session threshold check.

INT-07
2026-08-17 00:52:21 +00:00
ClawHDF5 Coding Agent 18ac510c29 security(agent): harden anomaly pattern matching against cheap evasion
check_pattern_anomaly did a plain case-folded literal-substring test, so
inserting whitespace, punctuation between letters, or a zero-width/
invisible-formatting character anywhere in a flagged phrase defeated every
one of the 15 injection patterns while the text still displays normally.

Add normalize_for_pattern_match: lowercases, drops control and
invisible-format characters (ZWSP, ZWJ, ZWNJ, bidi marks, BOM, soft
hyphen, word joiner, invisible math operators), drops punctuation
entirely (so split words rejoin instead of just being separated), and
collapses whitespace runs. Apply it to both the chunk and each configured
pattern before matching.

Scope, stated plainly: this does not add Unicode NFKC normalization or
confusable/homoglyph folding (e.g. Cyrillic а standing in for Latin a) —
that needs a per-codepoint confusable table (Unicode's confusables.txt)
beyond what's reasonable to hand-roll correctly, and no such crate is a
dependency of this crate today. A determined attacker using homoglyphs
can still evade these patterns; only the whitespace/punctuation/
zero-width bypasses are closed here.

INT-06
2026-08-17 00:51:01 +00:00
ClawHDF5 Coding Agent 3c7c229e20 security(agent): gate elevated MemorySource construction behind a distinct API
Two related trust-boundary gaps, both closed:

1. ConsolidationEngine::add_memory took a plain `source: MemorySource`
   parameter, so any caller could claim MemorySource::System/Correction —
   which get elevated importance weighting in score_correction — for
   content whose actual origin the caller doesn't control or hasn't
   verified. Split into add_memory(UntrustedSource) for ordinary
   caller-supplied content (User/Tool/Retrieval only, no elevated variant
   exists to claim) and add_trusted_memory(TrustedSource) for content whose
   elevated trust the caller has independently verified (System/
   Correction). Updated the one production consumer outside this crate
   (clawhdf5-bench's consolidation_efficiency benchmark) and all tests.

2. The provenance/anomaly wiring added in the previous commit introduced
   the same pattern: infer_memory_source mapped source_channel == "system"
   or "correction" straight to the elevated MemorySource variants. Since
   MemoryEntry.source_channel is unvalidated caller-supplied text, this let
   a write dodge check_source_anomaly's User-flood detection by simply
   self-labeling source_channel = "system". infer_memory_source now never
   returns System/Correction — only Tool/Retrieval (recognized channel
   names) or User (everything else, the conservative default).

INT-05
2026-08-17 00:49:34 +00:00
ClawHDF5 Coding Agent 2e8414e412 security(agent): wire provenance/anomaly detection into the real save path
ProvenanceStore, WriteAnomalyDetector, and their check_*/verify_integrity
methods had zero callers outside their own module/tests — lib.rs only
declared the modules. The 15 injection-pattern checks, rate limiting, and
content-hash integrity verification described as shipped in ROADMAP.md
Track 5 never executed during normal library usage.

HDF5Memory::save/save_batch/save_or_update now record a MemoryProvenance
entry (content hash, inferred MemorySource, session) for every write, run
check_rate_anomaly/check_pattern_anomaly/check_source_anomaly against it,
and queue any triggered AnomalyAlert for the caller to drain via the new
take_anomaly_alerts(). save_or_update's update path additionally verifies
the existing record's content against its last recorded hash before
overwriting, catching accidental in-session corruption.

Scope notes, stated plainly rather than overclaimed:
- There is no on-disk provenance ledger (see the CLAUDE.md note added
  here) — this is session-scoped bookkeeping, not a disk-integrity
  control. open() starts the store empty; there's no historical hash to
  verify loaded records against, so "verify on load" is implemented as
  "populate the store so subsequent updates in this session are
  checkable" rather than a check against nothing.
- MemorySource is inferred from source_channel via a plain string match
  (infer_memory_source) — a heuristic for bookkeeping, not the gated
  trust-boundary construction INT-05 asks for. That remains open.
- Alerts never block a save; this only makes detection real instead of
  dead code. Whether writes should ever be blocked is a policy decision
  left to the caller/a follow-up item.

INT-04
2026-08-17 00:45:04 +00:00
ClawHDF5 Coding Agent 45a38ba260 perf(agent): maintain a persistent flat embedding buffer for BLAS/Accelerate search
blas_cosine_batch and accelerate_cosine_batch_vecs re-flattened the
entire Vec<Vec<f32>> corpus into a fresh Vec<f32> on every single
query before running the batch matmul — an O(N·dim) copy paid per
query when fast-math/accelerate/openblas is enabled, even though a
flat fast-path (blas_cosine_batch_flat / accelerate_cosine_batch)
already existed for pre-flattened input.

Add MemoryCache::embeddings_flat, a contiguous [N × embedding_dim]
buffer maintained incrementally in push/update/compact (O(1) amortized
append, O(dim) in-place overwrite, O(n) rebuild only on compact/bulk
load). schema.rs's direct-push load path calls the new rebuild_flat()
explicitly. flat_embeddings() now just clones the already-maintained
buffer instead of rebuilding it.

Thread the flat buffer through strategy::search_with_metrics as a new
vectors_flat parameter, used only by the Blas/Accelerate arms (now
calling the *_flat variants); other strategies are unaffected. No
current caller wires search_with_metrics into the production query
path yet (only its own tests exercise it) — this fixes the identified
per-query re-flatten and makes the flat buffer available for whenever
that wiring lands.

INT-16
2026-08-17 00:41:23 +00:00
ClawHDF5 Coding Agent 1efd82c841 perf(agent): add adjacency index for knowledge graph traversal
bfs_neighbors scanned the entire relations list per queue-popped node
(O(V·E) instead of O(V+E)) and did an O(n) linear find over entities
per discovered neighbor; spreading_activation scanned the entire
relations list per active node per step (O(max_steps·active·E)). Add
a per-call AdjacencyIndex (entity-id -> entities-index map, entity-id
-> touching-relation-indices map) built once in O(V+E) and shared by
both traversal loops, replacing the linear scans with O(degree) /
O(1) lookups.

Built fresh per call rather than cached on KnowledgeCache: entities
and relations are plain pub Vecs pushed to directly by schema.rs's
load path (bypassing add_entity/add_relation), so a persisted index
would need extra staleness bookkeeping. get_relations_from/
get_relations_to are left as plain O(E) filters — they're single-node
lookups already optimal for a standalone call; wrapping them in an
O(V+E) index build would be a regression, not a fix, and nothing in
the codebase currently calls them in a per-node loop.

Added a self-loop regression test: the index must visit a src==tgt
relation exactly once, matching the original flat-iteration behavior.

INT-13
2026-08-17 00:34:01 +00:00
ClawHDF5 Coding Agent 4051d5c16e perf(agent): cache lowercased entity names and early-exit in resolve_or_create
resolve_or_create allocated a fresh lowercased String for every entity
on every call (this runs per extracted mention during entity/relation
extraction) and never short-circuited on an exact dist == 0 match,
scoring every remaining entity regardless. Add Entity::name_lower,
computed once at construction (add_entity, and schema.rs's direct-push
load path), and break out of the scan as soon as an exact match is
found.

INT-12
2026-08-17 00:32:20 +00:00
ClawHDF5 Coding Agent 934d053f92 perf(agent): replace BM25 WAND top-k re-sort with a min-heap
top_k_scores.sort_by(...) ran over the full k-sized buffer for every
matching document that beat the running threshold (twice in the full
branch), plus another full sort on first reaching k results —
O(m·k log k) for m matching documents. Replace the Vec<f32> buffer
with a BinaryHeap<Reverse<HeapScore>> min-heap of size k, giving
O(m log k). Existing wand_returns_same_results_as_exhaustive test
confirms results are unchanged.

INT-11
2026-08-17 00:29:44 +00:00
ClawHDF5 Coding Agent 603fcf8757 perf(agent): use HashSet for eviction ID membership checks in consolidation
records.retain(|r| !evict_ids.contains(&r.id)) called Vec::contains
(linear scan) for every record against evict_ids, giving O(n·m) cost
on both Working- and Episodic-tier eviction every consolidation tick.
Build evict_ids as a HashSet for O(1) membership checks.

INT-15
2026-08-17 00:29:03 +00:00
ClawHDF5 Coding Agent d787ac04c8 perf(agent): avoid cloning working-tier records in consolidation add_memory
score_surprise only reads r.embedding by reference, so cloning every
Working-tier record's full chunk text + embedding Vec<f32> on every
add_memory call was wasted work, discarded immediately after use.
Collect Vec<&MemoryRecord> instead and change score_surprise's
signature to take &[&MemoryRecord].

INT-14
2026-08-17 00:28:55 +00:00
ClawHDF5 Coding Agent 55c3737130 fix(migrate): truncate on a char boundary in validate::truncate
truncate() sliced source.chunk (arbitrary UTF-8 from the source SQLite
database) at a raw byte offset. A multi-byte character straddling byte
40 panics with "byte index 40 is not a char boundary" instead of
producing the mismatch diagnostic the code exists to report — and this
is the default validate_hdf5 path, not test-only. Cut on the nearest
char boundary at or before 40 instead.

INT-10
2026-08-17 00:27:48 +00:00
ClawHDF5 Coding Agent 7314971fe7 security(format): add recursion-depth guard to Datatype::parse
Datatype::parse recurses into itself for Compound/Enumeration/
VariableLength/Array/Complex member and base types with no depth
counter. A message data size capped at u16::MAX (65535 bytes) allows
~8000 levels of nesting in a crafted file, enough to blow the stack —
worse on the project's no_std/embedded targets with only a few KB of
stack. Thread a depth counter through a new parse_with_depth, mirroring
object_header.rs's continuation-depth guard, and reject past 64 levels
with FormatError::NestingDepthExceeded. The public Datatype::parse
signature is unchanged.

INT-03
2026-08-17 00:27:16 +00:00
ClawHDF5 Coding Agent 864faf3656 security(format): fix unchecked-addition bounds check in symbol_table.rs
SymbolTableNode::parse used raw offset+8 arithmetic that can overflow
on a crafted v1-group B-tree leaf with a near-u64::MAX SNOD child
pointer (group_v1.rs passes such offsets through unchecked). Switch to
checked_add, matching read_offset in the same file. Also harden the
entries_start + num_symbols*entry_size computation with checked_add
for consistency, even though num_symbols being u16 already bounds
that multiply. Add regression tests.

INT-02
2026-08-17 00:26:13 +00:00
ClawHDF5 Coding Agent 73bc067fea security(format): fix unchecked-addition bounds checks in fixed_array/extensible_array
Six sites used raw `offset + N > file_data.len()` arithmetic that can
overflow on a crafted file with an address field near u64::MAX,
bypassing the bounds check before the next slice op panics. Switch to
the checked_add-based ensure_len pattern already used by local_heap.rs
and other parsers in this crate. Add regression tests for offsets near
usize::MAX in both files.

INT-01
2026-08-17 00:25:38 +00:00
Omar Sobh 122849b5a9 research: add implementation brief with 17 numbered INT items
Covers performance, security, and provenance findings across
clawhdf5-format, clawhdf5-migrate, and memory/query crates. Each item
lists target file, problem, and proposed change for the coding phase.
2026-08-17 00:22:21 +00:00
Omar Sobh b08df7b628 clawmates: phase work
Mission: 01a00c41-bac0-7eb3-a8c8-8b7044f3086d
Phase: 01a00c41-bac2-71e3-a58b-c473421200ee

Committed by the ClawMates delivery pipeline from the agents' working tree. Authored by agents, not by the named committer.
2026-08-16 20:44:28 +00:00
132 changed files with 13738 additions and 2149 deletions
+20
View File
@@ -22,5 +22,25 @@ jobs:
run: rustup component add rustfmt clippy
- name: Install thumbv7em-none-eabihf target
run: rustup target add thumbv7em-none-eabihf
- name: Install Python interop dependencies
# The interop suites used to skip silently when python3/h5py were
# missing, so they never ran in CI. Install them and make a missing
# dependency a failure (CLAWHDF5_REQUIRE_INTEROP below).
run: |
apt-get update
apt-get install -y --no-install-recommends python3 python3-venv
python3 -m venv /opt/interop
/opt/interop/bin/pip install --no-cache-dir h5py numpy netCDF4 xarray
echo "/opt/interop/bin" >> "$GITHUB_PATH"
- name: Show interop library versions
run: /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
+494 -1
View File
@@ -28,6 +28,402 @@
---
## 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. What it costs is
throughput — about 13% of QPS and 16% of build time at 100 000 x 384. So the
setting trades ~13% of query speed for ~36% of the process's memory at equal
recall. It is **off by default**: the right side of that trade depends on
whether the deployment is short of memory or short of CPU.
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.
## Read harness
Produced by `cargo run --release -p clawhdf5-bench --bin read_harness`: a 4096 x
2048 `f64` dataset (64 MB) written three ways, read in full and through four
hyperslab selections, each from a fresh file handle. The last column is the
point: does a selection cost what the *selection* costs?
### Baseline (v2.4.0): every selection decodes the whole dataset
4096 x 2048 f64 (64 MB per dataset), chunks 256 x 256, file 129 MB
| layout | read | selected | time ms | MB/s of selection | vs full read |
|---|---|---:|---:|---:|---:|
| chunked + deflate | full (first) | 64 MB | 181.8 | 352 | |
| chunked + deflate | full (repeat) | 64 MB | 162.1 | 395 | 1.00x |
| chunked + deflate | 64 x 64 window (1 chunk) | 0.03 MB | 104.89 | 0 | 0.577x |
| chunked + deflate | 512 x 512 window (4-9 chunks) | 2.00 MB | 110.26 | 18 | 0.606x |
| chunked + deflate | one row | 0.02 MB | 105.81 | 0 | 0.582x |
| chunked + deflate | one column | 0.03 MB | 108.37 | 0 | 0.596x |
| chunked | full (first) | 64 MB | 97.4 | 657 | |
| chunked | full (repeat) | 64 MB | 86.7 | 738 | 1.00x |
| chunked | 64 x 64 window (1 chunk) | 0.03 MB | 40.97 | 1 | 0.420x |
| chunked | 512 x 512 window (4-9 chunks) | 2.00 MB | 44.66 | 45 | 0.458x |
| chunked | one row | 0.02 MB | 30.88 | 1 | 0.317x |
| chunked | one column | 0.03 MB | 30.27 | 1 | 0.311x |
| contiguous | full (first) | 64 MB | 57.6 | 1112 | |
| contiguous | full (repeat) | 64 MB | 53.5 | 1195 | 1.00x |
| contiguous | 64 x 64 window (1 chunk) | 0.03 MB | 30.97 | 1 | 0.538x |
| contiguous | 512 x 512 window (4-9 chunks) | 2.00 MB | 31.64 | 63 | 0.550x |
| contiguous | one row | 0.02 MB | 31.90 | 0 | 0.554x |
| contiguous | one column | 0.03 MB | 29.36 | 1 | 0.510x |
### After: partial reads
Only the rows of a contiguous dataset, or the chunks, that overlap the
selection's bounding box are read/decoded. A 64 x 64 window of the compressed
dataset: **105 -> 0.39 ms**; one row: **106 -> 2.7 ms**; one column:
**108 -> 5.2 ms**. (Absolute full-read times differ between the two runs
because the machine's speed drifted; compare the *vs full read* column.)
4096 x 2048 f64 (64 MB per dataset), chunks 256 x 256, file 129 MB
| layout | read | selected | time ms | MB/s of selection | vs full read |
|---|---|---:|---:|---:|---:|
| chunked + deflate | full (first) | 64 MB | 112.5 | 569 | |
| chunked + deflate | full (repeat) | 64 MB | 104.5 | 612 | 1.00x |
| chunked + deflate | 64 x 64 window (1 chunk) | 0.03 MB | 0.39 | 81 | 0.003x |
| chunked + deflate | 512 x 512 window (4-9 chunks) | 2.00 MB | 4.85 | 412 | 0.043x |
| chunked + deflate | one row | 0.02 MB | 2.69 | 6 | 0.024x |
| chunked + deflate | one column | 0.03 MB | 5.23 | 6 | 0.046x |
| chunked | full (first) | 64 MB | 70.0 | 915 | |
| chunked | full (repeat) | 64 MB | 61.7 | 1037 | 1.00x |
| chunked | 64 x 64 window (1 chunk) | 0.03 MB | 0.06 | 541 | 0.001x |
| chunked | 512 x 512 window (4-9 chunks) | 2.00 MB | 1.99 | 1005 | 0.028x |
| chunked | one row | 0.02 MB | 0.05 | 285 | 0.001x |
| chunked | one column | 0.03 MB | 0.45 | 69 | 0.006x |
| contiguous | full (first) | 64 MB | 60.3 | 1062 | |
| contiguous | full (repeat) | 64 MB | 56.4 | 1134 | 1.00x |
| contiguous | 64 x 64 window (1 chunk) | 0.03 MB | 0.08 | 396 | 0.001x |
| contiguous | 512 x 512 window (4-9 chunks) | 2.00 MB | 2.12 | 944 | 0.035x |
| contiguous | one row | 0.02 MB | 0.03 | 576 | 0.000x |
| contiguous | one column | 0.03 MB | 2.55 | 12 | 0.042x |
### After: parallel cached decode, fewer copies (full reads)
Full-read times, old and new binaries run alternately at the same moment (this
machine's absolute speed drifts over a long session, so only same-moment
comparisons mean anything):
| layout (64 MB `f64`) | before | after |
|---|---:|---:|
| chunked + deflate | 110 ms | 69 ms |
| chunked | 72 ms | 60 ms |
| contiguous | 56 ms | 30 ms |
What changed: the facade's cached read path decompressed chunks one at a time
(only the uncached reader was parallel) and pushed every chunk through a 16 MiB
cache that a 64 MB read simply churns; it now decodes cache misses in parallel
batches and caches only datasets that fit. Unfiltered chunks are copied
straight from the file bytes instead of via two intermediate buffers. A
contiguous dataset is converted straight from the file bytes (one copy instead
of two), and the native-endian conversions no longer zero a buffer they are
about to overwrite.
## Search harness baseline (v2.3.0)
Produced by `cargo run --release -p clawhdf5-bench --bin search_harness -- --full`
on deterministic **clustered** synthetic data (384-dim, unit-normalised; points =
cluster centre + noise — uniform random vectors are nearly equidistant in high
dimension and say nothing about embeddings). Recall is measured against an exact
brute-force scan, 200 queries. This is the *before* picture for the search
hot-path work; every change to that path should be justified by a re-run.
Two things stand out:
* **HNSW recall does not respond to `ef`** and degrades sharply with size
(0.87 → 0.67 → 0.31 recall@10 at 1K / 10K / 100K). Latency plateaus at the same
point, i.e. the search exhausts the nodes it can reach: on clustered data the
graph is poorly connected. The index selects neighbours by plain top-M
distance rather than the HNSW paper's diversity heuristic.
* **End-to-end `hybrid_search` is ~1000x slower than its vector stage** (49 ms
vs ~0.03 ms at 10K; 884 ms at 100K). Each query rebuilds the BM25 index from
scratch and rewrites the whole `.h5` file. The first query after `open()`
additionally rebuilds the HNSW index (10.5 s at 100K).
### HNSW, N = 1000, dim = 384, M = 16, ef_construction = 64
build: 72.4 ms (13818 vectors/s) · exact scan: 3854 QPS, p50 258 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.8710 | 59484 | 16 | 31 |
| 32 | 0.8730 | 46302 | 21 | 25 |
| 64 | 0.8730 | 31683 | 31 | 44 |
| 128 | 0.8730 | 24715 | 40 | 49 |
| 256 | 0.8730 | 24788 | 40 | 50 |
### HNSW, N = 10000, dim = 384, M = 16, ef_construction = 64
build: 802.5 ms (12461 vectors/s) · exact scan: 418 QPS, p50 2363 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.6695 | 44031 | 19 | 51 |
| 32 | 0.6705 | 45066 | 22 | 30 |
| 64 | 0.6705 | 32746 | 30 | 41 |
| 128 | 0.6705 | 27542 | 36 | 51 |
| 256 | 0.6705 | 27754 | 36 | 49 |
### HNSW, N = 100000, dim = 384, M = 16, ef_construction = 64
build: 9752.6 ms (10254 vectors/s) · exact scan: 40 QPS, p50 24648 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.3085 | 18046 | 57 | 84 |
| 32 | 0.3110 | 21621 | 43 | 75 |
| 64 | 0.3130 | 20015 | 49 | 70 |
| 128 | 0.3135 | 15822 | 63 | 99 |
| 256 | 0.3135 | 15308 | 66 | 124 |
### End to end: `HDF5Memory::hybrid_search` (k = 10, weights 0.7 / 0.3)
| N | ingest ms | checkpoint ms | open ms | first query ms | p50 ms | p99 ms | QPS |
|---:|---:|---:|---:|---:|---:|---:|---:|
| 1000 | 11 | 3.9 | 0.9 | 68.1 | 5.48 | 5.57 | 182.5 |
| 10000 | 114 | 32.2 | 10.9 | 845.0 | 48.56 | 78.65 | 19.8 |
| 100000 | 1486 | 713.0 | 354.5 | 10486.5 | 883.51 | 975.23 | 1.1 |
wrote /tmp/claude-1000/-home-osobh-projects-clawhdf5/422f755e-dd25-4c35-8613-5439087e3aaa/scratchpad/baseline_full.json
### After: HNSW neighbour-selection heuristic
Same harness, same data, after replacing closest-M neighbour selection with the
HNSW paper's diversity heuristic (Algorithm 4, keeping pruned connections) for
both new links and back-link pruning. Recall@10 at `ef = 64`: **0.87 → 1.00**
(1K), **0.67 → 1.00** (10K), **0.31 → 0.98** (100K), and it now rises with
`ef` as it should. The cost is a slower build (extra distance evaluations per
insert: ~3.5x at 10K); the distance-kernel work that follows targets that.
### HNSW, N = 1000, dim = 384, M = 16, ef_construction = 64
build: 221.3 ms (4519 vectors/s) · exact scan: 3851 QPS, p50 258 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9990 | 54760 | 18 | 29 |
| 32 | 1.0000 | 40422 | 24 | 44 |
| 64 | 1.0000 | 27744 | 36 | 51 |
| 128 | 1.0000 | 13164 | 74 | 106 |
| 256 | 1.0000 | 6879 | 144 | 175 |
### HNSW, N = 10000, dim = 384, M = 16, ef_construction = 64
build: 2733.5 ms (3658 vectors/s) · exact scan: 423 QPS, p50 2362 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9975 | 31321 | 27 | 61 |
| 32 | 1.0000 | 32427 | 29 | 48 |
| 64 | 1.0000 | 22738 | 42 | 62 |
| 128 | 1.0000 | 10055 | 99 | 129 |
| 256 | 1.0000 | 4649 | 214 | 266 |
### HNSW, N = 100000, dim = 384, M = 16, ef_construction = 64
build: 36472.8 ms (2742 vectors/s) · exact scan: 40 QPS, p50 24644 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9235 | 11394 | 82 | 194 |
| 32 | 0.9675 | 12788 | 73 | 161 |
| 64 | 0.9840 | 10406 | 91 | 186 |
| 128 | 0.9990 | 7633 | 126 | 248 |
| 256 | 0.9990 | 2823 | 352 | 510 |
### After: persistent keyword index, no store rewrite per query
`hybrid_search` used to rebuild the BM25 index from scratch (re-tokenising every
record) and rewrite the whole `.h5` file on **every query**. The index is now
kept for the life of the store and updated incrementally, and activation boosts
are persisted by the next checkpoint instead of inside the query. Steady-state
p50: **5.5 → 0.24 ms** (1K), **49 → 2.1 ms** (10K), **884 → 23 ms** (100K).
The first query after `open()` is slower than before (it pays for the better —
slower — HNSW build plus the one-off keyword index build); persisting the HNSW
index removes that.
### End to end: `HDF5Memory::hybrid_search` (k = 10, weights 0.7 / 0.3)
| N | ingest ms | checkpoint ms | open ms | first query ms | p50 ms | p99 ms | QPS |
|---:|---:|---:|---:|---:|---:|---:|---:|
| 1000 | 11 | 3.8 | 0.9 | 195.9 | 0.24 | 0.27 | 4130.4 |
| 10000 | 104 | 31.1 | 10.9 | 2627.1 | 2.09 | 2.11 | 479.5 |
| 100000 | 1436 | 684.7 | 278.0 | 36308.1 | 22.90 | 25.46 | 43.5 |
### After: vector index persisted with the checkpoint
The HNSW graph (not the vectors, which the store already holds) is saved to
`<store>.h5.ann` at each checkpoint and reloaded by `open()`, tied to that
checkpoint by a generation id. The index is now built once per store (the *cold
index build* column — the first query ever), not once per session. First query
after `open()`: **196 → 1.7 ms** (1K), **2627 → 15 ms** (10K),
**36308 → 159 ms** (100K); what remains is the one-off keyword index build.
Batch saves no longer force a full rebuild either: appended records join the
index incrementally.
| N | ingest ms | cold index build ms | checkpoint ms | open ms | first query after open ms | p50 ms | p99 ms | QPS |
|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 1000 | 14 | 220 | 6.1 | 1.2 | 1.7 | 0.24 | 0.27 | 4049.9 |
| 10000 | 120 | 2916 | 33.1 | 14.0 | 15.4 | 2.15 | 3.30 | 421.2 |
| 100000 | 1591 | 40515 | 747.3 | 324.7 | 158.9 | 23.07 | 30.42 | 41.3 |
### After: unit-vector dot product, reusable visited set
Cosine distance recomputed both vector norms on every evaluation; the index now
stores unit vectors and uses a plain dot product. The per-call `HashSet` of
visited nodes became a reusable epoch-stamped array. Recall is unchanged.
Build: **2.75 -> 1.89 s** (10K), **~38 -> 21 s** (100K). QPS at `ef = 64`:
**22.7K -> 39K** (10K), **10.4K -> 14K** (100K).
### HNSW, N = 1000, dim = 384, M = 16, ef_construction = 64
build: 113.4 ms (8821 vectors/s) · exact scan: 4375 QPS, p50 225 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9990 | 144379 | 7 | 15 |
| 32 | 1.0000 | 110654 | 9 | 17 |
| 64 | 1.0000 | 80446 | 12 | 25 |
| 128 | 1.0000 | 38220 | 26 | 36 |
| 256 | 1.0000 | 20041 | 50 | 62 |
### HNSW, N = 10000, dim = 384, M = 16, ef_construction = 64
build: 1519.4 ms (6581 vectors/s) · exact scan: 422 QPS, p50 2368 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9975 | 54608 | 15 | 45 |
| 32 | 1.0000 | 66009 | 14 | 24 |
| 64 | 1.0000 | 49854 | 19 | 31 |
| 128 | 1.0000 | 22403 | 45 | 57 |
| 256 | 1.0000 | 10096 | 100 | 120 |
### HNSW, N = 100000, dim = 384, M = 16, ef_construction = 64
build: 21084.6 ms (4743 vectors/s) · exact scan: 39 QPS, p50 24739 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9235 | 15139 | 61 | 154 |
| 32 | 0.9675 | 18181 | 53 | 121 |
| 64 | 0.9840 | 13980 | 70 | 139 |
| 128 | 0.9990 | 10959 | 86 | 174 |
| 256 | 0.9990 | 3731 | 254 | 697 |
### After: unranked keyword scores, top-k merge (rankings unchanged)
A fusion study (`search_harness --fusion-study`) showed that capping the
keyword candidate pool is **not** a safe optimisation: against the current
full-corpus normalisation the final top-10 overlap is only 0.83-0.92 and the
first result changes for 10-35% of queries, for only a 2x saving. So the fusion
semantics were left alone and the same answer made cheaper: fusion needs every
keyword score but not their ranking, so BM25 now returns them unsorted from a
dense accumulator (it hashed every posting and then sorted every match), and
the merge selects its top k instead of sorting every candidate. Steady-state
p50: **0.24 -> 0.07 ms** (1K), **2.1 -> 0.49 ms** (10K), **23 -> 4.65 ms**
(100K) — **79x / 100x / 190x** faster than the v2.3.0 baseline, with identical
results.
### End to end: `HDF5Memory::hybrid_search` (k = 10, weights 0.7 / 0.3)
| N | ingest ms | cold index build ms | checkpoint ms | open ms | first query after open ms | p50 ms | p99 ms | QPS |
|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 1000 | 11 | 112 | 4.0 | 1.1 | 1.4 | 0.07 | 0.08 | 14077.9 |
| 10000 | 104 | 1487 | 33.8 | 13.7 | 13.9 | 0.49 | 0.51 | 2020.9 |
| 100000 | 1376 | 20285 | 728.9 | 353.1 | 142.2 | 4.65 | 4.78 | 214.7 |
### After: batched bulk build (optionally parallel); deletions handled in search
Profiling showed **90% of a build's distance evaluations are in back-link
pruning**. The bulk build now inserts in batches: plan each node's neighbours
against the graph as it stood at the start of the batch, link, then prune every
overflowing list once. That is less work even single-threaded (a node gaining
several back-links in a batch is pruned once), and with the `parallel` feature
planning and pruning run on a thread pool. The graph is deterministic and the
same with or without the feature. Parallelising *within* one insert was tried
first and gave only 1.45x on 16 cores (tasks too small).
| build | 1K | 10K | 100K |
|---|---:|---:|---:|
| v2.4.0 | 116 ms | 1676 ms | ~21 s |
| batched | 83 ms | 1074 ms | 19.2 s |
| batched + `parallel` (16 cores) | 34 ms | 388 ms | 5.9 s |
Recall on clustered data is unchanged or slightly better (100K, `ef = 64`:
0.984 -> 0.9945). On uniform random data it dips slightly (10K, `ef = 64`:
0.474 -> 0.444), the cost of batch members not seeing each other while
planning; batches are capped at 1/16 of the graph and 512 nodes.
## Vector Search Latency
Brute-force cosine similarity over 384-dimensional embeddings (OpenAI text-embedding-3-small size).
@@ -276,6 +672,102 @@ Session-level:
| Vector only | 85.4% | 94.2% | 96.6% | 0.8901 |
| Hybrid | **88.2%** | **95.8%** | **97.8%** | **0.9158** |
### Fusion method — weighted vs. RRF, full haystack, n=500
Reciprocal rank fusion has been in the codebase since early on but was only
reachable as a free function over a linear scan, so it had never been compared
with the weighted sum on equal terms. `HDF5Memory::hybrid_search_with` now
takes a `Fusion`, and both run over the same HNSW + BM25 candidates:
| Mode | turn Hit@1 | Hit@5 | Hit@10 | MRR | session Hit@1 | session MRR |
|---|---|---|---|---|---|---|
| BM25 only | **53.8%** | 75.0% | 81.6% | 0.6320 | 86.2% | 0.8948 |
| Vector only | 36.0% | 71.8% | 81.6% | 0.5031 | 85.4% | 0.8901 |
| **Weighted 0.4 / 0.6** | 51.6% | **81.4%** | **87.8%** | **0.6430** | **91.0%** | **0.9347** |
| RRF (k=60) | 45.0% | 78.8% | 87.6% | 0.5967 | 89.6% | 0.9253 |
**RRF loses to the tuned weighted sum** — 6.6pp of turn Hit@1 and 0.046 of MRR
— and lands almost exactly where the old `0.7/0.3` weighting did (44.2% /
0.5856). That is not a coincidence: RRF combines the two stages by rank with
*equal* influence, and on this corpus the stages are not equally good. BM25
alone beats the vector stage by 17.8pp at Hit@1, so any scheme that treats them
as peers gives up rank-1 accuracy, and RRF discards the score magnitudes that
would say which stage to believe.
This is a property of the corpus, not a defect in RRF: its selling point is
robustness when the two stages' scores are not comparable and there is no
labelled data to tune against. Here there is, so the weighted sum is kept as
the default. `Fusion::Rrf` remains available for callers whose stages are more
evenly matched.
### Keyword tokenizer — stemming, full haystack, n=500
The keyword stage lowercases and splits on non-alphanumerics, with no stemming,
so "training" and "trains" are unrelated terms. `TokenFilter::Stemmed` strips
common English inflections (plurals, `-ing`/`-ed`, with consonant un-doubling)
from documents and queries alike. Turn-level:
| Mode | Hit@1 | Hit@5 | Hit@10 | MRR | session Hit@1 |
|---|---|---|---|---|---|
| BM25 only | **53.8%** | 75.0% | 81.6% | 0.6320 | 86.2% |
| BM25 only, stemmed | 52.0% | 77.8% | 84.0% | 0.6320 | 88.0% |
| Hybrid 0.4/0.6 | 51.6% | **81.4%** | 87.8% | **0.6430** | 91.0% |
| Hybrid 0.4/0.6, stemmed | 50.2% | **81.4%** | **88.2%** | 0.6394 | **91.4%** |
**Stemming is a trade, not a win, and the default stays off.** It reliably buys
depth and costs the top rank: on BM25 alone, +2.8pp Hit@5 and +2.4pp Hit@10 for
1.8pp Hit@1, with MRR unchanged to four decimal places — the gains deeper down
exactly offset the loss at rank 1. That is what conflation does: merging
"train"/"training"/"trains" surfaces documents an exact-match query would never
reach, and also lets a near-miss outrank the exact hit.
On the configuration that actually ships (hybrid 0.4/0.6) the trade is
narrower still — Hit@5 identical, Hit@10 +0.4pp, Hit@1 1.4pp, MRR 0.004 —
because the vector stage already supplies much of the recall stemming would
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
@@ -318,7 +810,8 @@ BM25 at Hit@1. Both dominate `0.7/0.3`.
The rows below are kept at the three original settings because they are what the
mode ablation measured — read them as "the shape of each stage in isolation",
and take the operating point from the sweep.
and take the operating point from the sweep. `0.4/0.6` is now the shipped
default (`hybrid::DEFAULT_FUSION`).
The same pattern shows up independently in omni-cortex's four-signal RRF ablation,
where adding BM25 to a dense retriever raised nDCG@5 while lowering Hit@1 and MRR.
+432 -1
View File
@@ -1,6 +1,427 @@
# Changelog
## Unreleased
## 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
- **Retrieval rankings change, for the better.** The default fusion weights
move from `0.7/0.3` to `0.4/0.6` (`hybrid::DEFAULT_FUSION`), measured over the
full LongMemEval haystack: turn-level Hit@1 51.6% vs 44.2%, MRR 0.643 vs
0.586. `unified_search` and the OpenClaw backend pick this up automatically;
callers passing weights to `hybrid_search` explicitly are unaffected.
- **Out-of-range selections are now errors.** `read_*_selection` used to return
data for a selection that ran past a dataset edge — a hyperslab came back
zero-padded, and a point with an out-of-range coordinate wrapped into the
next row. Both are now `FormatError::SelectionOutOfBounds`. Code relying on
the old (wrong) values will start seeing errors.
- **Large compressed datasets written without explicit chunk dimensions get a
different layout.** They used to be stored as one chunk; they are now split
to ~1 MiB chunks. The files stay standard and h5py-readable, and explicit
`with_chunks` is unaffected.
- `rayon` is now a default dependency of `clawhdf5-agent` (the parallel index
build). Opt out with `--no-default-features --features float16,hnsw`.
- `clawhdf5-ann` search results no longer shrink when records near the query
have been deleted, so a search that previously returned fewer than `k`
results now returns `k`.
### Retrieval quality
- `clawhdf5-agent`: optional keyword stemming — `bm25::TokenFilter::Stemmed`
and `HDF5Memory::set_token_filter`, so "training" and "trains" match. **Off
by default**, on measurement rather than principle: over the full LongMemEval
haystack it buys depth and costs the top rank (BM25 alone: Hit@5 +2.8pp,
Hit@10 +2.4pp, Hit@1 1.8pp, MRR unchanged), and on the shipping hybrid
configuration the trade is narrower still. See `BENCHMARKS.md`.
- `clawhdf5-agent`: **`QueryExpander::expand` panicked on ordinary non-ASCII
input** — `"İ AI"` was enough. It searched a lowercased copy of the query and
then sliced the *original* with those offsets, which only works while
lowercasing preserves byte length (Turkish `İ` is 2 bytes and lowercases to
3). Depending on where the offsets drifted it either corrupted the output
("İstanbul AI trip" lost a character) or panicked. Matching now walks the
original string.
- `clawhdf5-agent`: query expansion no longer rewrites text inside words.
`replace_word_case_insensitive` did a plain substring replace despite its
name, so "training" became "trArtificial Intelligencening" and "programming"
became "Pull Requestogramming" — every acronym expansion of ordinary prose
was corrupt. Matches now require word boundaries; genuine acronyms
(`API`, `database`) still expand.
- `clawhdf5-agent`: **the default fusion weights are now the measured ones.**
A sweep of every 0.1 step over the full LongMemEval haystack (500 questions,
real MiniLM embeddings) shows the long-standing `0.7/0.3` default is
*strictly dominated* by `0.4/0.6` — turn-level Hit@1 51.6% vs 44.2%, Hit@5
81.4% vs 79.2%, Hit@10 87.8% vs 85.8%, MRR 0.643 vs 0.586, and better at
session level too. The finding was recorded in `BENCHMARKS.md` but had never
been applied: `unified_search` and the OpenClaw backend both hardcoded
`0.7/0.3`. They now use `hybrid::DEFAULT_FUSION`. **Callers passing weights
to `hybrid_search` explicitly are unaffected** — pass `0.4`/`0.6` (or use
`hybrid_search_with`) to get the tuned behaviour.
- `clawhdf5-agent`: fusion is now selectable. New `hybrid::Fusion`
(`Weighted { vector, keyword }` or `Rrf { k }`), `hybrid::fuse`,
`hybrid::hybrid_search_fused` and `HDF5Memory::hybrid_search_with`.
Reciprocal rank fusion existed but was unreachable from the store, so it had
never been measured against the weighted sum; the LongMemEval bench now has
an `RRF` mode.
### HDF5 Read Path
- **Selection reads cost what the selection costs.** `read_*_selection` decoded
the *entire* dataset and then picked elements out, so a 64 x 64 window of a
64 MB compressed dataset took 105 ms - about as long as reading all of it.
Now only the rows (contiguous) or chunks that overlap the selection's
bounding box are read and decompressed: that window takes 0.39 ms, one row
2.7 ms, one column 5.2 ms. Results are identical to the full-read path
(equivalence-tested over random hyperslabs and point lists, ranks 1-3,
contiguous / chunked / deflate). New `read_harness` bench binary.
- **Faster full reads** (same-moment A/B, 64 MB `f64`): chunked + deflate
110 -> 69 ms, chunked 72 -> 60 ms, contiguous 56 -> 30 ms. The facade's
cached read path now decompresses cache misses in parallel batches (it was
sequential; only the uncached reader was parallel) and caches only datasets
that fit the chunk cache; unfiltered chunks are copied straight from the file
bytes; a contiguous dataset is converted straight from the file bytes; and
the native-endian conversions no longer zero a buffer before overwriting it.
- **Datasets indexed by a version-2 B-tree now read** (layout v4, chunk index
type 5 — what `libver='latest'` uses for two or more unlimited dimensions;
previously "unsupported chunked layout"). The four copies of the chunk-index
dispatch are now one shared function, so every read path gets it.
- **`H5T_STD_REF` references** (HDF5 1.12+, datatype message version 4) parse:
`ReferenceType` gains `Object2`, `DatasetRegion2` and `Attribute`, and
`read_object_references` decodes the new object references. Previously any
dataset of this type failed with `InvalidReferenceType(2)`. Tested against a
file written by HDF5 2.0 itself (fixture + generator script committed).
- **Automatic chunk sizes.** Asking for compression (or any filter) without
`with_chunks` used to store the whole dataset as one chunk, so any read had
to decompress everything and nothing could be decoded in parallel. Datasets up
to 1 MiB stay a single chunk, as before; larger ones are split by halving the
dimensions in turn until a chunk is at most 1 MiB (the approach h5py takes).
**Behaviour change:** large compressed datasets written without explicit
chunk dimensions get a different (standard, h5py-readable) layout. Explicit
`with_chunks` is unaffected.
- **Out-of-range selections are errors.** They used to return data: a hyperslab
past an edge came back padded with zeros, and a point whose column was out of
range wrapped into the next row and returned that element. Now
`FormatError::SelectionOutOfBounds` (also for a rank mismatch or overlapping
blocks).
### Search
- `clawhdf5-ann`: **faster index builds.** Back-link pruning is 90% of a
build's distance evaluations; the bulk build now inserts in batches and
prunes each overflowing neighbour list once per batch (10K: 1676 -> 1074 ms).
With the `parallel` feature, planning and pruning run on a thread pool (10K:
388 ms, 100K: ~21 s -> 5.9 s on 16 cores). The graph is deterministic and
identical with or without the feature. `clawhdf5-agent`'s `parallel` feature
enables it for the agent's index and is now **on by default** (adds `rayon`
to the default dependency set; build with `--no-default-features --features
float16,hnsw` to opt out).
- `clawhdf5-ann`: `HnswIndex::search` returned fewer than `k` results — often
none — when the records nearest the query had been deleted: it collected `ef`
candidates, *then* dropped the deleted ones, *then* took `k`. Deleted nodes
are now traversed as waypoints but never occupy a result slot, so a search
returns the `k` nearest live records. Matters for any store that deletes or
supersedes memories without compacting straight away.
## v2.4.0 (2026-09-19)
### Upgrade Notes
- **Search results improve on upgrade.** The HNSW index now reaches true
neighbours it previously could not (recall@10 0.31 -> 0.98 at 100K records on
clustered data), so `hybrid_search` rankings change for the better. The agent
rebuilds its index from the store automatically; a standalone `HnswIndex`
persisted with `to_hdf5_bytes` keeps its old graph until rebuilt.
- **`hybrid_search` no longer writes the store.** Hebbian activation boosts are
persisted by the next checkpoint (any flushing write, `flush_wal`, or when
the `HDF5Memory` is dropped) instead of inside every query; a crash before
then forgets only the boosts since the last checkpoint. Activation weights
are now capped at 16.
- A new sidecar file, `<store>.h5.ann`, holds the vector index graph. It is
derived data: safe to delete (the index is rebuilt), copied by `snapshot()`,
and worth including when copying a store by hand to avoid a rebuild.
- `BM25Index` no longer caches IDF and gained `add_document`,
`remove_document`, `pad_to`, `scores`, `len` and `is_empty`; results are now
deterministic (ties break by record id).
### Search
- `clawhdf5-ann`: **HNSW recall fix.** Neighbours were chosen as the plain
closest-M, which on clustered data (what embeddings look like) turns each
cluster into an island: recall@10 was 0.87 / 0.67 / 0.31 at 1K / 10K / 100K
vectors and did not improve with `ef`. The index now uses the HNSW paper's
diversity heuristic (Algorithm 4 with kept pruned connections) when linking a
new node and when pruning back-links: recall@10 at `ef = 64` is 1.00 / 1.00 /
0.98 and responds to `ef`. Builds are slower (~3.5x at 10K). Existing
persisted indexes keep their old graph until rebuilt; the agent rebuilds its
index from the cache, so stores pick this up automatically.
- `clawhdf5-agent`: **`hybrid_search` is 23-39x faster in steady state** (p50
5.5 -> 0.24 ms at 1K records, 49 -> 2.1 ms at 10K, 884 -> 23 ms at 100K).
Every query used to rebuild the BM25 index from scratch and rewrite the whole
`.h5` file. The keyword index now lives for the life of the store and is
updated incrementally (add / remove / in-place update, exactly equivalent to
a fresh build - property-tested), and a query no longer writes the store.
**Behaviour change:** Hebbian activation boosts are persisted by the next
checkpoint (any flushing write, `flush_wal`, or drop) rather than
immediately; a crash in between forgets only the boosts since the last
checkpoint. Activation weights are now capped (16.0) - they grew without
bound.
- `clawhdf5-agent`: **the vector index is persisted**, so `open()` no longer
rebuilds it on the first search (first query after open: 2627 -> 15 ms at 10K
records, 36 s -> 159 ms at 100K). The HNSW graph — not the vectors, which the
store already holds — is written to `<store>.h5.ann` at each checkpoint and
tied to it by a generation id in `/meta`; a missing, stale, damaged or
structurally invalid sidecar is ignored and the index rebuilt. Records
replayed from the WAL join the loaded index incrementally; a replayed update
or delete invalidates it. `snapshot()` copies it. Batch saves no longer force
a full index rebuild.
- `clawhdf5-ann`: faster HNSW build and search with identical recall. The
cosine metric stores unit vectors and compares them with a plain dot product
(it re-derived both norms on every distance evaluation), and the per-call
`HashSet` of visited nodes is a reusable epoch-stamped array. Build 2.75 ->
1.89 s at 10K and ~38 -> 21 s at 100K; QPS at `ef = 64` 22.7K -> 39K at 10K.
Distances returned by `search` are unchanged (1 - cosine). Indexes loaded
from older HDF5 files are normalised on load.
- `clawhdf5-accel`: the SIMD backend is detected once per process instead of
on every kernel call.
- `clawhdf5-ann`: `HnswIndex::graph_to_bytes` / `from_graph_bytes` — graph-only
serialization (checksummed, every neighbour id and level validated on load).
- `clawhdf5-agent`: a further 4-5x on `hybrid_search` with **identical
rankings** (p50 now 0.07 / 0.49 / 4.65 ms at 1K / 10K / 100K — 79x / 100x /
190x faster than v2.3.0). Fusion needs every keyword score but not their
ranking: new `BM25Index::scores` returns them unsorted from a dense
accumulator (it hashed every posting, then sorted every match), and
`merge_vector_keyword` selects its top k instead of sorting every candidate.
Capping the keyword candidate pool was measured and rejected: it changes the
top-10 for most queries (`search_harness --fusion-study`).
- `clawhdf5-agent`: BM25 results are deterministic (ties break by record id),
top-k uses a bounded heap, and the "WAND early termination" that computed a
bound and then ignored it is gone. IDF is computed per query.
- `clawhdf5-bench`: new `search_harness` binary — HNSW recall@10 / QPS / latency
per `ef` against an exact scan, and end-to-end `hybrid_search` timings, on
deterministic clustered (or `--uniform`) data. Baseline in `BENCHMARKS.md`.
## v2.3.0 (2026-09-19)
### Upgrade Notes
- **A memory store now has a single writer.** `HDF5Memory::create`/`open` take
an exclusive lock (`<store>.h5.lock`); a second open of the same store — in
the same or another process — returns `MemoryError::Locked`. Code that opened
a second handle just to read should use `HDF5Memory::open_read_only`.
- **Unsigned array attributes arrive as `AttrValue::U64Array`**, not
`I64Array`, and `attrs()` may now return `AttrValue::Raw`. Exhaustive matches
on `AttrValue` need the two new arms.
- **WAL header version 3 → 4.** v3 files are read and upgraded in place, but a
store written by 2.3.0 with a pending WAL cannot be opened by 2.2.0 or
earlier (it is refused, not corrupted). Checkpoint first
(`flush_wal`) if you need to downgrade.
- `MemoryConfig::compression` now uses deflate unless the agent's new `zstd`
feature is enabled; it previously failed outright in a default build.
- `MemoryError` gained `Locked`; `FormatError` gained `UnresolvedSharedMessage`,
`ExternalDataFilesUnsupported` and `ExternalLinkUnsupported`; `MessageType`
gained `ExternalDataFiles`.
### Bug Fixes
- `clawhdf5-format`: compound datatypes written with **default libver bounds**
(datatype message version 1 — what plain `h5py.File(path, 'w')` produces)
were mis-parsed. The v1 member layout carries 28 bytes of legacy array
fields after the byte offset (the parser skipped 24), and v2 pads member
names to 8 bytes and has no array fields at all (the parser did neither), so
every member after the first byte offset was read from the wrong position —
typically surfacing as `Overflow("compound member ...")` on read. Found by
adding a default-libver axis to the h5py interop tests; byte-level regression
tests for v1 and v2 added.
- `clawhdf5-gpu`: `gpu_tests` could hang forever under the default parallel
test runner — every test created its own wgpu instance and device at once.
Tests now serialise GPU access, and GPU→CPU readback waits are bounded
(30 s) so a wedged driver returns `GpuError::BufferMap` instead of blocking.
- `clawhdf5-agent`: `benches/bench.rs` and `benches/memory_bench.rs` no longer
compiled against the current `strategy`/`consolidation` APIs.
### HDF5 Compatibility
- `clawhdf5-format`/`clawhdf5`: datasets and attributes that use a **committed
(named) datatype** now read correctly. They store a shared-message reference;
the facade parsed the reference bytes as the datatype (`Time { size: 0 }`,
unreadable data) and silently dropped such attributes. The shared-reference
parser itself was wrong for real files: version 2 has no reserved bytes, and
the version 3 types were inverted (1 = SOHM heap, 2 = committed).
- **Fill values are applied on read.** There was no Fill Value message parser:
the holes of a sparse chunked dataset read as zeros even when the fill value
was not zero (silently wrong data), and a dataset that was created but never
written failed with `NoDataAllocated` where h5py returns a filled array.
Messages v1v3 and the old 0x0004 form are parsed; the fill value is written
into exactly the chunk-grid cells missing from the chunk index.
- **Soft links are followed** during path resolution, in old- and new-style
groups (absolute/relative targets, links to groups, links through links),
with a depth limit so a link cycle is an error rather than a hang. A dangling
link reports the target it could not find.
- Things the reader does not follow are now explicit errors instead of wrong
answers: an external link is `ExternalLinkUnsupported { filename,
object_path }` (was `PathNotFound`), and a dataset whose raw data lives in
external files (message 0x0007, now a known `MessageType`) is
`ExternalDataFilesUnsupported` (it would otherwise read as fill values).
- **`attrs()` no longer drops attributes.** Any attribute whose datatype had
no `AttrValue` variant was omitted with no error — including every Python
`bool` (h5py stores `attrs["flag"] = True` as an enum), complex numbers,
compound values and object references. Now:
- numpy/h5py-style booleans (an enum of exactly `FALSE`=0 / `TRUE`=1) decode
as `I64` / `I64Array` of 0/1;
- new `AttrValue::U64Array` keeps unsigned arrays unsigned (they were cast to
`I64Array`, so values above `i64::MAX` came back negative). **Behaviour
change:** code matching `I64Array` for an unsigned attribute must also
match `U64Array` (the netCDF-4 CF helpers and Python bindings do);
- new `AttrValue::Raw { datatype, shape, data }` carries everything else
verbatim, decodable with `clawhdf5_format::data_read` against `datatype`.
Both new variants are writable, so an attribute can be copied between files
unchanged. Python receives `Raw` as `{"dtype", "shape", "data"}`.
- All of the above are covered by h5py interop tests under both default and
`libver='latest'` bounds, compared against h5py's own readback.
### Security
- `clawhdf5`: virtual-dataset source file names are untrusted input but were
joined straight onto the opened file's directory, so a crafted file could
make the reader open any path the process can reach (absolute path, or `..`
components). Only plain relative paths inside that directory are accepted.
### Durability & Integrity
- `clawhdf5-agent`: a crash between writing a checkpoint and truncating the WAL
no longer **duplicates every pending entry** on the next open. Each
checkpoint records a `WalMark` (byte length + chained CRC of the WAL prefix it
folded in) in `/meta`; `open()` skips exactly that prefix when it is still
present. No WAL format change for this; older files behave as before.
- `clawhdf5-agent`: checkpoints and snapshots are durable as a unit — the temp
file is synced before the rename and the directory after it. Individual WAL
appends remain unsynced by design (documented in `CLAUDE.md`).
- `clawhdf5-agent`: `save_or_update` hits are logged as a new `Update` WAL
record, so replay updates in place instead of appending a duplicate. WAL
header version 3 → 4 (so older builds refuse the file rather than truncating
a record they can't parse); v3 files are read and upgraded in place.
- `clawhdf5-agent`: loading validates every per-record dataset length (a
truncated store is now `MemoryError::Schema`, not a later panic), fixes the
`n.len() == n.len()` tautology that trusted a norms dataset of any length,
and rejects `embedding_dim == 0` with records present.
- `clawhdf5-agent`: eight behavioural `MemoryConfig` fields are now persisted in
`/meta`. Previously they reset to defaults on every open — a compressed store
was rewritten uncompressed, `wal_enabled = false` flipped back to `true`.
- `clawhdf5-agent`: `compression = true` never worked in a default build (it
requested Zstd without enabling the feature, so every checkpoint failed with
`unsupported filter: 32015`). Default builds now use deflate; Zstd is the new
opt-in `zstd` feature.
- `clawhdf5-agent`: **single-writer lock** (`<store>.h5.lock`,
`MemoryError::Locked`) — two handles on one store used to silently destroy
each other's data. New `HDF5Memory::open_read_only` gives a lock-free,
never-writing view; the CLI's read-only subcommands use it.
- `clawhdf5-agent`: an unreadable WAL (torn header / bad magic) is quarantined
(`HDF5Memory::quarantined_wal()`) instead of blocking `open()` of a healthy
store. A WAL from an unknown newer version still fails and is left intact.
- `clawhdf5-agent`: provenance records are renumbered on compaction (they
weren't, so every later `save_or_update` raised a false High integrity
alert); pending anomaly alerts and tracked sessions are bounded;
`snapshot()` includes entries still in the WAL.
- `clawhdf5-agent`: hybrid ranking is deterministic (index tie-breaks instead
of `HashMap` order); a set of identical positive scores — including a single
candidate — normalises to 1.0 rather than 0.0; the Hebbian boost no longer
reinforces zero-score filler results.
- `clawhdf5-format`: chunked/VDS/hyperslab reads size their buffers with
overflow-checked arithmetic and fallible allocation, so crafted dimensions
are `FormatError::Overflow` instead of a wrapped size or a process abort;
`parallel_read` bounds checks use `checked_add`.
- `clawhdf5`: a malformed filter-pipeline message is an error instead of being
treated as "no filters" (which returned compressed bytes as data);
`FileBuilder::write` is atomic and synced instead of truncating the
destination first.
### CI / Testing
- CI now lints every target (`cargo clippy --all-targets`) plus
`clawhdf5-format`'s optional features, compiles all benches, and tests the
format feature matrix. Previously test/bench code and feature-gated modules
were never linted; the accumulated clippy backlog is fixed.
- CI installs python3 + h5py/numpy/netCDF4/xarray and sets
`CLAWHDF5_REQUIRE_INTEROP=1`, which turns a missing interop dependency into a
test **failure**. Until now every h5py/netCDF4 interop test silently skipped
in CI, which is how the HDF5 2.0 compound bug fixed in v2.2.0 reached a user.
The `#[ignore]`d `writer_h5py_tests` suite is run explicitly.
- h5py-generated-file tests now cover default libver bounds as well as
`libver='latest'` (HDF5 2.0 raised the default low bound to 1.8).
- `clawhdf5-agent`: WAL property tests (round trip; after any corruption the
entries read back are an exact prefix of what was written — 1500 seeded
cases), a crash-recovery matrix (an on-disk image after every operation, the
checkpoint window, and the WAL torn at every byte length, each reopened and
checked against a model), and a WAL fuzz target.
- Optional fuzz smoke run (`CLAWHDF5_FUZZ_SECONDS=N scripts/ci-test.sh`); new
datatype corpus seeds for v1 compound and native complex messages.
## v2.2.0 (2026-09-18)
### Security
- `clawhdf5-format`: bounded decompression output (`MAX_DECOMPRESS_SIZE`) for
@@ -245,6 +666,16 @@
reading compound types and — critically — every chunked/compressed dataset
written by HDF5 2.0. Found by running the h5py interop tests against
h5py 3.16 / HDF5 2.0.
Independently reported (with a patch) against the v2.1.0 tag by
M. Scot Breitenfeld (The HDF Group) — v2.1.0 predates this fix.
- `clawhdf5-format`: parse HDF5 2.0 native complex datatypes (class 11,
datatype version 5, e.g. `H5T_COMPLEX_IEEE_F64LE`). The properties are a
single base floating-point datatype, not a compound-style member list; the
old parser read the base type's bytes as member names, producing a garbage
datatype, and failed with `UnexpectedEof` when a complex type was nested in
a compound. It is now surfaced as the equivalent `{r, i}` compound (the
shape h5py writes for numpy complex dtypes), with a size check against the
base type. Validated end-to-end against an HDF5 2.0-written file.
### Performance
- `clawhdf5-format`: chunked writes now compress all chunks up front via
+60 -1
View File
@@ -33,7 +33,66 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
the approximate `clawhdf5-ann` index for the vector stage (the index mirrors
the cache and self-heals on drift). Build the agent with
`--no-default-features --features float16` to force the exact linear cosine scan.
- WAL (write-ahead log) for crash-safe persistence, with a CRC32 trailer per entry so a corrupted entry stops replay cleanly instead of loading bad data
The agent's `parallel` feature (also default) builds the index on a thread
pool; the graph is identical with or without it.
The index uses the HNSW paper's diversity heuristic for neighbour selection
(plain closest-M capped recall on clustered data: 0.31 recall@10 at 100K). Its
graph is saved to `<store>.h5.ann` at each checkpoint and reloaded by `open()`
(tied to the checkpoint by a generation id; stale/damaged sidecars are
ignored and the index rebuilt). `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 and costs
~13% of QPS. `hybrid_search` keeps one incremental BM25
index for the life of the store and never writes the store: Hebbian
activation boosts are persisted by the next checkpoint (or on drop), not per
query. Measure any search-path change with
`cargo run --release -p clawhdf5-bench --bin search_harness` (baselines in
`BENCHMARKS.md`).
- WAL (write-ahead log) for crash-safe persistence, with a chained CRC32
trailer per entry (each entry's CRC folds in the previous entry's CRC) so a
corrupted, reordered, duplicated, or spliced entry stops replay cleanly
instead of loading bad or tampered data. The pre-chaining per-entry-CRC
format (v2) is still fully readable; the oldest no-CRC format (v1) is only
reachable through the one-time migration path in `HDF5Memory::open`, not
through the public `WalFile::read_entries`.
**What the WAL guarantees:** integrity, ordering, and recovery from a
*process* crash at any point — including between a checkpoint and the WAL
truncate (each checkpoint records a `WalMark` in `/meta`, and `open()` skips
the WAL prefix the `.h5` already contains, so entries are never applied
twice). Checkpoints and snapshots are made durable as a unit (temp file
synced, renamed, directory synced). **What it does not guarantee:**
individual WAL appends are *not* fsynced (a deliberate latency trade-off), so
saves made since the last checkpoint can be lost on power failure or kernel
panic. Current header version is 4 (adds the `Update` record used by
`save_or_update`); v3 files are read and upgraded in place.
- A store has a **single writer**: `HDF5Memory::create`/`open` hold an exclusive
advisory lock on `<store>.h5.lock` and a second opener gets
`MemoryError::Locked`. Use `HDF5Memory::open_read_only` for a lock-free,
never-writing point-in-time view (the CLI's `recall`/`stats`/`agents-md`/
`export` do). An unreadable WAL (torn header, bad magic) is quarantined to
`<store>.h5.wal.corrupt-<ts>` rather than blocking `open()`; a WAL with an
unknown *newer* version still fails and is left untouched.
- `MemoryConfig::compression` uses deflate by default; enable the agent's
`zstd` feature to compress embeddings with Zstd instead (links libzstd).
- `Dataset::verify_provenance()` (clawhdf5 facade, `provenance` feature, on by
default) recomputes a dataset's SHA-256 and compares it against the
`_provenance_sha256` attribute written automatically on save when
`DatasetBuilder::with_provenance` is used. It's opt-in per call, not run
automatically on open — it decodes and hashes the whole dataset. The hash
is unkeyed (tamper-*evident*, not tamper-*proof*): it detects accidental
corruption, not a deliberate actor able to modify both the data and the
stored hash.
- `clawhdf5-agent`'s `HDF5Memory::save`/`save_batch`/`save_or_update` run every
write through an in-memory (session-scoped, not persisted to disk)
provenance ledger and write-anomaly detector: a content hash per record
(`provenance.rs`) for detecting accidental mid-session corruption, plus
rate-limit/injection-pattern/source-distribution checks (`anomaly.rs`).
Alerts never block a save — drain them with `HDF5Memory::take_anomaly_alerts`.
`MemorySource` for this bookkeeping is inferred from the caller-supplied
`source_channel` string (a heuristic, not an authenticated trust boundary).
- GPU-accelerated batch I/O for large dataset processing
- Python and Node.js bindings for cross-language use
- NetCDF-4 compatibility for scientific data interop
+2 -2
View File
@@ -21,10 +21,10 @@ members = [
resolver = "2"
[workspace.package]
version = "2.1.0"
version = "2.6.0"
edition = "2024"
license = "MIT"
repository = "https://github.com/redclawsystems/clawhdf5"
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
[workspace.dependencies]
tempfile = "3"
+15
View File
@@ -432,6 +432,13 @@ 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::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 — recall matches the `f32` index, at about
13% fewer queries per second. 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 +499,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
+2 -2
View File
@@ -1,10 +1,10 @@
[package]
name = "clawhdf5-accel"
version = "2.1.0"
version = "2.6.0"
edition = "2024"
description = "SIMD-accelerated operations for rustyhdf5"
license = "MIT"
repository = "https://github.com/redclawsystems/clawhdf5"
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
readme = "README.md"
keywords = ["hdf5", "simd", "acceleration", "performance"]
categories = ["science", "algorithms"]
+5 -1
View File
@@ -111,7 +111,11 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
}
let denom = (norm_a * norm_b).sqrt();
if denom == 0.0 { 0.0 } else { dot / denom }
if denom < f32::EPSILON {
0.0
} else {
dot / denom
}
}
}
+5 -1
View File
@@ -89,7 +89,11 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
}
let denom = (norm_a * norm_b).sqrt();
if denom == 0.0 { 0.0 } else { dot / denom }
if denom < f32::EPSILON {
0.0
} else {
dot / denom
}
}
}
+19 -1
View File
@@ -61,8 +61,14 @@ pub enum Backend {
Scalar,
}
/// Detect the best available SIMD backend at runtime.
/// The best available SIMD backend, detected once per process. Every kernel
/// dispatches through this, so it sits in the innermost loop of every search.
pub fn detect_backend() -> Backend {
static BACKEND: std::sync::OnceLock<Backend> = std::sync::OnceLock::new();
*BACKEND.get_or_init(detect_backend_uncached)
}
fn detect_backend_uncached() -> Backend {
#[cfg(target_arch = "aarch64")]
{
return Backend::Neon; // Always available on aarch64
@@ -361,6 +367,18 @@ mod tests {
assert!(approx_eq(cosine_similarity(&a, &b), 0.0, EPSILON));
}
#[test]
fn test_cosine_near_zero_norm_clamped() {
// denom = 1e-4 * 1e-4 = 1e-8, comfortably below f32::EPSILON
// (~1.19e-7) but not exactly 0.0 — must still clamp to 0.0 so
// callers computing `1.0 - cosine_similarity(...)` treat these
// as maximally dissimilar, matching the pre-SIMD scalar guard.
let a = [1e-4f32];
let b = [1e-4f32];
assert_eq!(cosine_similarity(&a, &b), 0.0);
assert_eq!(scalar::cosine_similarity(&a, &b), 0.0);
}
#[test]
fn test_cosine_scalar_vs_dispatch() {
let a: Vec<f32> = (0..384).map(|i| (i as f32).sin()).collect();
+5 -1
View File
@@ -94,7 +94,11 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
}
let denom = (norm_a * norm_b).sqrt();
if denom == 0.0 { 0.0 } else { dot / denom }
if denom < f32::EPSILON {
0.0
} else {
dot / denom
}
}
/// NEON L2 distance.
+5 -1
View File
@@ -21,7 +21,11 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
norm_b += y * y;
}
let denom = (norm_a * norm_b).sqrt();
if denom == 0.0 { 0.0 } else { dot / denom }
if denom < f32::EPSILON {
0.0
} else {
dot / denom
}
}
pub fn batch_cosine(query: &[f32], vectors: &[&[f32]], results: &mut [(usize, f32)]) {
+15 -10
View File
@@ -1,21 +1,21 @@
[package]
name = "clawhdf5-agent"
version = "2.1.0"
version = "2.6.0"
edition = "2024"
description = "HDF5-backed persistent memory store for on-device AI agents"
license = "MIT"
repository = "https://github.com/redclawsystems/clawhdf5"
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
readme = "README.md"
keywords = ["agent", "memory", "hdf5", "vector-search", "embedding"]
categories = ["database", "science", "algorithms"]
[dependencies]
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0", features = ["parallel", "fast-checksum"] }
clawhdf5 = { path = "../clawhdf5", version = "2.1.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.1.0", features = ["mmap"] }
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.1.0" }
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.1.0", optional = true }
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.1.0", optional = true, default-features = false }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0", features = ["parallel", "fast-checksum"] }
clawhdf5 = { path = "../clawhdf5", version = "2.6.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.6.0", features = ["mmap"] }
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.6.0" }
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.6.0", optional = true }
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.6.0", optional = true, default-features = false }
serde = { workspace = true }
byteorder = "1"
half = { workspace = true, optional = true }
@@ -45,9 +45,14 @@ name = "memory_bench"
harness = false
[features]
default = ["float16", "hnsw"]
default = ["float16", "hnsw", "parallel"]
float16 = ["half"]
parallel = ["rayon"]
# Rayon-parallel brute-force search strategies, and a parallel bulk build of
# the HNSW index (same graph, several times faster on a multi-core machine).
parallel = ["rayon", "clawhdf5-ann?/parallel"]
# Compress embeddings with Zstd instead of deflate when
# `MemoryConfig::compression` is on. Off by default: it links libzstd (C).
zstd = ["clawhdf5/zstd"]
# HNSW approximate-nearest-neighbour acceleration for the vector stage of
# hybrid_search. On by default; the index is rebuilt from the cache on demand
# and stays self-consistent with the persisted memory store. Disable with
+16 -3
View File
@@ -483,7 +483,7 @@ fn rayon_benches(c: &mut Criterion) {
use rayon::prelude::*;
let query_norm = vector_search::compute_norm(&query);
let num_cores = rayon::current_num_threads().max(1);
let chunk_size = (n + num_cores - 1) / num_cores;
let chunk_size = n.div_ceil(num_cores);
let mut results: Vec<(usize, f32)> = vectors
.par_chunks(chunk_size)
.enumerate()
@@ -537,7 +537,7 @@ fn rayon_benches(c: &mut Criterion) {
use rayon::prelude::*;
let query_norm = vector_search::compute_norm(&query);
let num_cores = rayon::current_num_threads().max(1);
let chunk_size = (n + num_cores - 1) / num_cores;
let chunk_size = n.div_ceil(num_cores);
let mut results: Vec<(usize, f32)> = vectors
.par_chunks(chunk_size)
.enumerate()
@@ -766,12 +766,22 @@ fn adaptive_benches(c: &mut Criterion) {
.map(|v| vector_search::compute_norm(v))
.collect();
let tombstones = vec![0u8; n];
let flat: Vec<f32> = vectors.iter().flatten().copied().collect();
c.bench_function("adaptive_search_10k", |b| {
let hw = HardwareCapabilities::detect();
let strat = strategy::auto_select_strategy(n, &hw);
b.iter(|| {
strategy::search_with_metrics(&query, &vectors, &norms, &tombstones, 10, strat, None)
strategy::search_with_metrics(
&query,
&vectors,
&flat,
&norms,
&tombstones,
10,
strat,
None,
)
});
});
@@ -781,6 +791,7 @@ fn adaptive_benches(c: &mut Criterion) {
strategy::search_with_metrics(
&query,
&vectors,
&flat,
&norms,
&tombstones,
10,
@@ -795,6 +806,7 @@ fn adaptive_benches(c: &mut Criterion) {
strategy::search_with_metrics(
&query,
&vectors,
&flat,
&norms,
&tombstones,
10,
@@ -809,6 +821,7 @@ fn adaptive_benches(c: &mut Criterion) {
strategy::search_with_metrics(
&query,
&vectors,
&flat,
&norms,
&tombstones,
10,
+19 -6
View File
@@ -1,6 +1,7 @@
use clawhdf5_agent::bm25::BM25Index;
use clawhdf5_agent::consolidation::{
ConsolidationConfig, ConsolidationEngine, ImportanceScorer, ImportanceWeights, MemorySource,
UntrustedSource,
};
use clawhdf5_agent::hybrid::{hybrid_search, rrf_hybrid_search};
use clawhdf5_agent::knowledge::KnowledgeCache;
@@ -285,7 +286,12 @@ fn consolidation_benches(c: &mut Criterion) {
for i in 0..n {
let embedding = make_vec(&mut rng, DIM);
let chunk = format!("memory record {i} with some content");
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
engine.add_memory(
chunk,
embedding,
UntrustedSource::User,
now + i as f64,
);
}
engine
},
@@ -307,9 +313,10 @@ fn consolidation_benches(c: &mut Criterion) {
for i in 0..50usize {
let embedding = make_vec(&mut rng, DIM);
let chunk = format!("existing record {i}");
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
engine.add_memory(chunk, embedding, UntrustedSource::User, now + i as f64);
}
let records = engine.records().to_vec();
let record_refs: Vec<&_> = records.iter().collect();
let weights = ImportanceWeights::default();
let query_embedding = make_vec(&mut rng, DIM);
let sample_text =
@@ -317,7 +324,7 @@ fn consolidation_benches(c: &mut Criterion) {
group.bench_function("bench_importance_scoring", |b| {
b.iter(|| {
let surprise = ImportanceScorer::score_surprise(&query_embedding, &records);
let surprise = ImportanceScorer::score_surprise(&query_embedding, &record_refs);
let correction = ImportanceScorer::score_correction(&MemorySource::Correction);
let length = ImportanceScorer::score_length(sample_text);
ImportanceScorer::score_combined(surprise, correction, length, &weights)
@@ -354,7 +361,7 @@ fn temporal_benches(c: &mut Criterion) {
// Insert benchmark: measure time to insert 10k timestamps one by one
group.bench_function("bench_temporal_insert_10k", |b| {
b.iter_batched(
|| TemporalIndex::new(),
TemporalIndex::new,
|mut idx| {
for i in 0..N {
// Shuffle insertion order slightly using a simple offset pattern
@@ -442,7 +449,8 @@ fn large_consolidation_benches(c: &mut Criterion) {
let mut group = c.benchmark_group("consolidation_large");
group.sample_size(10);
for (label, n) in [("10k", 10_000usize)] {
{
let (label, n) = ("10k", 10_000usize);
group.bench_with_input(
BenchmarkId::new("bench_consolidation_cycle", label),
&n,
@@ -459,7 +467,12 @@ fn large_consolidation_benches(c: &mut Criterion) {
for i in 0..n {
let embedding = make_vec(&mut rng, DIM);
let chunk = format!("memory record {i} with content");
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
engine.add_memory(
chunk,
embedding,
UntrustedSource::User,
now + i as f64,
);
}
engine
},
+3
View File
@@ -0,0 +1,3 @@
target/
artifacts/
coverage/
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "clawhdf5-agent-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
tempfile = "3"
[dependencies.clawhdf5-agent]
path = ".."
[workspace]
members = ["."]
[[bin]]
name = "fuzz_wal_replay"
path = "fuzz_targets/fuzz_wal_replay.rs"
doc = false
@@ -0,0 +1,36 @@
#![no_main]
//! Arbitrary bytes as a WAL file. Reading, and opening for append (which scans
//! the chain and truncates an unverifiable tail), must never panic, hang, or
//! allocate without bound — and after `open` repairs the file, everything
//! `read_entries` returned before must still be returned.
//!
//! The deterministic counterpart that runs in ordinary CI is
//! `tests/wal_properties.rs`; this target explores inputs it cannot reach.
use std::io::Write as _;
use clawhdf5_agent::wal::WalFile;
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let Ok(mut tmp) = tempfile::NamedTempFile::new() else {
return;
};
if tmp.write_all(data).and_then(|()| tmp.flush()).is_err() {
return;
}
let before = WalFile::read_entries(tmp.path()).map(|e| e.len());
// Only the chained formats (header versions 3 and 4) are repaired in
// place. `open` deliberately recreates a legacy-format file from scratch:
// `HDF5Memory::open` has already replayed its entries by then.
let chained = matches!(data.get(4), Some(3 | 4));
let opened = WalFile::open(tmp.path());
if !chained {
return;
}
if let (Ok(before), Ok(wal)) = (before, opened) {
drop(wal);
let after = WalFile::read_entries(tmp.path()).map(|e| e.len());
assert_eq!(after.ok(), Some(before), "open() changed what is replayable");
}
});
+1
View File
@@ -118,6 +118,7 @@ mod tests {
created_at: "2025-01-01T00:00:00Z".to_string(),
wal_enabled: false,
wal_max_entries: 500,
quantized_index: false,
}
}
+243 -5
View File
@@ -82,6 +82,68 @@ impl Default for AnomalyConfig {
}
}
// ---------------------------------------------------------------------------
// Pattern-match normalization
// ---------------------------------------------------------------------------
/// `true` for characters used to invisibly break up text without being
/// rendered (zero-width joiners/spacers, bidi control marks, the BOM/ZWNBSP,
/// soft hyphen, and the invisible math operators) — a common trick for
/// splitting a flagged word so a literal-substring check misses it while the
/// text still displays normally.
fn is_invisible_format_char(ch: char) -> bool {
matches!(
ch,
'\u{00AD}' // soft hyphen
| '\u{200B}' // zero width space
| '\u{200C}' // zero width non-joiner
| '\u{200D}' // zero width joiner
| '\u{200E}' // left-to-right mark
| '\u{200F}' // right-to-left mark
| '\u{2060}' // word joiner
| '\u{2061}'..='\u{2064}' // invisible times/plus/separator/function application
| '\u{202A}'..='\u{202E}' // bidi embedding/override controls
| '\u{FEFF}' // BOM / zero width no-break space
)
}
/// Normalize text before suspicious-pattern matching so the cheapest evasion
/// tricks — extra whitespace, zero-width characters, or punctuation spliced
/// between letters (e.g. `"s.y.s.t.e.m"`) — don't defeat a literal-substring
/// check. Lowercases, drops invisible-format and control characters, drops
/// punctuation entirely (not just collapses it, so split words rejoin), and
/// collapses whitespace runs to a single space.
///
/// Does not perform Unicode NFKC normalization or confusable/homoglyph
/// folding (see [`WriteAnomalyDetector::check_pattern_anomaly`]).
fn normalize_for_pattern_match(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut last_was_space = true; // trims leading whitespace for free
for ch in text.chars() {
if ch.is_control() || is_invisible_format_char(ch) {
continue;
}
if ch.is_whitespace() {
if !last_was_space {
out.push(' ');
last_was_space = true;
}
continue;
}
if ch.is_ascii_punctuation() {
continue;
}
for lower in ch.to_lowercase() {
out.push(lower);
}
last_was_space = false;
}
while out.ends_with(' ') {
out.pop();
}
out
}
// ---------------------------------------------------------------------------
// WriteEvent
// ---------------------------------------------------------------------------
@@ -99,6 +161,9 @@ pub struct WriteEvent {
// WriteAnomalyDetector
// ---------------------------------------------------------------------------
/// Upper bound on distinct session ids the detector tracks at once.
const MAX_TRACKED_SESSIONS: usize = 4096;
/// Tracks write events and raises alerts for suspicious behaviour.
#[derive(Debug)]
pub struct WriteAnomalyDetector {
@@ -127,6 +192,23 @@ impl WriteAnomalyDetector {
if event.timestamp > self.last_timestamp {
self.last_timestamp = event.timestamp;
}
// Bound the per-session map: a long-lived process sees an unbounded
// number of distinct session ids. When it overflows, forget the
// sessions with the fewest writes (they are furthest from the limit
// this map exists to enforce); the current one is re-added below.
if self.session_counts.len() >= MAX_TRACKED_SESSIONS
&& !self.session_counts.contains_key(&event.session_id)
{
let mut counts: Vec<u32> = self.session_counts.values().copied().collect();
let keep_from = counts.len() / 2;
counts.select_nth_unstable(keep_from);
let threshold = counts[keep_from];
self.session_counts.retain(|_, c| *c >= threshold);
if self.session_counts.len() >= MAX_TRACKED_SESSIONS {
// Every session had the same count: drop them all.
self.session_counts.clear();
}
}
*self
.session_counts
.entry(event.session_id.clone())
@@ -146,6 +228,13 @@ impl WriteAnomalyDetector {
/// Returns an alert if the number of writes in the last 60 seconds exceeds
/// `config.max_writes_per_minute`, or if any session has exceeded
/// `config.max_writes_per_session`.
///
/// The 60-second window is a single shared window across all
/// sessions/sources, so when it trips the alert additionally names the
/// top-contributing session and source within that window — a session
/// can never account for more of the window than the aggregate count, so
/// this attributes the same trip to its actual offender rather than
/// reporting only the anonymous aggregate total.
pub fn check_rate_anomaly(&self) -> Option<AnomalyAlert> {
let recent = self.window.len() as u32;
if recent > self.config.max_writes_per_minute {
@@ -156,11 +245,31 @@ impl WriteAnomalyDetector {
} else {
Severity::Medium
};
let mut per_session: std::collections::HashMap<&str, u32> =
std::collections::HashMap::new();
// MemorySource isn't Eq/Hash, so key by its Display string instead.
let mut per_source: std::collections::HashMap<String, u32> =
std::collections::HashMap::new();
for e in &self.window {
*per_session.entry(e.session_id.as_str()).or_insert(0) += 1;
*per_source.entry(e.source.to_string()).or_insert(0) += 1;
}
let top_session = per_session.iter().max_by_key(|&(_, &c)| c);
let top_source = per_source.iter().max_by_key(|&(_, &c)| c);
let attribution = match (top_session, top_source) {
(Some((session, s_count)), Some((source, r_count))) => format!(
"; top contributor: session '{session}' with {s_count} writes, \
source {source} with {r_count} writes"
),
_ => String::new(),
};
return Some(AnomalyAlert {
severity,
message: format!(
"Rate limit exceeded: {} writes in last 60s (max {})",
recent, self.config.max_writes_per_minute
"Rate limit exceeded: {} writes in last 60s (max {}){}",
recent, self.config.max_writes_per_minute, attribution
),
timestamp: self.last_timestamp,
});
@@ -188,11 +297,24 @@ impl WriteAnomalyDetector {
// -----------------------------------------------------------------------
/// Returns an alert if `chunk` contains any of the configured suspicious
/// patterns (case-insensitive).
/// patterns, after normalizing both sides to defeat the cheapest evasion
/// tricks (case, extra whitespace, punctuation between letters,
/// zero-width/invisible-formatting characters).
///
/// This does not perform Unicode NFKC normalization or confusable/
/// homoglyph folding (e.g. Cyrillic 'а' standing in for Latin 'a') —
/// that needs a per-codepoint confusable table (Unicode's
/// `confusables.txt`) beyond what's practical to hand-roll correctly,
/// and no such crate is a dependency of this crate today. A determined
/// attacker using homoglyphs can still evade these patterns.
pub fn check_pattern_anomaly(&self, chunk: &str) -> Option<AnomalyAlert> {
let lower = chunk.to_lowercase();
let normalized = normalize_for_pattern_match(chunk);
for pattern in &self.config.suspicious_patterns {
if lower.contains(pattern.as_str()) {
let normalized_pattern = normalize_for_pattern_match(pattern);
if normalized_pattern.is_empty() {
continue;
}
if normalized.contains(&normalized_pattern) {
let severity = if pattern.contains("ignore") || pattern.contains("override") {
Severity::Critical
} else if pattern.contains("system") || pattern.contains("jailbreak") {
@@ -327,6 +449,57 @@ mod tests {
assert!(alert.unwrap().severity >= Severity::Medium);
}
/// A single session dominating the shared 60s window must be named in
/// the alert, not just the anonymous aggregate count — this is the case
/// the separate cumulative max_writes_per_session check doesn't cover
/// (the window can trip before the session's lifetime total does).
#[test]
fn rate_anomaly_names_offending_session() {
let mut det = WriteAnomalyDetector::new(cfg());
for i in 0..11 {
det.record_write(event(
1.0 + i as f64 * 0.1,
"flood-session",
MemorySource::User,
));
}
let alert = det.check_rate_anomaly().unwrap();
assert!(
alert.message.contains("flood-session"),
"expected the offending session to be named, got: {}",
alert.message
);
}
/// When many distinct sessions jointly trip the shared window, the top
/// contributor named must actually be the one with the most writes.
#[test]
fn rate_anomaly_attributes_top_contributor_among_many_sessions() {
let mut det = WriteAnomalyDetector::new(cfg());
// 5 sessions with 1 write each (below any per-session limit)...
for i in 0..5 {
det.record_write(event(
1.0 + i as f64 * 0.1,
"minor-session",
MemorySource::User,
));
}
// ...plus one session responsible for the majority of the flood.
for i in 0..8 {
det.record_write(event(
2.0 + i as f64 * 0.1,
"major-session",
MemorySource::User,
));
}
let alert = det.check_rate_anomaly().unwrap();
assert!(
alert.message.contains("major-session"),
"expected the top contributor to be named, got: {}",
alert.message
);
}
#[test]
fn rate_anomaly_critical_3x() {
let mut det = WriteAnomalyDetector::new(cfg());
@@ -395,6 +568,71 @@ mod tests {
assert!(alert.is_some());
}
// --- Pattern-match evasion hardening ---
#[test]
fn pattern_defeats_extra_whitespace() {
let det = WriteAnomalyDetector::new(cfg());
let alert = det.check_pattern_anomaly("please ignore previous instructions");
assert!(alert.is_some(), "extra whitespace must not defeat matching");
}
#[test]
fn pattern_defeats_punctuation_splicing() {
let det = WriteAnomalyDetector::new(cfg());
let alert = det.check_pattern_anomaly("i.g.n.o.r.e p-r-e-v-i-o-u-s instructions");
assert!(
alert.is_some(),
"punctuation spliced between letters must not defeat matching"
);
}
#[test]
fn pattern_defeats_zero_width_space() {
let det = WriteAnomalyDetector::new(cfg());
// Zero-width space (U+200B) inserted mid-word.
let chunk = "ign\u{200B}ore previ\u{200B}ous instructions";
let alert = det.check_pattern_anomaly(chunk);
assert!(
alert.is_some(),
"zero-width space injection must not defeat matching"
);
}
#[test]
fn pattern_defeats_zero_width_joiner_and_bom() {
let det = WriteAnomalyDetector::new(cfg());
let chunk = "jail\u{200D}break\u{FEFF} attempt";
let alert = det.check_pattern_anomaly(chunk);
assert!(
alert.is_some(),
"ZWJ/BOM injection must not defeat matching"
);
}
#[test]
fn pattern_still_clean_after_normalization() {
let det = WriteAnomalyDetector::new(cfg());
// Normalization must not introduce false positives on ordinary text
// that merely contains punctuation and extra whitespace.
let alert =
det.check_pattern_anomaly("Well, I think... the weather is nice today, right?");
assert!(alert.is_none());
}
#[test]
fn normalize_for_pattern_match_examples() {
assert_eq!(
normalize_for_pattern_match("i.g.n.o.r.e p-r-e-v-i-o-u-s"),
"ignore previous"
);
assert_eq!(
normalize_for_pattern_match("ign\u{200B}ore previous"),
"ignore previous"
);
assert_eq!(normalize_for_pattern_match("SYSTEM:"), "system");
}
#[test]
fn pattern_jailbreak() {
let det = WriteAnomalyDetector::new(cfg());
+5 -1
View File
@@ -37,7 +37,7 @@
//! let mem = AsyncHDF5Memory::open_with(path, config).await?;
//! mem.save(entry).await?; // buffered → background writer
//! mem.save_batch(entries).await?; // also buffered
//! let results = mem.hybrid_search(emb, "query".into(), 0.7, 0.3, 5).await;
//! let results = mem.hybrid_search(emb, "query".into(), 0.4, 0.6, 5).await;
//! mem.shutdown().await?; // final flush + stop
//! ```
@@ -408,6 +408,10 @@ impl AsyncHDF5Memory {
let (tx, rx) = oneshot::channel();
let _ = self.write_tx.send(WriteCmd::Shutdown(tx)).await;
let _ = rx.await;
// The writer task has stopped, so nothing can write through this
// handle any more: release the single-writer lock now rather than at
// drop, so the store can be reopened while `self` is still in scope.
self.inner.lock().await.release_store_lock();
Ok(())
}
}
+410 -120
View File
@@ -3,12 +3,38 @@
//! Provides a standard BM25 (Okapi BM25) implementation with an in-memory
//! inverted index. Tombstoned documents are excluded from indexing and search.
//!
//! Optimizations:
//! - Cached IDF scores (don't recompute per query)
//! - Sorted posting lists by doc_id for cache-friendly access
//! - Block-Max WAND early termination
//! The index is **incremental**: [`BM25Index::add_document`] and
//! [`BM25Index::remove_document`] keep it exactly equivalent to one built from
//! scratch over the same live documents, so a store can maintain one index for
//! its lifetime instead of re-tokenising the whole corpus per query. To make
//! that possible IDF is computed at query time (it depends on the live
//! document count) rather than cached at build time.
//!
//! - Posting lists sorted by doc id
//! - Bounded-heap top-k; results ordered by score, then doc id (deterministic)
use std::collections::HashMap;
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap};
/// `f32` wrapper providing a total order (via `total_cmp`) so BM25 scores can
/// be kept in a `BinaryHeap`. Scores are always finite in practice (no NaN
/// inputs reach this path), so `total_cmp`'s NaN ordering is never exercised.
#[derive(Debug, Clone, Copy, PartialEq)]
struct HeapScore(f32);
impl Eq for HeapScore {}
impl PartialOrd for HeapScore {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for HeapScore {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.0.total_cmp(&other.0)
}
}
/// Default BM25 term-frequency saturation parameter.
const DEFAULT_K1: f32 = 1.2;
@@ -20,10 +46,11 @@ const DEFAULT_B: f32 = 0.75;
pub struct BM25Index {
/// Inverted index: token -> sorted list of (doc_id, term_frequency).
inverted: HashMap<String, Vec<(usize, u32)>>,
/// Cached IDF scores per token.
idf_cache: HashMap<String, f32>,
/// Number of tokens in each document (0 for tombstoned docs).
doc_lengths: Vec<u32>,
/// Sum of `doc_lengths` over live documents (keeps `avg_dl` exact under
/// incremental updates).
total_length: u64,
/// Average document length across non-tombstoned docs.
avg_dl: f32,
/// Number of non-tombstoned documents.
@@ -32,19 +59,27 @@ pub struct BM25Index {
k1: f32,
/// BM25 b parameter.
b: f32,
/// Applied to every document and query token, so the two always agree.
filter: TokenFilter,
}
impl BM25Index {
/// Build a BM25 index from a set of documents, excluding tombstoned entries.
pub fn build(documents: &[String], tombstones: &[u8]) -> Self {
Self::build_with(documents, tombstones, TokenFilter::default())
}
/// [`BM25Index::build`] with the token filter chosen explicitly.
pub fn build_with(documents: &[String], tombstones: &[u8], filter: TokenFilter) -> Self {
let mut index = Self {
inverted: HashMap::new(),
idf_cache: HashMap::new(),
doc_lengths: vec![0; documents.len()],
total_length: 0,
avg_dl: 0.0,
num_docs: 0,
k1: DEFAULT_K1,
b: DEFAULT_B,
filter,
};
index.index_documents(documents, tombstones);
index
@@ -56,112 +91,165 @@ impl BM25Index {
/// Uses Block-Max WAND for early termination when remaining documents
/// cannot beat the current top-k threshold.
pub fn search(&self, query: &str, k: usize) -> Vec<(usize, f32)> {
if self.num_docs == 0 || k == 0 {
if k == 0 {
return Vec::new();
}
let tokens = tokenize(query);
if tokens.is_empty() {
return Vec::new();
}
// Collect posting lists and cached IDF scores for query tokens
type QueryTerm<'a> = (&'a str, f32, &'a [(usize, u32)]);
let mut query_terms: Vec<QueryTerm<'_>> = Vec::new();
for token in &tokens {
if let (Some(postings), Some(&idf)) = (
self.inverted.get(token.as_str()),
self.idf_cache.get(token.as_str()),
) {
query_terms.push((token, idf, postings));
// Top-k with a bounded min-heap: O(matches * log k) instead of sorting
// every match. Ties break towards the lower doc id so results are
// deterministic.
let mut heap: BinaryHeap<Reverse<(HeapScore, Reverse<usize>)>> =
BinaryHeap::with_capacity(k.min(1024) + 1);
for (doc_id, score) in self.scores(query) {
heap.push(Reverse((HeapScore(score), Reverse(doc_id))));
if heap.len() > k {
heap.pop();
}
}
let mut results: Vec<(usize, f32)> = heap
.into_iter()
.map(|Reverse((HeapScore(score), Reverse(doc_id)))| (doc_id, score))
.collect();
results.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
results
}
if query_terms.is_empty() {
/// The BM25 score of **every** matching document, in doc-id order, unsorted
/// by score. Score fusion normalises over the whole matching set, so it
/// needs all of these but not their ranking; producing a ranked list of
/// every match (`search(query, corpus_len)`) spent most of its time sorting.
pub fn scores(&self, query: &str) -> Vec<(usize, f32)> {
if self.num_docs == 0 {
return Vec::new();
}
// Accumulate BM25 scores per document using WAND-style scoring
let mut scores: HashMap<usize, f32> = HashMap::new();
// Compute maximum possible contribution per term for WAND
let max_tf_score: Vec<f32> = query_terms
.iter()
.map(|(_, idf, _)| {
// Upper bound: max TF contribution when tf is high and dl is short
let max_tf_num = 10.0 * (self.k1 + 1.0);
let max_tf_den = 10.0 + self.k1 * (1.0 - self.b);
idf * max_tf_num / max_tf_den
})
.collect();
let total_max_contribution: f32 = max_tf_score.iter().sum();
// Threshold for WAND early termination
let mut threshold = 0.0f32;
let mut top_k_scores: Vec<f32> = Vec::with_capacity(k);
for (term_idx, (_, idf, postings)) in query_terms.iter().enumerate() {
for &(doc_id, freq) in *postings {
// Term-at-a-time accumulation into a dense array: a common term has a
// posting per document, and hashing each one dominated query time.
// IDF is computed here rather than cached at build time: it depends on
// the live document count, which changes with every incremental
// add/remove, and costs one `ln` per query term.
let mut acc = vec![0.0f32; self.doc_lengths.len()];
let mut matched = false;
for token in tokenize_with(query, self.filter) {
let Some(postings) = self.inverted.get(token.as_str()) else {
continue;
};
matched = true;
let df = postings.len() as f32;
let idf = ((self.num_docs as f32 - df + 0.5) / (df + 0.5) + 1.0).ln();
for &(doc_id, freq) in postings {
let dl = self.doc_lengths[doc_id] as f32;
let freq_f = freq as f32;
let tf = (freq_f * (self.k1 + 1.0))
/ (freq_f + self.k1 * (1.0 - self.b + self.b * dl / self.avg_dl));
let contribution = idf * tf;
let entry = scores.entry(doc_id).or_insert(0.0);
*entry += contribution;
// WAND check: if this doc's current partial score + remaining
// max terms can't beat threshold, we can skip (but we still
// accumulate since we process term-at-a-time)
if term_idx == query_terms.len() - 1 {
// Last term: check if this doc beats threshold
let final_score = *entry;
if final_score > threshold && top_k_scores.len() >= k {
// Update threshold
top_k_scores
.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
if final_score > top_k_scores[k - 1] {
top_k_scores[k - 1] = final_score;
top_k_scores.sort_by(|a, b| {
b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
});
threshold = top_k_scores[k - 1];
}
} else if top_k_scores.len() < k {
top_k_scores.push(final_score);
if top_k_scores.len() == k {
top_k_scores.sort_by(|a, b| {
b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
});
threshold = top_k_scores[k - 1];
}
}
}
}
// After processing each term, check if remaining terms can
// possibly produce results above threshold
let remaining_max: f32 = max_tf_score[term_idx + 1..].iter().sum();
if remaining_max < threshold && total_max_contribution > 0.0 {
// Early termination: remaining terms can't produce new top-k
// entries on their own. But existing partial scores may still
// be updated, so we continue (WAND is approximate here).
let _ = remaining_max; // hint to compiler
acc[doc_id] += idf * tf;
}
}
if !matched {
return Vec::new();
}
// Every contribution is strictly positive (idf = ln(1 + x), x > 0), so
// a zero entry is a document no query term touched.
acc.into_iter()
.enumerate()
.filter(|&(_, score)| score > 0.0)
.collect()
}
let mut results: Vec<(usize, f32)> = scores.into_iter().collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
results.truncate(k);
results
/// The token filter this index was built with.
pub fn token_filter(&self) -> TokenFilter {
self.filter
}
/// Number of document slots (live or not) the index covers. Ids are
/// positions in the document list it mirrors.
pub fn len(&self) -> usize {
self.doc_lengths.len()
}
/// `true` when the index covers no document slots.
pub fn is_empty(&self) -> bool {
self.doc_lengths.is_empty()
}
/// Index `text` as document `doc_id`, which must be the next free id
/// (`self.len()`) or an existing slot that is currently empty (removed or
/// tombstoned). After any sequence of `add_document` / `remove_document`
/// calls the index scores exactly as one freshly built from the same live
/// documents.
pub fn add_document(&mut self, doc_id: usize, text: &str) {
if doc_id >= self.doc_lengths.len() {
self.doc_lengths.resize(doc_id + 1, 0);
}
debug_assert_eq!(self.doc_lengths[doc_id], 0, "slot {doc_id} is occupied");
let tokens = tokenize_with(text, self.filter);
let mut term_freqs: HashMap<&str, u32> = HashMap::new();
for token in &tokens {
*term_freqs.entry(token).or_insert(0) += 1;
}
for (token, freq) in term_freqs {
let postings = self.inverted.entry(token.to_string()).or_default();
// Posting lists stay sorted by doc id; appends are the common case.
match postings.last() {
Some(&(last, _)) if last >= doc_id => {
let at = postings.partition_point(|&(id, _)| id < doc_id);
postings.insert(at, (doc_id, freq));
}
_ => postings.push((doc_id, freq)),
}
}
self.doc_lengths[doc_id] = tokens.len() as u32;
self.total_length += tokens.len() as u64;
self.num_docs += 1;
self.refresh_avg_dl();
}
/// Extend the index to cover `len` document slots, leaving new ones empty.
/// Used for slots that hold no live document (tombstoned records).
pub fn pad_to(&mut self, len: usize) {
if len > self.doc_lengths.len() {
self.doc_lengths.resize(len, 0);
}
}
/// Remove document `doc_id`, whose indexed text was `text`. The text is
/// needed to find its postings; pass exactly what was added.
pub fn remove_document(&mut self, doc_id: usize, text: &str) {
let tokens = tokenize_with(text, self.filter);
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for token in &tokens {
if !seen.insert(token) {
continue;
}
if let Some(postings) = self.inverted.get_mut(token.as_str()) {
if let Ok(at) = postings.binary_search_by_key(&doc_id, |&(id, _)| id) {
postings.remove(at);
}
if postings.is_empty() {
self.inverted.remove(token.as_str());
}
}
}
if let Some(len) = self.doc_lengths.get_mut(doc_id) {
self.total_length = self.total_length.saturating_sub(u64::from(*len));
*len = 0;
}
self.num_docs = self.num_docs.saturating_sub(1);
self.refresh_avg_dl();
}
fn refresh_avg_dl(&mut self) {
self.avg_dl = if self.num_docs > 0 {
self.total_length as f32 / self.num_docs as f32
} else {
0.0
};
}
/// Rebuild the index from scratch (e.g., after compaction).
pub fn rebuild(&mut self, documents: &[String], tombstones: &[u8]) {
self.inverted.clear();
self.idf_cache.clear();
self.doc_lengths = vec![0; documents.len()];
self.total_length = 0;
self.avg_dl = 0.0;
self.num_docs = 0;
self.index_documents(documents, tombstones);
@@ -177,7 +265,7 @@ impl BM25Index {
continue;
}
let tokens = tokenize(doc);
let tokens = tokenize_with(doc, self.filter);
let doc_len = tokens.len() as u32;
self.doc_lengths[i] = doc_len;
total_length += doc_len as u64;
@@ -198,33 +286,98 @@ impl BM25Index {
}
self.num_docs = count;
self.avg_dl = if count > 0 {
total_length as f32 / count as f32
} else {
0.0
};
self.total_length = total_length;
self.refresh_avg_dl();
// Sort posting lists by doc_id for cache-friendly access
for postings in self.inverted.values_mut() {
postings.sort_by_key(|&(doc_id, _)| doc_id);
}
// Pre-compute and cache IDF scores
for (token, postings) in &self.inverted {
let df = postings.len() as f32;
let idf = ((self.num_docs as f32 - df + 0.5) / (df + 0.5) + 1.0).ln();
self.idf_cache.insert(token.clone(), idf);
}
}
}
/// Tokenize a string: lowercase, split on non-alphanumeric characters,
/// filter empty tokens.
/// What [`tokenize_with`] does to each token after splitting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TokenFilter {
/// Lowercase and split only — the original behaviour.
#[default]
Plain,
/// Also strip common English inflections, so "running" and "runs" match
/// "run". Conservative on purpose: only plural and past/continuous verb
/// endings, and only on tokens long enough that stripping leaves a real
/// stem. A stemmer earns its keep by conflating *related* words; an
/// aggressive one also conflates unrelated ones ("universe"/"university"),
/// which costs precision.
Stemmed,
}
/// Strip common English inflections from an already-lowercased token.
///
/// Applied identically to documents and queries, so the pair only has to agree
/// with itself — the stem need not be a real word.
fn stem(token: &str) -> &str {
// Below this, stripping does more harm than good ("bed" -> "b").
const MIN_STEM: usize = 4;
let strip = |suffix: &str, min_len: usize| -> Option<&str> {
let stem = token.strip_suffix(suffix)?;
(stem.len() >= min_len).then_some(stem)
};
// Plurals first: "studies" -> "studi", "classes" -> "class", "cats" -> "cat".
// "ies" keeps its "i" so the result meets "-ied" ("studied" -> "studi").
if let Some(stem) = strip("ies", 2) {
return &token[..stem.len() + 1];
}
for suffix in ["sses", "shes", "ches", "xes", "zes"] {
if let Some(stem) = strip(suffix, MIN_STEM - 1) {
// Keep the sibilant: "classes" -> "class", not "clas".
return &token[..stem.len() + 2];
}
}
// Verb endings before the bare plural, so "raced" doesn't become "raced".
if let Some(stem) = strip("ing", MIN_STEM - 1).or_else(|| strip("ed", MIN_STEM - 1)) {
return undouble(stem);
}
if !token.ends_with("ss")
&& !token.ends_with("us")
&& !token.ends_with("is")
&& let Some(stem) = strip("s", MIN_STEM - 1)
{
return stem;
}
token
}
/// "runn" -> "run": undo the consonant doubling that "-ing"/"-ed" introduce.
fn undouble(stem: &str) -> &str {
let mut chars = stem.chars().rev();
let (Some(last), Some(prev)) = (chars.next(), chars.next()) else {
return stem;
};
let doubled = last == prev && !"aeiou".contains(last) && last.is_ascii_alphabetic();
if doubled && stem.len() > 3 {
&stem[..stem.len() - 1]
} else {
stem
}
}
#[cfg(test)]
fn tokenize(text: &str) -> Vec<String> {
tokenize_with(text, TokenFilter::Plain)
}
/// Split `text` into scoring tokens under `filter`.
pub fn tokenize_with(text: &str, filter: TokenFilter) -> Vec<String> {
text.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.map(|token| match filter {
TokenFilter::Plain => token.to_string(),
TokenFilter::Stemmed => stem(token).to_string(),
})
.collect()
}
@@ -370,24 +523,21 @@ mod tests {
}
#[test]
fn cached_idf_consistent_with_computed() {
fn score_matches_the_bm25_formula() {
let docs = vec![
"rust programming".to_string(),
"rust systems".to_string(),
"python scripting".to_string(),
];
let tombstones = vec![0, 0, 0];
let index = BM25Index::build(&docs, &tombstones);
let index = BM25Index::build(&docs, &[0, 0, 0]);
// IDF for "rust" (appears in 2 of 3 docs)
let idf_rust = index.idf_cache.get("rust").unwrap();
let expected_idf = ((3.0f32 - 2.0 + 0.5) / (2.0 + 0.5) + 1.0).ln();
assert!(
(idf_rust - expected_idf).abs() < 1e-6,
"cached IDF mismatch: {} vs {}",
idf_rust,
expected_idf
);
// "python": df = 1 of N = 3. Every doc has the average length (2) and
// tf = 1, so the tf factor is exactly 1 and the score is the IDF.
let results = index.search("python", 3);
let expected_idf = ((3.0f32 - 1.0 + 0.5) / (1.0 + 0.5) + 1.0).ln();
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 2);
assert!((results[0].1 - expected_idf).abs() < 1e-6, "{results:?}");
}
#[test]
@@ -451,4 +601,144 @@ mod tests {
);
}
}
/// Documents drawn from a small vocabulary so terms collide heavily.
fn random_doc(state: &mut u64) -> String {
const VOCAB: &[&str] = &[
"alpha", "beta", "gamma", "delta", "eps", "zeta", "eta", "x1",
];
let mut next = || {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(*state >> 33) as usize
};
let len = 1 + next() % 9;
(0..len)
.map(|_| VOCAB[next() % VOCAB.len()])
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn incremental_updates_match_a_fresh_build_exactly() {
for seed in 0..60u64 {
let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
let mut docs: Vec<String> = Vec::new();
let mut tombstones: Vec<u8> = Vec::new();
let mut index = BM25Index::build(&docs, &tombstones);
for step in 0..80 {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
let live: Vec<usize> = (0..docs.len()).filter(|&i| tombstones[i] == 0).collect();
match (state >> 40) % 4 {
0 if !live.is_empty() => {
// delete
let id = live[(state >> 20) as usize % live.len()];
index.remove_document(id, &docs[id]);
tombstones[id] = 1;
}
1 if !live.is_empty() => {
// update in place
let id = live[(state >> 20) as usize % live.len()];
let new_text = random_doc(&mut state);
index.remove_document(id, &docs[id]);
index.add_document(id, &new_text);
docs[id] = new_text;
}
_ => {
let text = random_doc(&mut state);
index.add_document(docs.len(), &text);
docs.push(text);
tombstones.push(0);
}
}
let fresh = BM25Index::build(&docs, &tombstones);
for query in ["alpha", "beta gamma", "x1 zeta alpha delta", "missing"] {
let got = index.search(query, 5);
let want = fresh.search(query, 5);
assert_eq!(got.len(), want.len(), "seed {seed} step {step} {query:?}");
for (g, w) in got.iter().zip(&want) {
assert_eq!(
g.0, w.0,
"seed {seed} step {step} {query:?}: {got:?} vs {want:?}"
);
assert!(
(g.1 - w.1).abs() < 1e-5,
"seed {seed} step {step} {query:?}"
);
}
}
}
}
}
#[test]
fn scores_is_the_unranked_form_of_a_full_search() {
let mut state = 99u64;
let docs: Vec<String> = (0..200).map(|_| random_doc(&mut state)).collect();
let tombstones: Vec<u8> = (0..200).map(|i| u8::from(i % 7 == 0)).collect();
let index = BM25Index::build(&docs, &tombstones);
for query in ["alpha", "beta gamma x1", "missing", ""] {
let mut all = index.scores(query);
all.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
assert_eq!(all, index.search(query, docs.len()), "{query:?}");
assert!(all.iter().all(|(id, _)| tombstones[*id] == 0));
}
}
#[test]
fn stemming_conflates_inflections_of_the_same_word() {
let stem_of = |w: &str| tokenize_with(w, TokenFilter::Stemmed).pop().unwrap();
// Pairs that should meet.
for (a, b) in [
("running", "runs"),
("trained", "training"),
("miles", "mile"),
("studies", "studied"),
("mentioned", "mentioning"),
("classes", "class"),
("planned", "planning"),
] {
assert_eq!(stem_of(a), stem_of(b), "{a} / {b} should share a stem");
}
// Pairs that must stay apart. Note which pairs are deliberately absent:
// "bed"/"bedding" and "gas"/"gassed" both collapse to one stem, which
// is what Porter does too and is right — they are related words.
for (a, b) in [
("universe", "university"),
("business", "busy"),
("this", "thing"),
] {
assert_ne!(stem_of(a), stem_of(b), "{a} / {b} must not be conflated");
}
// Short words and non-inflections are left alone.
for word in ["run", "bus", "is", "his", "data", "gas"] {
assert_eq!(stem_of(word), word, "{word} should be untouched");
}
}
#[test]
fn stemming_is_off_by_default_and_applied_consistently() {
assert_eq!(tokenize("Running miles"), ["running", "miles"]);
assert_eq!(
tokenize_with("Running miles", TokenFilter::Stemmed),
["run", "mile"]
);
// A query inflected differently from the document still matches.
let docs = vec!["I ran while training for the marathon".to_string()];
let plain = BM25Index::build_with(&docs, &[0], TokenFilter::Plain);
let stemmed = BM25Index::build_with(&docs, &[0], TokenFilter::Stemmed);
assert!(plain.search("trains", 1).is_empty());
assert_eq!(stemmed.search("trains", 1).len(), 1);
}
#[test]
fn ties_break_towards_the_lower_doc_id() {
let docs: Vec<String> = (0..6).map(|_| "same text".to_string()).collect();
let index = BM25Index::build(&docs, &[0; 6]);
let ids: Vec<usize> = index.search("same", 3).into_iter().map(|r| r.0).collect();
assert_eq!(ids, [0, 1, 2]);
}
}
+267 -13
View File
@@ -2,11 +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>>,
pub embeddings: Embeddings,
pub source_channels: Vec<String>,
pub timestamps: Vec<f64>,
pub session_ids: Vec<String>,
@@ -23,7 +155,7 @@ impl MemoryCache {
pub fn new(embedding_dim: usize) -> Self {
Self {
chunks: Vec::new(),
embeddings: Vec::new(),
embeddings: Embeddings::new(embedding_dim),
source_channels: Vec::new(),
timestamps: Vec::new(),
session_ids: Vec::new(),
@@ -35,6 +167,16 @@ impl MemoryCache {
}
}
/// 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).
pub fn len(&self) -> usize {
self.chunks.len()
@@ -62,7 +204,7 @@ impl MemoryCache {
let idx = self.chunks.len();
let norm = vector_search::compute_norm(&embedding);
self.chunks.push(chunk);
self.embeddings.push(embedding);
self.embeddings.push(&embedding);
self.source_channels.push(source_channel);
self.timestamps.push(timestamp);
self.session_ids.push(session_id);
@@ -100,7 +242,7 @@ impl MemoryCache {
if idx < self.chunks.len() {
let norm = vector_search::compute_norm(&embedding);
self.chunks[idx] = chunk;
self.embeddings[idx] = embedding;
self.embeddings.set(idx, &embedding);
self.source_channels[idx] = source_channel;
self.timestamps[idx] = timestamp;
self.session_ids[idx] = session_id;
@@ -152,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());
@@ -165,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;
@@ -177,12 +320,123 @@ impl MemoryCache {
(removed, index_map)
}
/// Flatten all embeddings into a single Vec<f32> for HDF5 storage.
pub fn flat_embeddings(&self) -> Vec<f32> {
let mut flat = Vec::with_capacity(self.embeddings.len() * self.embedding_dim);
for emb in &self.embeddings {
flat.extend_from_slice(emb);
}
flat
/// 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()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `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.as_flat(), expected);
}
#[test]
fn push_keeps_flat_buffer_in_sync() {
let mut cache = MemoryCache::new(3);
cache.push(
"a".into(),
vec![1.0, 2.0, 3.0],
"chan".into(),
0.0,
"s1".into(),
String::new(),
);
cache.push(
"b".into(),
vec![4.0, 5.0, 6.0],
"chan".into(),
1.0,
"s1".into(),
String::new(),
);
assert_flat_in_sync(&cache);
assert_eq!(
cache.embeddings.as_flat(),
vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
);
}
#[test]
fn update_keeps_flat_buffer_in_sync() {
let mut cache = MemoryCache::new(3);
cache.push(
"a".into(),
vec![1.0, 2.0, 3.0],
"chan".into(),
0.0,
"s1".into(),
String::new(),
);
cache.push(
"b".into(),
vec![4.0, 5.0, 6.0],
"chan".into(),
1.0,
"s1".into(),
String::new(),
);
cache.update(
0,
"a2".into(),
vec![7.0, 8.0, 9.0],
"chan".into(),
2.0,
"s1".into(),
);
assert_flat_in_sync(&cache);
assert_eq!(
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"
);
}
#[test]
fn compact_keeps_flat_buffer_in_sync() {
let mut cache = MemoryCache::new(2);
cache.push(
"a".into(),
vec![1.0, 1.0],
"chan".into(),
0.0,
"s1".into(),
String::new(),
);
cache.push(
"b".into(),
vec![2.0, 2.0],
"chan".into(),
1.0,
"s1".into(),
String::new(),
);
cache.push(
"c".into(),
vec![3.0, 3.0],
"chan".into(),
2.0,
"s1".into(),
String::new(),
);
cache.mark_deleted(1);
cache.compact();
assert_flat_in_sync(&cache);
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
.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]);
}
}
+148 -31
View File
@@ -16,6 +16,55 @@ pub enum MemorySource {
Correction,
}
/// Source classification for content whose true origin is *not*
/// independently verified by the caller of [`ConsolidationEngine::add_memory`]
/// — arbitrary text forwarded from a user, a tool's output, or a retrieval
/// pipeline. This is the only source set `add_memory` accepts; it cannot
/// claim the `System`/`Correction` importance boost (see [`TrustedSource`]
/// and [`ConsolidationEngine::add_trusted_memory`]) — a caller passing
/// through untrusted content has no way to self-report an elevated trust
/// level through this entry point.
#[derive(Clone, Debug, PartialEq)]
pub enum UntrustedSource {
User,
Tool,
Retrieval,
}
impl From<UntrustedSource> for MemorySource {
fn from(s: UntrustedSource) -> Self {
match s {
UntrustedSource::User => MemorySource::User,
UntrustedSource::Tool => MemorySource::Tool,
UntrustedSource::Retrieval => MemorySource::Retrieval,
}
}
}
/// Source classification for content whose elevated trust level has been
/// independently verified by the caller — e.g. the library's own
/// system-generated text, or a caller that ran its own correction-cue
/// detection (as `memory_strategy::SaveOnUserCorrection` does) rather than
/// forwarding a caller-supplied label verbatim. `MemorySource::System`/
/// `Correction` get elevated importance weighting in
/// [`ImportanceScorer::score_correction`]; only reachable through
/// [`ConsolidationEngine::add_trusted_memory`], a distinct entry point from
/// the one untrusted content is passed through.
#[derive(Clone, Debug, PartialEq)]
pub enum TrustedSource {
System,
Correction,
}
impl From<TrustedSource> for MemorySource {
fn from(s: TrustedSource) -> Self {
match s {
TrustedSource::System => MemorySource::System,
TrustedSource::Correction => MemorySource::Correction,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum MemoryTier {
Working,
@@ -118,7 +167,7 @@ impl ImportanceScorer {
/// Novelty score: 1.0 max cosine similarity against all existing records.
/// Returns 1.0 when there are no existing memories.
pub fn score_surprise(embedding: &[f32], existing_memories: &[MemoryRecord]) -> f32 {
pub fn score_surprise(embedding: &[f32], existing_memories: &[&MemoryRecord]) -> f32 {
if existing_memories.is_empty() {
return 1.0;
}
@@ -199,21 +248,51 @@ impl ConsolidationEngine {
}
}
/// Add a new memory to the Working tier.
/// Add a new memory to the Working tier from an untrusted/ordinary origin
/// (User, Tool, or Retrieval). This is the entry point for arbitrary
/// caller-supplied content — it cannot claim the elevated System/
/// Correction importance boost. Use [`Self::add_trusted_memory`] for
/// content whose elevated trust level the caller has independently
/// verified.
///
/// Importance is scored against existing Working-tier records only.
pub fn add_memory(
&mut self,
chunk: String,
embedding: Vec<f32>,
source: UntrustedSource,
now: f64,
) -> u64 {
self.add_memory_with_source(chunk, embedding, source.into(), now)
}
/// Add a new memory tagged System or Correction, which get elevated
/// importance weighting in [`ImportanceScorer::score_correction`]. Only
/// call this from code that has independently verified the origin (the
/// library's own system-generated text, or a caller that ran its own
/// correction-cue detection) — never from a path that forwards a
/// caller-supplied trust label verbatim.
pub fn add_trusted_memory(
&mut self,
chunk: String,
embedding: Vec<f32>,
source: TrustedSource,
now: f64,
) -> u64 {
self.add_memory_with_source(chunk, embedding, source.into(), now)
}
fn add_memory_with_source(
&mut self,
chunk: String,
embedding: Vec<f32>,
source: MemorySource,
now: f64,
) -> u64 {
let working: Vec<MemoryRecord> = self
let working: Vec<&MemoryRecord> = self
.records
.iter()
.filter(|r| r.tier == MemoryTier::Working)
.cloned()
.collect();
let surprise = ImportanceScorer::score_surprise(&embedding, &working);
@@ -281,7 +360,7 @@ impl ConsolidationEngine {
if working_count > capacity {
let evict_n = working_count - capacity;
// Collect the ids of the records to evict (lowest decay = first in sorted list).
let evict_ids: Vec<u64> = working_indices[..evict_n]
let evict_ids: std::collections::HashSet<u64> = working_indices[..evict_n]
.iter()
.map(|&i| self.records[i].id)
.collect();
@@ -342,7 +421,7 @@ impl ConsolidationEngine {
});
let evict_n = episodic_count - episodic_capacity;
let evict_ids: Vec<u64> = episodic_indices[..evict_n]
let evict_ids: std::collections::HashSet<u64> = episodic_indices[..evict_n]
.iter()
.map(|&i| self.records[i].id)
.collect();
@@ -419,13 +498,44 @@ mod tests {
// ---------------------------------------------------------------------------
// 2. Add memory — basic
// ---------------------------------------------------------------------------
/// add_trusted_memory(TrustedSource::Correction) must actually produce a
/// MemorySource::Correction record — the only way to reach that elevated
/// classification, since add_memory's UntrustedSource has no such variant.
#[test]
fn test_add_trusted_memory_sets_correction_source() {
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
let id = engine.add_trusted_memory(
"verified correction".to_string(),
unit_vec(4, 0),
TrustedSource::Correction,
0.0,
);
let rec = engine.get_by_id(id).unwrap();
assert_eq!(rec.source, MemorySource::Correction);
}
/// add_trusted_memory(TrustedSource::System) must produce a
/// MemorySource::System record.
#[test]
fn test_add_trusted_memory_sets_system_source() {
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
let id = engine.add_trusted_memory(
"bootstrap text".to_string(),
unit_vec(4, 0),
TrustedSource::System,
0.0,
);
let rec = engine.get_by_id(id).unwrap();
assert_eq!(rec.source, MemorySource::System);
}
#[test]
fn test_add_memory_basic() {
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
let id = engine.add_memory(
"Hello world".to_string(),
unit_vec(4, 0),
MemorySource::User,
UntrustedSource::User,
1_000_000.0,
);
assert_eq!(id, 0);
@@ -453,7 +563,7 @@ mod tests {
#[test]
fn test_importance_scorer_surprise_identical() {
let emb = unit_vec(4, 0);
let existing = vec![MemoryRecord {
let existing = [MemoryRecord {
id: 0,
chunk: "existing".to_string(),
embedding: emb.clone(),
@@ -464,7 +574,8 @@ mod tests {
created_at: 0.0,
source: MemorySource::User,
}];
let score = ImportanceScorer::score_surprise(&emb, &existing);
let existing_refs: Vec<&MemoryRecord> = existing.iter().collect();
let score = ImportanceScorer::score_surprise(&emb, &existing_refs);
assert!(score < 0.01, "expected ~0.0, got {score}");
}
@@ -492,23 +603,20 @@ mod tests {
fn test_importance_scorer_length() {
assert!((ImportanceScorer::score_length("")).abs() < f32::EPSILON);
// 50 words → 0.5
let fifty_words = std::iter::repeat("word")
.take(50)
let fifty_words = std::iter::repeat_n("word", 50)
.collect::<Vec<_>>()
.join(" ");
let s50 = ImportanceScorer::score_length(&fifty_words);
assert!((s50 - 0.5).abs() < 1e-5, "expected 0.5, got {s50}");
// 100 words → 1.0
let hundred_words = std::iter::repeat("word")
.take(100)
let hundred_words = std::iter::repeat_n("word", 100)
.collect::<Vec<_>>()
.join(" ");
assert_eq!(ImportanceScorer::score_length(&hundred_words), 1.0);
// 200 words → still 1.0 (clamped)
let two_hundred = std::iter::repeat("word")
.take(200)
let two_hundred = std::iter::repeat_n("word", 200)
.collect::<Vec<_>>()
.join(" ");
assert_eq!(ImportanceScorer::score_length(&two_hundred), 1.0);
@@ -582,9 +690,11 @@ mod tests {
// ---------------------------------------------------------------------------
#[test]
fn test_consolidate_eviction_working() {
let mut cfg = ConsolidationConfig::default();
cfg.working_capacity = 3;
cfg.working_to_episodic_threshold = 2.0; // never promote in this test
let cfg = ConsolidationConfig {
working_capacity: 3,
working_to_episodic_threshold: 2.0, // never promote in this test
..Default::default()
};
let mut engine = ConsolidationEngine::new(cfg);
// Add 5 records; all have very low importance so none get promoted.
@@ -592,7 +702,7 @@ mod tests {
let id = engine.add_memory(
"x".to_string(),
unit_vec(4, i as usize),
MemorySource::User,
UntrustedSource::User,
i as f64,
);
// Force low importance so promotion threshold is not crossed.
@@ -625,10 +735,10 @@ mod tests {
let cfg = ConsolidationConfig::default();
let mut engine = ConsolidationEngine::new(cfg);
let id = engine.add_memory(
let id = engine.add_trusted_memory(
"important memory".to_string(),
unit_vec(4, 0),
MemorySource::Correction,
TrustedSource::Correction,
0.0,
);
// Force importance above threshold.
@@ -661,7 +771,7 @@ mod tests {
let id = engine.add_memory(
"frequently accessed".to_string(),
unit_vec(4, 0),
MemorySource::User,
UntrustedSource::User,
0.0,
);
@@ -689,7 +799,12 @@ mod tests {
#[test]
fn test_access_memory_reactivation() {
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
let id = engine.add_memory("chunk".to_string(), unit_vec(4, 0), MemorySource::User, 0.0);
let id = engine.add_memory(
"chunk".to_string(),
unit_vec(4, 0),
UntrustedSource::User,
0.0,
);
engine.access_memory(id, 5000.0);
let rec = engine.get_by_id(id).unwrap();
@@ -710,11 +825,11 @@ mod tests {
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
// 2 Working
engine.add_memory("w1".to_string(), unit_vec(4, 0), MemorySource::User, 0.0);
engine.add_memory("w2".to_string(), unit_vec(4, 1), MemorySource::User, 0.0);
engine.add_memory("w1".to_string(), unit_vec(4, 0), UntrustedSource::User, 0.0);
engine.add_memory("w2".to_string(), unit_vec(4, 1), UntrustedSource::User, 0.0);
// 1 Episodic (manually set)
let id_e = engine.add_memory("e1".to_string(), unit_vec(4, 2), MemorySource::User, 0.0);
let id_e = engine.add_memory("e1".to_string(), unit_vec(4, 2), UntrustedSource::User, 0.0);
engine
.records
.iter_mut()
@@ -723,7 +838,7 @@ mod tests {
.tier = MemoryTier::Episodic;
// 1 Semantic (manually set)
let id_s = engine.add_memory("s1".to_string(), unit_vec(4, 3), MemorySource::User, 0.0);
let id_s = engine.add_memory("s1".to_string(), unit_vec(4, 3), UntrustedSource::User, 0.0);
engine
.records
.iter_mut()
@@ -742,9 +857,11 @@ mod tests {
// ---------------------------------------------------------------------------
#[test]
fn test_consolidate_episodic_eviction() {
let mut cfg = ConsolidationConfig::default();
cfg.episodic_capacity = 3;
cfg.working_to_episodic_threshold = 2.0; // never auto-promote from Working
let cfg = ConsolidationConfig {
episodic_capacity: 3,
working_to_episodic_threshold: 2.0, // never auto-promote from Working
..Default::default()
};
let mut engine = ConsolidationEngine::new(cfg);
// Seed 5 records directly in Episodic.
@@ -752,7 +869,7 @@ mod tests {
let id = engine.add_memory(
"episodic chunk".to_string(),
unit_vec(4, i as usize),
MemorySource::User,
UntrustedSource::User,
i as f64,
);
let rec = engine.records.iter_mut().find(|r| r.id == id).unwrap();
+14 -8
View File
@@ -777,8 +777,10 @@ mod tests {
#[test]
fn test_tech_disabled() {
let mut config = ExtractorConfig::default();
config.extract_technology = false;
let config = ExtractorConfig {
extract_technology: false,
..Default::default()
};
let e = EntityExtractor::new(config);
let entities = e.extract("We use Rust and Docker.");
assert!(
@@ -847,8 +849,10 @@ mod tests {
#[test]
fn test_date_disabled() {
let mut config = ExtractorConfig::default();
config.extract_dates = false;
let config = ExtractorConfig {
extract_dates: false,
..Default::default()
};
let e = EntityExtractor::new(config);
let entities = e.extract("Released on 2024-03-19.");
assert!(
@@ -981,8 +985,10 @@ mod tests {
#[test]
fn test_confidence_filter() {
let mut config = ExtractorConfig::default();
config.min_confidence = 0.95;
let config = ExtractorConfig {
min_confidence: 0.95,
..Default::default()
};
let e = EntityExtractor::new(config);
// Only dates (0.95) and techs (0.9) should survive; 0.9 < 0.95 filters techs.
let entities = e.extract("We use Rust since 2024-01-01.");
@@ -1002,7 +1008,7 @@ mod tests {
fn test_batch_dedup() {
let e = default_extractor();
let texts = ["We use Rust.", "Rust is fast.", "Also Rust for safety."];
let entities = e.extract_batch(&texts.iter().map(|s| *s).collect::<Vec<_>>());
let entities = e.extract_batch(&texts);
let rust_count = entities.iter().filter(|x| x.text == "Rust").count();
assert_eq!(rust_count, 1, "Rust should appear exactly once after dedup");
}
@@ -1011,7 +1017,7 @@ mod tests {
fn test_batch_multiple_types() {
let e = default_extractor();
let texts = ["Deploy with Docker.", "We merged last week."];
let entities = e.extract_batch(&texts.iter().map(|s| *s).collect::<Vec<_>>());
let entities = e.extract_batch(&texts);
assert!(
entities
.iter()
+219 -25
View File
@@ -28,13 +28,40 @@ use crate::vector_search;
pub fn hybrid_search(
query_embedding: &[f32],
query_text: &str,
vectors: &[Vec<f32>],
_chunks: &[String],
vectors: &(impl crate::vector_search::VectorSet + Sync + ?Sized),
chunks: &[String],
tombstones: &[u8],
bm25_index: &BM25Index,
vector_weight: f32,
keyword_weight: f32,
k: usize,
) -> Vec<(usize, f32)> {
hybrid_search_fused(
query_embedding,
query_text,
vectors,
chunks,
tombstones,
bm25_index,
Fusion::Weighted {
vector: vector_weight,
keyword: keyword_weight,
},
k,
)
}
/// [`hybrid_search`] with the fusion method chosen explicitly.
#[allow(clippy::too_many_arguments)]
pub fn hybrid_search_fused(
query_embedding: &[f32],
query_text: &str,
vectors: &(impl crate::vector_search::VectorSet + Sync + ?Sized),
_chunks: &[String],
tombstones: &[u8],
bm25_index: &BM25Index,
fusion: Fusion,
k: usize,
) -> Vec<(usize, f32)> {
// Get raw scores from both systems. Request all results so normalization
// covers the full distribution.
@@ -42,12 +69,12 @@ pub fn hybrid_search(
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)
@@ -58,9 +85,9 @@ pub fn hybrid_search(
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
}
};
let kw_scores = bm25_index.search(query_text, vectors.len());
let kw_scores = bm25_index.scores(query_text);
merge_vector_keyword(vec_scores, kw_scores, vector_weight, keyword_weight, k)
fuse(vec_scores, kw_scores, fusion, k)
}
/// Merge pre-computed vector-similarity and keyword scores into a single ranking.
@@ -76,29 +103,120 @@ pub fn merge_vector_keyword(
keyword_weight: f32,
k: usize,
) -> Vec<(usize, f32)> {
// Normalize each set to [0, 1].
let vec_normalized = normalize_scores(&vec_scores);
let kw_normalized = normalize_scores(&kw_scores);
fuse(
vec_scores,
kw_scores,
Fusion::Weighted {
vector: vector_weight,
keyword: keyword_weight,
},
k,
)
}
// Merge scores with weights.
let mut merged: HashMap<usize, f32> = HashMap::new();
/// How the vector and keyword stages are combined into one ranking.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Fusion {
/// Min-max normalise each stage over its own candidates, then take a
/// weighted sum. Uses the *scores*, so a stage that separates its
/// candidates sharply keeps that separation — and a stage whose candidates
/// are all near-identical contributes little.
Weighted {
/// Weight on the vector stage.
vector: f32,
/// Weight on the keyword stage.
keyword: f32,
},
/// Reciprocal rank fusion: each stage contributes `1 / (k + rank)`,
/// ignoring score magnitudes entirely. Robust when the two stages'
/// scores aren't comparable, at the cost of discarding confidence.
Rrf {
/// The rank-damping constant; 60 is the value from the original paper.
k: f32,
},
}
for (idx, score) in &vec_normalized {
*merged.entry(*idx).or_insert(0.0) += vector_weight * score;
impl Default for Fusion {
fn default() -> Self {
DEFAULT_FUSION
}
for (idx, score) in &kw_normalized {
*merged.entry(*idx).or_insert(0.0) += keyword_weight * score;
}
/// The fusion `hybrid_search` uses unless told otherwise.
///
/// The weights are not a guess: a sweep of every 0.1 step over the full
/// LongMemEval haystack (500 questions, real MiniLM embeddings) found the
/// long-standing 0.7/0.3 default *strictly dominated* — 0.4/0.6 is better at
/// Hit@1, Hit@5, Hit@10 and MRR, at both turn and session granularity. See
/// `BENCHMARKS.md`, "Weight sweep".
pub const DEFAULT_FUSION: Fusion = Fusion::Weighted {
vector: 0.4,
keyword: 0.6,
};
/// Combine one ranked candidate list from each stage into a single top-`k`.
///
/// Neither list need be sorted; both are consumed.
pub fn fuse(
vec_scores: Vec<(usize, f32)>,
kw_scores: Vec<(usize, f32)>,
fusion: Fusion,
k: usize,
) -> Vec<(usize, f32)> {
let mut merged: HashMap<usize, f32> = HashMap::new();
match fusion {
Fusion::Weighted { vector, keyword } => {
// Normalize each set to [0, 1].
for (idx, score) in &normalize_scores(&vec_scores) {
*merged.entry(*idx).or_insert(0.0) += vector * score;
}
for (idx, score) in &normalize_scores(&kw_scores) {
*merged.entry(*idx).or_insert(0.0) += keyword * score;
}
}
Fusion::Rrf { k: damping } => {
for mut stage in [vec_scores, kw_scores] {
// Rank 1 is the best score. Ties break by index so a stage's
// contribution doesn't depend on the candidate order it
// happened to be produced in.
stage.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
});
for (rank, (idx, _)) in stage.iter().enumerate() {
*merged.entry(*idx).or_insert(0.0) += 1.0 / (damping + (rank + 1) as f32);
}
}
}
}
let mut results: Vec<(usize, f32)> = merged.into_iter().collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
results.truncate(k);
// Index tie-break: `merged` is a HashMap, so without it the ties that
// survive differ from run to run.
let by_score_then_id = |a: &(usize, f32), b: &(usize, f32)| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
};
// Only the top k are wanted: partition them out, then order just those,
// instead of sorting every candidate (the keyword side can be the corpus).
if k == 0 {
return Vec::new();
}
if results.len() > k {
results.select_nth_unstable_by(k - 1, by_score_then_id);
results.truncate(k);
}
results.sort_by(by_score_then_id);
results
}
/// Normalize a set of scores to the [0, 1] range using min-max normalization.
///
/// If all scores are identical, returns 0.0 for each entry.
/// If all scores are identical there is no spread to normalise: each entry
/// gets 1.0 when that score is positive (all equally the best match) and 0.0
/// otherwise (nothing matched).
fn normalize_scores(scores: &[(usize, f32)]) -> Vec<(usize, f32)> {
if scores.is_empty() {
return Vec::new();
@@ -112,7 +230,13 @@ fn normalize_scores(scores: &[(usize, f32)]) -> Vec<(usize, f32)> {
let range = max - min;
if range == 0.0 {
return scores.iter().map(|(idx, _)| (*idx, 0.0)).collect();
// All candidates scored the same (including the single-candidate
// case), so min-max has no spread to work with. They are all equally
// the best match if that score is positive, and all non-matches
// otherwise. This used to return 0.0 unconditionally, which erased a
// lone perfect match from the fused score.
let level = if max > 0.0 { 1.0 } else { 0.0 };
return scores.iter().map(|(idx, _)| (*idx, level)).collect();
}
scores
@@ -146,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,
@@ -158,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)
@@ -174,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));
@@ -324,10 +448,80 @@ mod tests {
#[test]
fn normalize_scores_single() {
// A lone positive score is the best match there is, not a non-match.
let result = normalize_scores(&[(0, 5.0)]);
assert_eq!(result.len(), 1);
// Single score normalizes to 0.0 (range is 0)
assert_eq!(result[0].1, 0.0);
assert_eq!(result[0].1, 1.0);
}
#[test]
fn default_fusion_is_the_tuned_operating_point() {
// A sweep over the full LongMemEval haystack found 0.7/0.3 strictly
// dominated by 0.4/0.6 (BENCHMARKS.md). This guards the finding
// against being quietly undone.
assert_eq!(
DEFAULT_FUSION,
Fusion::Weighted {
vector: 0.4,
keyword: 0.6
}
);
}
#[test]
fn rrf_rewards_agreement_between_the_stages_and_ignores_magnitudes() {
// Doc 1 is second-best in both stages; doc 0 is best in one and absent
// from the other. RRF prefers the doc both stages liked.
let vec_scores = vec![(0, 100.0), (1, 0.9)];
let kw_scores = vec![(2, 5.0), (1, 4.9)];
let ranked = fuse(vec_scores, kw_scores, Fusion::Rrf { k: 60.0 }, 3);
assert_eq!(ranked[0].0, 1, "{ranked:?}");
// Scaling one stage's scores cannot change an RRF ranking, only the
// order within that stage can.
let a = fuse(
vec![(0, 1.0), (1, 0.5)],
vec![(1, 2.0), (0, 1.0)],
Fusion::Rrf { k: 60.0 },
2,
);
let b = fuse(
vec![(0, 1e6), (1, -3.0)],
vec![(1, 0.002), (0, 0.001)],
Fusion::Rrf { k: 60.0 },
2,
);
assert_eq!(
a.iter().map(|r| r.0).collect::<Vec<_>>(),
b.iter().map(|r| r.0).collect::<Vec<_>>()
);
}
#[test]
fn merge_top_k_matches_a_full_sort() {
// Many ties (scores repeat) so the index tie-break is exercised.
let vec_scores: Vec<(usize, f32)> = (0..300).map(|i| (i, ((i * 7) % 13) as f32)).collect();
let kw_scores: Vec<(usize, f32)> = (100..500).map(|i| (i, ((i * 5) % 11) as f32)).collect();
let everything =
merge_vector_keyword(vec_scores.clone(), kw_scores.clone(), 0.7, 0.3, 10_000);
assert_eq!(everything.len(), 500);
assert!(
everything
.windows(2)
.all(|w| { w[0].1 > w[1].1 || (w[0].1 == w[1].1 && w[0].0 < w[1].0) })
);
for k in [0, 1, 7, 50, 499, 500, 501] {
let top = merge_vector_keyword(vec_scores.clone(), kw_scores.clone(), 0.7, 0.3, k);
assert_eq!(top, everything[..k.min(500)], "k = {k}");
}
}
#[test]
fn normalize_scores_all_equal() {
let matched = normalize_scores(&[(0, 0.4), (1, 0.4)]);
assert!(matched.iter().all(|(_, s)| *s == 1.0));
let unmatched = normalize_scores(&[(0, 0.0), (1, 0.0)]);
assert!(unmatched.iter().all(|(_, s)| *s == 0.0));
}
#[test]
+121 -18
View File
@@ -50,6 +50,9 @@ impl RelationType {
pub struct Entity {
pub id: u64,
pub name: String,
/// Lowercased `name`, cached at construction time to avoid re-allocating
/// and re-lowercasing on every entity-resolution scan.
pub name_lower: String,
pub entity_type: String,
/// Index into the memory embeddings array, or -1 if none.
pub embedding_idx: i64,
@@ -69,6 +72,7 @@ impl Default for Entity {
Self {
id: 0,
name: String::new(),
name_lower: String::new(),
entity_type: String::new(),
embedding_idx: -1,
properties: HashMap::new(),
@@ -151,6 +155,55 @@ fn levenshtein(a: &str, b: &str) -> usize {
prev[nb]
}
// ---------------------------------------------------------------------------
// AdjacencyIndex
// ---------------------------------------------------------------------------
/// Adjacency index over a snapshot of `entities`/`relations`: an entity-id ->
/// entities-slice-index map, and an entity-id -> relation-indices map (edges
/// touching that entity as either source or target).
///
/// Built fresh per traversal call rather than cached on `KnowledgeCache`:
/// entities/relations are plain `pub` `Vec`s that get pushed to directly
/// (e.g. `schema.rs`'s load path bypasses `add_entity`/`add_relation`), so a
/// persistent index would need extra bookkeeping to avoid drifting stale. A
/// one-off O(V+E) build per call is still a large win over the O(V·E) (BFS)
/// / O(steps·active·E) (spreading activation) scans it replaces.
struct AdjacencyIndex {
entity_index: HashMap<u64, usize>,
by_entity: HashMap<u64, Vec<usize>>,
}
impl AdjacencyIndex {
fn build(entities: &[Entity], relations: &[Relation]) -> Self {
let mut entity_index = HashMap::with_capacity(entities.len());
for (i, e) in entities.iter().enumerate() {
entity_index.insert(e.id, i);
}
let mut by_entity: HashMap<u64, Vec<usize>> = HashMap::new();
for (i, r) in relations.iter().enumerate() {
by_entity.entry(r.src).or_default().push(i);
if r.tgt != r.src {
by_entity.entry(r.tgt).or_default().push(i);
}
}
Self {
entity_index,
by_entity,
}
}
/// Indices into `relations` of every edge touching `entity_id`.
fn relations_touching(&self, entity_id: u64) -> &[usize] {
self.by_entity
.get(&entity_id)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
}
// ---------------------------------------------------------------------------
// KnowledgeCache
// ---------------------------------------------------------------------------
@@ -198,6 +251,7 @@ impl KnowledgeCache {
self.entities.push(Entity {
id,
name: name.to_owned(),
name_lower: name.to_lowercase(),
entity_type: entity_type.to_owned(),
embedding_idx,
properties: HashMap::new(),
@@ -310,16 +364,22 @@ impl KnowledgeCache {
) -> (u64, bool) {
let lower_name = name.to_lowercase();
// Search for the closest existing entity.
let best = self
.entities
.iter()
.map(|e| {
let dist = levenshtein(&lower_name, &e.name.to_lowercase());
(e.id, dist)
})
.filter(|&(_, dist)| dist <= max_distance)
.min_by_key(|&(_, dist)| dist);
// Search for the closest existing entity, short-circuiting on an
// exact match since no closer candidate can exist.
let mut best: Option<(u64, usize)> = None;
for e in &self.entities {
let dist = levenshtein(&lower_name, &e.name_lower);
if dist > max_distance {
continue;
}
if dist == 0 {
best = Some((e.id, dist));
break;
}
if best.is_none_or(|(_, best_dist)| dist < best_dist) {
best = Some((e.id, dist));
}
}
if let Some((id, _)) = best {
return (id, false);
@@ -337,6 +397,7 @@ impl KnowledgeCache {
/// together with their discovered depth. The seed entity itself is NOT
/// included. Traversal follows both outgoing and incoming relation edges.
pub fn bfs_neighbors(&self, entity_id: u64, max_depth: usize) -> Vec<(Entity, usize)> {
let idx = AdjacencyIndex::build(&self.entities, &self.relations);
let mut visited: HashSet<u64> = HashSet::new();
let mut queue: VecDeque<(u64, usize)> = VecDeque::new();
let mut results: Vec<(Entity, usize)> = Vec::new();
@@ -349,11 +410,13 @@ impl KnowledgeCache {
continue;
}
// Collect neighbour IDs from outgoing and incoming edges.
let neighbours: Vec<u64> = self
.relations
// Collect neighbour IDs from outgoing and incoming edges touching
// this node only, instead of scanning every relation in the graph.
let neighbours: Vec<u64> = idx
.relations_touching(current_id)
.iter()
.filter_map(|r| {
.filter_map(|&i| {
let r = &self.relations[i];
if r.src == current_id {
Some(r.tgt)
} else if r.tgt == current_id {
@@ -366,9 +429,9 @@ impl KnowledgeCache {
for neighbour_id in neighbours {
if visited.insert(neighbour_id)
&& let Some(entity) = self.get_entity(neighbour_id)
&& let Some(&entity_idx) = idx.entity_index.get(&neighbour_id)
{
results.push((entity.clone(), depth + 1));
results.push((self.entities[entity_idx].clone(), depth + 1));
queue.push_back((neighbour_id, depth + 1));
}
}
@@ -439,6 +502,7 @@ impl KnowledgeCache {
min_activation: f32,
max_steps: usize,
) -> Vec<(u64, f32)> {
let idx = AdjacencyIndex::build(&self.entities, &self.relations);
let mut activation: HashMap<u64, f32> = HashMap::new();
// Initialise seeds with activation 1.0.
@@ -461,8 +525,10 @@ impl KnowledgeCache {
let mut any_spread = false;
for (source_id, source_score) in current {
// Spread to all neighbours via outgoing and incoming edges.
for rel in &self.relations {
// Spread only to edges touching this node, instead of
// scanning every relation in the graph per active node.
for &rel_idx in idx.relations_touching(source_id) {
let rel = &self.relations[rel_idx];
let neighbour_id = if rel.src == source_id {
rel.tgt
} else if rel.tgt == source_id {
@@ -855,6 +921,19 @@ mod tests {
assert_eq!(id, orig_id);
}
/// An exact match must win even when a near-match with a smaller Levenshtein
/// distance-to-zero gap was scanned first — the early exit on dist == 0
/// must not skip past a later exact match.
#[test]
fn test_resolve_or_create_exact_match_beats_earlier_fuzzy_candidate() {
let mut cache = KnowledgeCache::new();
cache.add_entity("Alyce", "person", -1); // dist 1 from "Alice"
let exact_id = cache.add_entity("Alice", "person", -1); // dist 0
let (id, created) = cache.resolve_or_create("Alice", "person", -1, 2);
assert!(!created);
assert_eq!(id, exact_id);
}
#[test]
fn test_resolve_or_create_no_match_beyond_threshold() {
let mut cache = KnowledgeCache::new();
@@ -1035,6 +1114,30 @@ mod tests {
assert!(b_score.unwrap() > 0.0);
}
/// A self-loop relation (src == tgt) must be visited exactly once by the
/// adjacency index, matching the pre-index behavior of iterating
/// `self.relations` directly (each relation processed once regardless of
/// how many of its endpoints match the current node).
#[test]
fn test_spreading_activation_self_loop_not_double_counted() {
let mut cache = KnowledgeCache::new();
let a = cache.add_entity("A", "node", -1);
cache.add_relation(a, a, "self", 1.0);
let result = cache.spreading_activation(&[a], 0.5, 0.0001, 1);
let a_score = result
.iter()
.find(|&&(id, _)| id == a)
.map(|&(_, s)| s)
.unwrap();
// Seed activation (1.0) plus exactly one spread contribution
// (1.0 * weight 1.0 * decay 0.5), not two.
assert!(
(a_score - 1.5).abs() < 1e-5,
"expected 1.5 (one self-loop contribution), got {a_score}"
);
}
#[test]
fn test_spreading_activation_decay_reduces_signal() {
let mut cache = KnowledgeCache::new();
File diff suppressed because it is too large Load Diff
+76 -70
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,
@@ -531,11 +531,14 @@ impl MemoryBackend for ClawhdfBackend {
query_embedding: &[f32],
k: usize,
) -> Vec<MemorySearchResult> {
// 1. Hybrid retrieval (RRF-blended vector + BM25).
// 1. Hybrid retrieval (vector + BM25, fused by score).
let candidates = k.saturating_mul(3).max(10);
let raw = self
.memory
.hybrid_search(query_embedding, query_text, 0.7, 0.3, candidates);
let raw = self.memory.hybrid_search_with(
query_embedding,
query_text,
crate::hybrid::DEFAULT_FUSION,
candidates,
);
if raw.is_empty() {
return Vec::new();
@@ -551,6 +554,7 @@ impl MemoryBackend for ClawhdfBackend {
timestamp: r.timestamp,
source_channel: r.source_channel.clone(),
raw_activation: r.activation,
relevance: r.score,
})
.collect();
@@ -713,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)
@@ -748,6 +754,69 @@ impl MemoryBackend for ClawhdfBackend {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Ephemeral tier methods on ClawhdfBackend
// ─────────────────────────────────────────────────────────────────────────────
impl ClawhdfBackend {
/// Enable the ephemeral (in-memory only) working memory tier.
pub fn enable_ephemeral(&mut self, config: crate::ephemeral::EphemeralConfig) {
self.memory.enable_ephemeral(config);
}
/// Store a text value in ephemeral memory.
///
/// Returns an error string if the ephemeral tier has not been enabled.
pub fn ephemeral_set(
&mut self,
key: &str,
value: &str,
ttl_secs: Option<f64>,
) -> Result<(), String> {
match self.memory.ephemeral_mut() {
Some(s) => {
s.set_text(key, value, ttl_secs);
Ok(())
}
None => Err("ephemeral tier not enabled".to_string()),
}
}
/// Retrieve a text value from ephemeral memory.
///
/// Returns `None` if the tier is disabled, the key is absent, or the
/// entry has expired.
pub fn ephemeral_get(&mut self, key: &str) -> Option<String> {
self.memory
.ephemeral_mut()?
.get_text(key)
.map(|s| s.to_string())
}
/// Delete a key from ephemeral memory.
///
/// Returns `true` if the key existed and was removed.
pub fn ephemeral_delete(&mut self, key: &str) -> bool {
self.memory.ephemeral_mut().is_some_and(|s| s.delete(key))
}
/// Return a snapshot of ephemeral tier statistics, or `None` if the tier
/// is not enabled.
pub fn ephemeral_stats(&self) -> Option<crate::ephemeral::EphemeralStats> {
self.memory.ephemeral().map(|s| s.stats())
}
/// Promote frequently-accessed ephemeral entries to persistent HDF5 storage.
///
/// Entries with `access_count >= min_access_count` are moved from the
/// ephemeral store into the persistent cache. Returns the count promoted.
pub fn promote_ephemeral(&mut self, min_access_count: u32) -> Result<usize, String> {
self.memory
.promote_ephemeral(min_access_count)
.map_err(|e| e.to_string())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
@@ -1333,66 +1402,3 @@ mod tests {
assert!(out.starts_with("# Title"));
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Ephemeral tier methods on ClawhdfBackend
// ─────────────────────────────────────────────────────────────────────────────
impl ClawhdfBackend {
/// Enable the ephemeral (in-memory only) working memory tier.
pub fn enable_ephemeral(&mut self, config: crate::ephemeral::EphemeralConfig) {
self.memory.enable_ephemeral(config);
}
/// Store a text value in ephemeral memory.
///
/// Returns an error string if the ephemeral tier has not been enabled.
pub fn ephemeral_set(
&mut self,
key: &str,
value: &str,
ttl_secs: Option<f64>,
) -> Result<(), String> {
match self.memory.ephemeral_mut() {
Some(s) => {
s.set_text(key, value, ttl_secs);
Ok(())
}
None => Err("ephemeral tier not enabled".to_string()),
}
}
/// Retrieve a text value from ephemeral memory.
///
/// Returns `None` if the tier is disabled, the key is absent, or the
/// entry has expired.
pub fn ephemeral_get(&mut self, key: &str) -> Option<String> {
self.memory
.ephemeral_mut()?
.get_text(key)
.map(|s| s.to_string())
}
/// Delete a key from ephemeral memory.
///
/// Returns `true` if the key existed and was removed.
pub fn ephemeral_delete(&mut self, key: &str) -> bool {
self.memory.ephemeral_mut().is_some_and(|s| s.delete(key))
}
/// Return a snapshot of ephemeral tier statistics, or `None` if the tier
/// is not enabled.
pub fn ephemeral_stats(&self) -> Option<crate::ephemeral::EphemeralStats> {
self.memory.ephemeral().map(|s| s.stats())
}
/// Promote frequently-accessed ephemeral entries to persistent HDF5 storage.
///
/// Entries with `access_count >= min_access_count` are moved from the
/// ephemeral store into the persistent cache. Returns the count promoted.
pub fn promote_ephemeral(&mut self, min_access_count: u32) -> Result<usize, String> {
self.memory
.promote_ephemeral(min_access_count)
.map_err(|e| e.to_string())
}
}
+17
View File
@@ -105,6 +105,23 @@ impl ProvenanceStore {
self.records.insert(provenance.record_id, provenance);
}
/// Renumber records after the store was compacted. `index_map[old]` is
/// the record's new id, or `None` if it was removed. Without this, every
/// surviving record's hash ends up filed under some other record's id and
/// the next integrity check reports a bogus mismatch.
pub fn remap(&mut self, index_map: &[Option<usize>]) {
let old = std::mem::take(&mut self.records);
for (old_id, mut prov) in old {
let new_id = usize::try_from(old_id)
.ok()
.and_then(|i| index_map.get(i).copied().flatten());
if let Some(new_id) = new_id {
prov.record_id = new_id as u64;
self.records.insert(new_id as u64, prov);
}
}
}
/// Retrieve by record ID.
pub fn get(&self, record_id: u64) -> Option<&MemoryProvenance> {
self.records.get(&record_id)
+162 -8
View File
@@ -6,6 +6,11 @@
//! - Temporal expansion (time-related rewrites)
//! - Morphological variants (stemming-like transforms)
//! - Knowledge graph expansion (entity aliases and neighbors)
//!
//! The morphological rules are crude suffix swaps, so some variants are not
//! words ("during" -> "dured"). That is tolerable for a BM25 stage, which
//! simply finds no postings for a nonsense term, but it means expansion is not
//! free: measure before enabling it on a retrieval path.
use crate::knowledge::KnowledgeCache;
@@ -340,20 +345,87 @@ fn contains_phrase(text: &str, phrase: &str) -> bool {
/// Replace a phrase in `text` case-insensitively, preserving surrounding case.
fn replace_word_case_insensitive(text: &str, from: &str, to: &str) -> String {
case_insensitive_replace(text, from, to)
replace_first(text, from, to, MatchKind::WholeWord)
}
fn case_insensitive_replace(text: &str, from: &str, to: &str) -> String {
let lower = text.to_lowercase();
let lower_from = from.to_lowercase();
if let Some(pos) = lower.find(&lower_from) {
let end = pos + from.len();
format!("{}{}{}", &text[..pos], to, &text[end..])
} else {
text.to_string()
replace_first(text, from, to, MatchKind::Substring)
}
/// Whether a match may fall inside a larger word.
#[derive(Clone, Copy, PartialEq)]
enum MatchKind {
/// Match anywhere, including inside another word.
Substring,
/// Match only when both ends sit on a word boundary.
WholeWord,
}
/// Replace the first case-insensitive match of `from` in `text` with `to`.
///
/// Matching walks the *original* string rather than a lowercased copy. The
/// previous implementation searched `text.to_lowercase()` and then sliced
/// `text` with the offsets it found, which only holds while lowercasing
/// preserves byte length. It does not: Turkish `İ` (2 bytes) lowercases to
/// `i` + U+0307 (3 bytes), so every later offset was wrong — silently
/// corrupting the output, or panicking when an offset landed inside a
/// character or past the end. `"İ AI"` was enough to panic.
fn replace_first(text: &str, from: &str, to: &str, kind: MatchKind) -> String {
match find_case_insensitive(text, from, kind) {
Some((start, end)) => {
let mut out = String::with_capacity(text.len() - (end - start) + to.len());
out.push_str(&text[..start]);
out.push_str(to);
out.push_str(&text[end..]);
out
}
None => text.to_string(),
}
}
/// Byte range of the first case-insensitive match of `needle` in `haystack`.
fn find_case_insensitive(haystack: &str, needle: &str, kind: MatchKind) -> Option<(usize, usize)> {
if needle.is_empty() {
return None;
}
let lowered: Vec<char> = needle.chars().flat_map(char::to_lowercase).collect();
let is_word = |c: char| c.is_alphanumeric() || c == '_';
for (start, _) in haystack.char_indices() {
if kind == MatchKind::WholeWord
&& haystack[..start].chars().next_back().is_some_and(is_word)
{
continue; // mid-word: "ai" inside "training"
}
let mut matched = 0usize;
let mut end = start;
for (offset, ch) in haystack[start..].char_indices() {
if matched == lowered.len() {
break;
}
let mut consumed_all = true;
for lc in ch.to_lowercase() {
if lowered.get(matched) != Some(&lc) {
consumed_all = false;
break;
}
matched += 1;
}
if !consumed_all {
break;
}
end = start + offset + ch.len_utf8();
}
if matched == lowered.len()
&& !(kind == MatchKind::WholeWord
&& haystack[end..].chars().next().is_some_and(is_word))
{
return Some((start, end));
}
}
None
}
/// Simple whitespace/punctuation tokenizer.
fn tokenize(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_alphanumeric())
@@ -637,4 +709,86 @@ mod tests {
expanded.iter().map(|x| &x.text).collect::<Vec<_>>()
);
}
#[test]
fn acronyms_only_match_whole_words() {
let ex = QueryExpander::new(QueryExpansionConfig::default());
// "training" contains "ai", "programming" contains "pr". These used to
// be rewritten to "trArtificial Intelligencening" and
// "Pull Requestogramming".
for query in [
"How many miles during my marathon training?",
"Which programming language did I pick?",
"I updated the maintainer list",
] {
for expansion in ex.expand(query) {
assert!(
expansion.expansion_type != "acronym",
"{query:?} produced {expansion:?}"
);
}
}
// A real acronym still expands, in both directions.
let texts: Vec<String> = ex
.expand("What about the API and the database?")
.into_iter()
.filter(|e| e.expansion_type == "acronym")
.map(|e| e.text)
.collect();
assert!(
texts
.iter()
.any(|t| t.contains("Application Programming Interface")),
"{texts:?}"
);
assert!(texts.iter().any(|t| t.contains("DB")), "{texts:?}");
}
#[test]
fn non_ascii_queries_do_not_panic_or_corrupt() {
let ex = QueryExpander::new(QueryExpansionConfig::default());
// Turkish 'İ' is 2 bytes but lowercases to 3, so offsets taken from a
// lowercased copy no longer line up with the original. `"İ AI"` used
// to panic; `"İstanbul AI trip"` used to silently eat a character.
for query in ["İ AI", "İé AI", "İİ ML", "İstanbul AI trip", "ǰ ML notes"] {
for expansion in ex.expand(query) {
assert!(
expansion.text.contains('İ') || expansion.text.contains('ǰ'),
"{query:?} lost its leading character: {expansion:?}"
);
}
}
let expanded = ex.expand("İstanbul AI trip");
assert!(
expanded
.iter()
.any(|e| e.text == "İstanbul Artificial Intelligence trip"),
"{expanded:?}"
);
}
#[test]
fn whole_word_matching_handles_string_edges_and_case() {
assert_eq!(
replace_word_case_insensitive("ai tools", "AI", "Artificial Intelligence"),
"Artificial Intelligence tools"
);
assert_eq!(
replace_word_case_insensitive("tools for ai", "AI", "Artificial Intelligence"),
"tools for Artificial Intelligence"
);
assert_eq!(
replace_word_case_insensitive("the aim", "AI", "Artificial Intelligence"),
"the aim",
"must not match inside a word"
);
assert_eq!(
replace_word_case_insensitive("no match here", "xyz", "abc"),
"no match here"
);
// Only the first occurrence is replaced, as before.
assert_eq!(
replace_word_case_insensitive("ai and ai", "ai", "ML"),
"ML and ai"
);
}
}
+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);
+319 -35
View File
@@ -12,10 +12,18 @@ use crate::MemoryError;
use crate::cache::MemoryCache;
use crate::knowledge::KnowledgeCache;
use crate::session::SessionCache;
use crate::wal::WalMark;
pub const SCHEMA_VERSION: &str = "1.0";
pub const ZEROCLAW_VERSION: &str = "0.8.0";
/// `/meta` attributes holding the [`WalMark`] of the WAL prefix already folded
/// into this file. Absent on files written before the mark existed, and when
/// the checkpoint was taken with an empty WAL.
const WAL_APPLIED_LEN_ATTR: &str = "wal_applied_len";
const WAL_APPLIED_CRC_ATTR: &str = "wal_applied_crc";
const ANN_GENERATION_ATTR: &str = "ann_generation";
/// Build a complete HDF5 file from the in-memory state.
pub fn build_hdf5_file(
config: &MemoryConfig,
@@ -23,6 +31,47 @@ pub fn build_hdf5_file(
sessions: &SessionCache,
knowledge: &KnowledgeCache,
) -> Result<Vec<u8>, MemoryError> {
build_hdf5_file_with_mark(config, cache, sessions, knowledge, None)
}
/// [`build_hdf5_file`], recording which WAL prefix this state already
/// contains (see [`WalMark`]) so a crash before the WAL is truncated doesn't
/// replay those entries a second time.
pub fn build_hdf5_file_with_mark(
config: &MemoryConfig,
cache: &MemoryCache,
sessions: &SessionCache,
knowledge: &KnowledgeCache,
wal_applied: Option<WalMark>,
) -> Result<Vec<u8>, MemoryError> {
let meta = CheckpointMeta {
wal_applied,
ann_generation: None,
};
build_hdf5_file_with_meta(config, cache, sessions, knowledge, &meta)
}
/// Bookkeeping a checkpoint records in `/meta` beside the store's contents.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CheckpointMeta {
/// The WAL prefix this checkpoint already contains; see [`WalMark`].
pub wal_applied: Option<WalMark>,
/// Identifies the vector-index sidecar (`<store>.h5.ann`) written with this
/// checkpoint. A sidecar is loaded only if it carries the same value, so
/// one left over from another checkpoint can never be attached to records
/// it wasn't built from.
pub ann_generation: Option<u64>,
}
/// [`build_hdf5_file`] with checkpoint bookkeeping.
pub fn build_hdf5_file_with_meta(
config: &MemoryConfig,
cache: &MemoryCache,
sessions: &SessionCache,
knowledge: &KnowledgeCache,
checkpoint: &CheckpointMeta,
) -> Result<Vec<u8>, MemoryError> {
let wal_applied = checkpoint.wal_applied;
let mut builder = clawhdf5::FileBuilder::new();
// /meta group with schema attributes
@@ -34,10 +83,44 @@ pub fn build_hdf5_file(
meta.set_attr("embedding_dim", AttrValue::I64(config.embedding_dim as i64));
meta.set_attr("chunk_size", AttrValue::I64(config.chunk_size as i64));
meta.set_attr("overlap", AttrValue::I64(config.overlap as i64));
// Behavioural settings. These used to live only in memory, so reopening a
// store silently reset them to defaults — e.g. a compressed store was
// rewritten uncompressed by the first checkpoint after a reopen. Loaders
// treat each one as optional so older files keep opening.
meta.set_attr("float16", AttrValue::I64(config.float16.into()));
meta.set_attr("compression", AttrValue::I64(config.compression.into()));
meta.set_attr(
"compression_level",
AttrValue::I64(config.compression_level.into()),
);
meta.set_attr(
"compact_threshold",
AttrValue::F64(config.compact_threshold.into()),
);
meta.set_attr("hebbian_boost", AttrValue::F64(config.hebbian_boost.into()));
meta.set_attr("decay_factor", AttrValue::F64(config.decay_factor.into()));
meta.set_attr("wal_enabled", AttrValue::I64(config.wal_enabled.into()));
meta.set_attr(
"wal_max_entries",
AttrValue::I64(config.wal_max_entries as i64),
);
meta.set_attr(
"quantized_index",
AttrValue::I64(config.quantized_index.into()),
);
meta.set_attr(
"edgehdf5_version",
AttrValue::String(ZEROCLAW_VERSION.into()),
);
if let Some(mark) = wal_applied.filter(|m| m.len > 0) {
meta.set_attr(WAL_APPLIED_LEN_ATTR, AttrValue::I64(mark.len as i64));
meta.set_attr(WAL_APPLIED_CRC_ATTR, AttrValue::I64(i64::from(mark.crc)));
}
if let Some(generation) = checkpoint.ann_generation {
// Stored as the i64 with the same bits; attributes have no u64 scalar
// round trip through every reader.
meta.set_attr(ANN_GENERATION_ATTR, AttrValue::I64(generation as i64));
}
// Need at least one dataset in the group for it to be a proper group
meta.create_dataset("_marker").with_u8_data(&[1]).compact();
let finished_meta = meta.finish();
@@ -74,7 +157,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
@@ -83,15 +166,33 @@ fn build_memory_group(
let rows_per_chunk = (target_chunk_bytes / (d * 4)).max(1).min(n);
ds.with_chunks(&[rows_per_chunk, d]);
// Compression: Zstd for embeddings — faster than deflate at same ratio.
// Shuffle is applied automatically (auto-shuffle pre-filter).
// Compression. Shuffle is applied automatically (auto-shuffle
// pre-filter). Zstd is faster than deflate at the same ratio but
// pulls in libzstd, so it is opt-in via the `zstd` feature; the
// default build uses deflate, which is always available. (This
// used to call `with_zstd` unconditionally, so without the
// feature every checkpoint of a compressed store failed with
// "unsupported filter: 32015".) Both are standard HDF5 filters;
// reading a zstd-compressed store needs a zstd-enabled build.
if config.compression {
let level = if config.compression_level > 0 {
config.compression_level.min(22)
} else {
3 // Zstd level 3: fast + good ratio for f32 embeddings
};
ds.with_zstd(level);
#[cfg(feature = "zstd")]
{
let level = if config.compression_level > 0 {
config.compression_level.min(22)
} else {
3 // fast + good ratio for f32 embeddings
};
ds.with_zstd(level);
}
#[cfg(not(feature = "zstd"))]
{
let level = if config.compression_level > 0 {
config.compression_level.min(9)
} else {
4
};
ds.with_deflate(level);
}
}
}
@@ -309,6 +410,36 @@ fn write_string_dataset(
}
/// Validate an HDF5 file has the correct schema and load all data.
/// Read the checkpoint's [`WalMark`] from `/meta`, if it has one.
pub fn read_wal_mark(file: &clawhdf5::File) -> Option<WalMark> {
let attrs = file.group("meta").ok()?.attrs().ok()?;
let len = match attrs.get(WAL_APPLIED_LEN_ATTR)? {
AttrValue::I64(v) => u64::try_from(*v).ok()?,
_ => return None,
};
let crc = match attrs.get(WAL_APPLIED_CRC_ATTR)? {
AttrValue::I64(v) => u32::try_from(*v).ok()?,
_ => return None,
};
Some(WalMark { len, crc })
}
/// Read the checkpoint bookkeeping from `/meta`.
pub fn read_checkpoint_meta(file: &clawhdf5::File) -> CheckpointMeta {
let ann_generation = file
.group("meta")
.ok()
.and_then(|g| g.attrs().ok())
.and_then(|attrs| match attrs.get(ANN_GENERATION_ATTR) {
Some(AttrValue::I64(v)) => Some(*v as u64),
_ => None,
});
CheckpointMeta {
wal_applied: read_wal_mark(file),
ann_generation,
}
}
pub fn validate_and_load(
file: &clawhdf5::File,
) -> Result<(MemoryConfig, MemoryCache, SessionCache, KnowledgeCache), MemoryError> {
@@ -344,15 +475,20 @@ pub fn validate_and_load(
embedding_dim,
chunk_size,
overlap,
float16: false,
compression: false,
compression_level: 0,
compact_threshold: 0.3,
hebbian_boost: 0.15,
decay_factor: 0.98,
float16: optional_bool_attr(&attrs, "float16", false),
compression: optional_bool_attr(&attrs, "compression", false),
compression_level: optional_i64_attr(&attrs, "compression_level")
.and_then(|v| u32::try_from(v).ok())
.unwrap_or(0),
compact_threshold: optional_f32_attr(&attrs, "compact_threshold", 0.3),
hebbian_boost: optional_f32_attr(&attrs, "hebbian_boost", 0.15),
decay_factor: optional_f32_attr(&attrs, "decay_factor", 0.98),
created_at,
wal_enabled: true,
wal_max_entries: 500,
wal_enabled: optional_bool_attr(&attrs, "wal_enabled", true),
wal_max_entries: optional_i64_attr(&attrs, "wal_max_entries")
.and_then(|v| usize::try_from(v).ok())
.unwrap_or(500),
quantized_index: optional_bool_attr(&attrs, "quantized_index", false),
};
// Load /memory group
@@ -391,27 +527,48 @@ fn load_memory_group(
let tags = read_string_dataset_from_group(&group, "tags")?;
let tombstones = read_u8_dataset(&group, "tombstones")?;
// Read norms if present, otherwise compute from embeddings
let norms = match read_f32_dataset(&group, "norms") {
Ok(n) if n.len() == n.len() => n,
_ => {
// Compute norms from flat embeddings
flat_embeddings
.chunks(embedding_dim)
.map(|chunk| {
let sq_sum: f32 = chunk.iter().map(|x| x * x).sum();
sq_sum.sqrt()
})
.collect()
// Every per-record dataset must describe exactly `n` records. Without
// this, a truncated or hand-edited file loads "successfully" and then
// panics on the first out-of-bounds index during search/delete.
if embedding_dim == 0 {
return Err(MemoryError::Schema(format!(
"/memory has {n} records but embedding_dim is 0"
)));
}
let expected_flat = n.checked_mul(embedding_dim).ok_or_else(|| {
MemoryError::Schema(format!("/memory size overflow: {n} x {embedding_dim}"))
})?;
let check_len = |name: &str, actual: usize, expected: usize| {
if actual == expected {
Ok(())
} else {
Err(MemoryError::Schema(format!(
"/memory/{name} has {actual} entries, expected {expected} \
({n} records)"
)))
}
};
check_len("embeddings", flat_embeddings.len(), expected_flat)?;
check_len("source_channel", source_channels.len(), n)?;
check_len("timestamps", timestamps.len(), n)?;
check_len("session_ids", session_ids.len(), n)?;
check_len("tags", tags.len(), n)?;
check_len("tombstones", tombstones.len(), n)?;
// Unflatten embeddings
let embeddings: Vec<Vec<f32>> = flat_embeddings
.chunks(embedding_dim)
.map(|c| c.to_vec())
.collect();
// Norms are derived data: use the stored ones only if they are present
// and the right length, otherwise recompute from the embeddings.
let norms = match read_f32_dataset(&group, "norms") {
Ok(stored) if stored.len() == n => stored,
_ => flat_embeddings
.chunks(embedding_dim)
.map(|chunk| {
let sq_sum: f32 = chunk.iter().map(|x| x * x).sum();
sq_sum.sqrt()
})
.collect(),
};
// 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,
@@ -419,7 +576,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;
@@ -480,6 +637,7 @@ fn load_knowledge_group(file: &clawhdf5::File) -> Result<KnowledgeCache, MemoryE
cache.entities.push(crate::knowledge::Entity {
id: entity_ids[i] as u64,
name: entity_names[i].clone(),
name_lower: entity_names[i].to_lowercase(),
entity_type: entity_types[i].clone(),
embedding_idx: emb_idxs[i],
..Default::default()
@@ -529,6 +687,27 @@ fn extract_string_attr(
}
}
type MetaAttrs = std::collections::HashMap<String, AttrValue>;
fn optional_i64_attr(attrs: &MetaAttrs, name: &str) -> Option<i64> {
match attrs.get(name) {
Some(AttrValue::I64(v)) => Some(*v),
_ => None,
}
}
fn optional_bool_attr(attrs: &MetaAttrs, name: &str, default: bool) -> bool {
optional_i64_attr(attrs, name).map_or(default, |v| v != 0)
}
/// Finite values only: a NaN threshold/decay would poison every comparison.
fn optional_f32_attr(attrs: &MetaAttrs, name: &str, default: f32) -> f32 {
match attrs.get(name) {
Some(AttrValue::F64(v)) if v.is_finite() => *v as f32,
_ => default,
}
}
fn extract_i64_attr(
attrs: &std::collections::HashMap<String, AttrValue>,
name: &str,
@@ -614,3 +793,108 @@ fn read_u8_dataset(group: &clawhdf5::Group<'_>, name: &str) -> Result<Vec<u8>, M
.map_err(|e| MemoryError::Hdf5(format!("cannot read u8 from {name}: {e}")))?;
Ok(data.into_iter().map(|v| v as u8).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> MemoryConfig {
MemoryConfig::new(std::path::PathBuf::from("unused.h5"), "agent", 4)
}
fn cache_with(n: usize) -> MemoryCache {
let mut cache = MemoryCache::new(4);
for i in 0..n {
cache.push(
format!("chunk {i}"),
vec![i as f32 + 1.0, 0.0, 0.0, 0.0],
"user".into(),
i as f64,
"s".into(),
"t".into(),
);
}
cache
}
fn roundtrip(cache: &MemoryCache) -> Result<MemoryCache, MemoryError> {
let bytes = build_hdf5_file(
&config(),
cache,
&SessionCache::new(),
&KnowledgeCache::new(),
)?;
let file =
clawhdf5::File::from_bytes(bytes).map_err(|e| MemoryError::Hdf5(e.to_string()))?;
validate_and_load(&file).map(|(_, cache, _, _)| cache)
}
#[test]
fn behavioural_config_survives_a_reopen() {
let mut cfg = config();
cfg.compression = true;
cfg.compression_level = 7;
cfg.compact_threshold = 0.5;
cfg.hebbian_boost = 0.25;
cfg.decay_factor = 0.9;
cfg.wal_enabled = false;
cfg.wal_max_entries = 42;
let bytes = build_hdf5_file(
&cfg,
&cache_with(2),
&SessionCache::new(),
&KnowledgeCache::new(),
)
.unwrap();
let file = clawhdf5::File::from_bytes(bytes).unwrap();
let (loaded, loaded_cache, ..) = validate_and_load(&file).unwrap();
// The compressed embeddings must also read back intact.
assert_eq!(loaded_cache.embeddings, cache_with(2).embeddings);
assert!(loaded.compression);
assert_eq!(loaded.compression_level, 7);
assert_eq!(loaded.compact_threshold, 0.5);
assert_eq!(loaded.hebbian_boost, 0.25);
assert_eq!(loaded.decay_factor, 0.9);
assert!(!loaded.wal_enabled);
assert_eq!(loaded.wal_max_entries, 42);
}
#[test]
fn consistent_store_loads() {
let loaded = roundtrip(&cache_with(3)).unwrap();
assert_eq!(loaded.chunks.len(), 3);
assert_eq!(loaded.norms, vec![1.0, 2.0, 3.0]);
}
#[test]
fn wrong_length_norms_are_recomputed_not_trusted() {
// Regression: the guard used to be `n.len() == n.len()`, so a norms
// dataset of any length was accepted and corrupted every cosine score.
let mut cache = cache_with(3);
cache.norms = vec![99.0];
let loaded = roundtrip(&cache).unwrap();
assert_eq!(loaded.norms, vec![1.0, 2.0, 3.0]);
}
#[test]
fn mismatched_per_record_datasets_are_schema_errors() {
type Corrupt = fn(&mut MemoryCache);
let cases: [(&str, Corrupt); 5] = [
("tombstones", |c| c.tombstones.truncate(1)),
("timestamps", |c| c.timestamps.truncate(1)),
("tags", |c| c.tags.truncate(1)),
("session_ids", |c| c.session_ids.truncate(1)),
("source_channel", |c| c.source_channels.truncate(1)),
];
for (name, corrupt) in cases {
let mut cache = cache_with(3);
corrupt(&mut cache);
match roundtrip(&cache) {
Err(MemoryError::Schema(msg)) => {
assert!(msg.contains(name), "{name}: unexpected message {msg}")
}
other => panic!("{name}: expected Schema error, got {:?}", other.map(|_| ())),
}
}
}
}
+82 -32
View File
@@ -4,7 +4,7 @@ use std::path::Path;
use crate::bm25;
use crate::hybrid;
use crate::{HDF5Memory, MemoryError, Result, SearchResult};
use crate::{HDF5Memory, MAX_ACTIVATION_WEIGHT, MemoryError, Result, SearchResult};
impl HDF5Memory {
/// Vector + keyword scoring stage of [`HDF5Memory::hybrid_search`].
@@ -20,8 +20,7 @@ impl HDF5Memory {
query_embedding: &[f32],
query_text: &str,
bm25: &bm25::BM25Index,
vector_weight: f32,
keyword_weight: f32,
fusion: hybrid::Fusion,
k: usize,
) -> Vec<(usize, f32)> {
self.ensure_hnsw_fresh();
@@ -30,29 +29,40 @@ impl HDF5Memory {
// Over-fetch so the merge sees a useful vector pool; cosine
// distance from the index converts back to similarity (1 - d).
let pool = (k * 8).max(64);
let vec_scores: Vec<(usize, f32)> = index
.search(query_embedding, pool, pool)
let candidates = index.search(query_embedding, pool, pool);
// 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();
let kw_scores = bm25.search(query_text, self.cache.len());
hybrid::merge_vector_keyword(
vec_scores,
kw_scores,
vector_weight,
keyword_weight,
k,
)
// Fusion normalises over every keyword match, so it needs all
// the scores — but not ranked.
let kw_scores = bm25.scores(query_text);
hybrid::fuse(vec_scores, kw_scores, fusion, k)
}
_ => hybrid::hybrid_search(
_ => hybrid::hybrid_search_fused(
query_embedding,
query_text,
&self.cache.embeddings,
&self.cache.chunks,
&self.cache.tombstones,
bm25,
vector_weight,
keyword_weight,
fusion,
k,
),
}
@@ -64,19 +74,17 @@ impl HDF5Memory {
query_embedding: &[f32],
query_text: &str,
bm25: &bm25::BM25Index,
vector_weight: f32,
keyword_weight: f32,
fusion: hybrid::Fusion,
k: usize,
) -> Vec<(usize, f32)> {
hybrid::hybrid_search(
hybrid::hybrid_search_fused(
query_embedding,
query_text,
&self.cache.embeddings,
&self.cache.chunks,
&self.cache.tombstones,
bm25,
vector_weight,
keyword_weight,
fusion,
k,
)
}
@@ -90,15 +98,35 @@ impl HDF5Memory {
keyword_weight: f32,
k: usize,
) -> Vec<SearchResult> {
let bm25 = bm25::BM25Index::build(&self.cache.chunks, &self.cache.tombstones);
let scored = self.vector_keyword_search(
self.hybrid_search_with(
query_embedding,
query_text,
&bm25,
vector_weight,
keyword_weight,
hybrid::Fusion::Weighted {
vector: vector_weight,
keyword: keyword_weight,
},
k,
);
)
}
/// [`HDF5Memory::hybrid_search`] with the fusion method chosen explicitly.
///
/// [`hybrid::DEFAULT_FUSION`] is what the weighted form defaults to;
/// [`hybrid::Fusion::Rrf`] combines the two stages by rank instead of by
/// score.
pub fn hybrid_search_with(
&mut self,
query_embedding: &[f32],
query_text: &str,
fusion: hybrid::Fusion,
k: usize,
) -> Vec<SearchResult> {
// The keyword index lives for the life of the store and is updated
// incrementally. Take it out for the duration of the call so the
// vector stage can borrow `self` mutably, then put it back.
self.ensure_bm25_fresh();
let bm25 = self.bm25.take().expect("ensure_bm25_fresh leaves an index");
let scored = self.vector_keyword_search(query_embedding, query_text, &bm25, fusion, k);
let mut results: Vec<SearchResult> = scored
.into_iter()
.map(|(idx, score)| {
@@ -113,23 +141,45 @@ impl HDF5Memory {
}
})
.collect();
// Ties broken by index so results (and therefore which records get
// boosted) don't depend on HashMap iteration order upstream.
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.index.cmp(&b.index))
});
let hit_indices: Vec<usize> = results.iter().map(|r| r.index).collect();
// Only reinforce records that actually matched. When fewer than `k`
// records are relevant, the rest of the list is zero-score filler;
// boosting it would teach the store that arbitrary records are
// important just because they were nearby in iteration order.
let hit_indices: Vec<usize> = results
.iter()
.filter(|r| r.score > 0.0)
.map(|r| r.index)
.collect();
self.apply_hebbian_boost(&hit_indices);
self.flush().ok();
self.bm25 = Some(bm25);
results
}
/// Reinforce the records a query returned. The new weights are persisted by
/// the next checkpoint (any write that flushes, `flush_wal`, or drop) — not
/// by rewriting the whole store inside the query, which is what made
/// `hybrid_search` cost O(store size) in disk I/O. They are a ranking hint,
/// not user data: a crash before the next checkpoint only forgets the
/// boosts since the last one.
fn apply_hebbian_boost(&mut self, hit_indices: &[usize]) {
for &idx in hit_indices {
self.cache.activation_weights[idx] += self.config.hebbian_boost;
if hit_indices.is_empty() || self.config.hebbian_boost == 0.0 {
return;
}
for &idx in hit_indices {
let w = &mut self.cache.activation_weights[idx];
*w = (*w + self.config.hebbian_boost).min(MAX_ACTIVATION_WEIGHT);
}
self.activations_dirty = true;
}
/// Get the chunk text for a memory entry by index.
+94 -5
View File
@@ -11,6 +11,7 @@ use crate::cache::MemoryCache;
use crate::knowledge::KnowledgeCache;
use crate::schema;
use crate::session::SessionCache;
use crate::wal::WalMark;
/// Write all in-memory state to an HDF5 file on disk.
pub fn write_to_disk(
@@ -20,7 +21,36 @@ pub fn write_to_disk(
sessions: &SessionCache,
knowledge: &KnowledgeCache,
) -> Result<(), MemoryError> {
let bytes = schema::build_hdf5_file(config, cache, sessions, knowledge)?;
write_to_disk_with_mark(path, config, cache, sessions, knowledge, None)
}
/// [`write_to_disk`] for a checkpoint: `wal_applied` is the mark of the WAL
/// prefix whose entries `cache` already contains.
pub fn write_to_disk_with_mark(
path: &Path,
config: &MemoryConfig,
cache: &MemoryCache,
sessions: &SessionCache,
knowledge: &KnowledgeCache,
wal_applied: Option<WalMark>,
) -> Result<(), MemoryError> {
let meta = schema::CheckpointMeta {
wal_applied,
ann_generation: None,
};
write_to_disk_with_meta(path, config, cache, sessions, knowledge, &meta)
}
/// [`write_to_disk`] with full checkpoint bookkeeping.
pub fn write_to_disk_with_meta(
path: &Path,
config: &MemoryConfig,
cache: &MemoryCache,
sessions: &SessionCache,
knowledge: &KnowledgeCache,
checkpoint: &schema::CheckpointMeta,
) -> Result<(), MemoryError> {
let bytes = schema::build_hdf5_file_with_meta(config, cache, sessions, knowledge, checkpoint)?;
if bytes.is_empty() {
return Err(MemoryError::Hdf5("build_hdf5_file produced 0 bytes".into()));
@@ -28,9 +58,41 @@ pub fn write_to_disk(
// Write to a temp file first, then rename for atomicity
let tmp_path = path.with_extension("h5.tmp");
std::fs::write(&tmp_path, &bytes).map_err(MemoryError::Io)?;
std::fs::rename(&tmp_path, path).map_err(MemoryError::Io)?;
write_synced(&tmp_path, &bytes)?;
rename_synced(&tmp_path, path)
}
/// Write `bytes` to `path` and flush them to stable storage.
pub(crate) fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), MemoryError> {
use std::io::Write;
let mut f = std::fs::File::create(path).map_err(MemoryError::Io)?;
f.write_all(bytes).map_err(MemoryError::Io)?;
f.sync_all().map_err(MemoryError::Io)
}
/// Rename `from` over `to`, then sync the parent directory so the rename
/// itself survives a power loss. `from` must already be synced: without that,
/// the rename can reach disk before the data and leave an empty or partial
/// file under the final name.
///
/// This is per-checkpoint/snapshot cost only (each is already a full file
/// write). Individual WAL appends are deliberately not synced — see the
/// durability notes in the crate docs.
pub(crate) fn rename_synced(from: &Path, to: &Path) -> Result<(), MemoryError> {
std::fs::rename(from, to).map_err(MemoryError::Io)?;
#[cfg(unix)]
if let Some(dir) = to.parent() {
let dir = if dir.as_os_str().is_empty() {
Path::new(".")
} else {
dir
};
// Directory fsync is best-effort: some filesystems refuse it, and the
// rename has already happened.
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
}
Ok(())
}
@@ -42,6 +104,15 @@ pub fn write_to_disk(
pub fn read_from_disk(
path: &Path,
) -> Result<(MemoryConfig, MemoryCache, SessionCache, KnowledgeCache), MemoryError> {
read_from_disk_with_mark(path).map(|(state, _mark)| state)
}
/// Everything [`read_from_disk`] returns.
pub type StoreState = (MemoryConfig, MemoryCache, SessionCache, KnowledgeCache);
/// [`read_from_disk`], plus the checkpoint's [`WalMark`] (if any) so the
/// caller can skip WAL entries this file already contains.
pub fn read_from_disk_with_mark(path: &Path) -> Result<(StoreState, Option<WalMark>), MemoryError> {
let mmap = clawhdf5_io::MmapReader::open(path).map_err(MemoryError::Io)?;
// Advise the OS we'll need the whole file for parsing
@@ -53,8 +124,23 @@ pub fn read_from_disk(
let (mut config, cache, sessions, knowledge) = schema::validate_and_load(&file)?;
config.path = path.to_path_buf();
let wal_applied = schema::read_wal_mark(&file);
Ok((config, cache, sessions, knowledge))
Ok(((config, cache, sessions, knowledge), wal_applied))
}
/// [`read_from_disk`], plus all checkpoint bookkeeping.
pub fn read_from_disk_with_meta(
path: &Path,
) -> Result<(StoreState, schema::CheckpointMeta), MemoryError> {
let mmap = clawhdf5_io::MmapReader::open(path).map_err(MemoryError::Io)?;
mmap.advise_willneed(0, mmap.len());
let file = clawhdf5::File::from_bytes(mmap.as_bytes().to_vec())
.map_err(|e| MemoryError::Hdf5(format!("cannot open {}: {e}", path.display())))?;
let (mut config, cache, sessions, knowledge) = schema::validate_and_load(&file)?;
config.path = path.to_path_buf();
let meta = schema::read_checkpoint_meta(&file);
Ok(((config, cache, sessions, knowledge), meta))
}
/// Copy an HDF5 file atomically to a destination.
@@ -78,7 +164,10 @@ pub fn snapshot_file(src: &Path, dest: &Path) -> Result<std::path::PathBuf, Memo
// Atomic copy: write to temp, then rename
let tmp_path = dest_file.with_extension("h5.tmp");
std::fs::copy(src, &tmp_path).map_err(MemoryError::Io)?;
std::fs::rename(&tmp_path, &dest_file).map_err(MemoryError::Io)?;
std::fs::File::open(&tmp_path)
.and_then(|f| f.sync_all())
.map_err(MemoryError::Io)?;
rename_synced(&tmp_path, &dest_file)?;
Ok(dest_file)
}
+79
View File
@@ -0,0 +1,79 @@
//! Single-writer guard for a memory store.
//!
//! `HDF5Memory` keeps the whole store in memory and rewrites the `.h5` file at
//! every checkpoint, so two handles on one store (two processes, or two opens
//! in one process) silently destroy each other's data: whoever checkpoints
//! last wins, and both append to the same WAL with independent CRC chains.
//! The lock turns that into an immediate, explicit error.
use std::fs::{File, OpenOptions, TryLockError};
use std::path::{Path, PathBuf};
use crate::MemoryError;
const LOCK_RETRIES: u32 = 25;
const LOCK_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(10);
/// An exclusive advisory lock on `<store>.h5.lock`, held for the lifetime of
/// the owning `HDF5Memory` and released when it is dropped (or when the
/// process dies — the OS drops the lock with the file descriptor, so a crash
/// never leaves a stale lock behind; the empty lock file itself is harmless).
#[derive(Debug)]
pub(crate) struct StoreLock {
_file: File,
}
impl StoreLock {
pub(crate) fn lock_path(store: &Path) -> PathBuf {
store.with_extension("h5.lock")
}
pub(crate) fn acquire(store: &Path) -> Result<Self, MemoryError> {
let path = Self::lock_path(store);
let file = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)?;
// A previous owner may be mid-teardown (e.g. an `AsyncHDF5Memory`
// dropped without `shutdown()`: its background task releases the
// store a moment later), so give the lock a short, bounded grace
// period before reporting a genuine second writer.
let mut attempts_left = LOCK_RETRIES;
loop {
match file.try_lock() {
Ok(()) => return Ok(Self { _file: file }),
Err(TryLockError::WouldBlock) if attempts_left > 0 => {
attempts_left -= 1;
std::thread::sleep(LOCK_RETRY_DELAY);
}
Err(TryLockError::WouldBlock) => {
return Err(MemoryError::Locked(format!(
"{} is already open in this or another process (lock file {})",
store.display(),
path.display()
)));
}
Err(TryLockError::Error(e)) => return Err(MemoryError::Io(e)),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn second_acquire_fails_until_first_is_dropped() {
let dir = tempfile::TempDir::new().unwrap();
let store = dir.path().join("s.h5");
let first = StoreLock::acquire(&store).unwrap();
assert!(matches!(
StoreLock::acquire(&store),
Err(MemoryError::Locked(_))
));
drop(first);
StoreLock::acquire(&store).unwrap();
}
}
+39 -3
View File
@@ -167,10 +167,17 @@ pub fn auto_select_strategy(num_vectors: usize, hw: &HardwareCapabilities) -> Se
/// This dispatches to the appropriate search implementation based on the
/// selected strategy. For IVF-PQ, an index must be provided externally
/// (this function uses brute-force fallback if no IVF-PQ index is available).
///
/// `vectors_flat` is `vectors` flattened into one contiguous `[N × dim]`
/// row-major buffer (e.g. `MemoryCache::embeddings_flat`, maintained
/// incrementally alongside `vectors`). It's only consulted by the
/// `Blas`/`Accelerate` strategies, which otherwise re-flatten the whole
/// corpus on every call — passing the already-flat buffer skips that copy.
#[allow(clippy::too_many_arguments)]
pub fn search_with_metrics(
query: &[f32],
vectors: &[Vec<f32>],
vectors_flat: &[f32],
norms: &[f32],
tombstones: &[u8],
k: usize,
@@ -178,6 +185,10 @@ pub fn search_with_metrics(
#[cfg(feature = "gpu")] gpu_backend: Option<&crate::gpu_search::GpuSearchBackend>,
#[cfg(not(feature = "gpu"))] _gpu_backend: Option<&()>,
) -> (Vec<(usize, f32)>, SearchMetrics) {
// Only read by the Blas/Accelerate arms below, which are themselves
// feature-gated — reference it unconditionally so a build with neither
// feature enabled doesn't warn about an unused parameter.
let _ = vectors_flat;
let start = Instant::now();
let active_count = tombstones.iter().filter(|&&t| t == 0).count();
@@ -197,7 +208,14 @@ pub fn search_with_metrics(
gpu_active = false;
#[cfg(feature = "fast-math")]
{
crate::blas_search::blas_cosine_batch(query, vectors, norms, tombstones, k)
crate::blas_search::blas_cosine_batch_flat(
query,
vectors_flat,
norms,
tombstones,
query.len(),
k,
)
}
#[cfg(not(feature = "fast-math"))]
{
@@ -211,8 +229,13 @@ pub fn search_with_metrics(
gpu_active = false;
#[cfg(any(feature = "accelerate", feature = "openblas"))]
{
crate::accelerate_search::accelerate_cosine_batch_vecs(
query, vectors, norms, tombstones, k,
crate::accelerate_search::accelerate_cosine_batch(
query,
vectors_flat,
norms,
tombstones,
query.len(),
k,
)
}
#[cfg(not(any(feature = "accelerate", feature = "openblas")))]
@@ -325,6 +348,10 @@ mod tests {
(0..n).map(|_| (0..dim).map(|_| next()).collect()).collect()
}
fn flatten(vectors: &[Vec<f32>]) -> Vec<f32> {
vectors.iter().flatten().copied().collect()
}
// --- auto_select_strategy tests ---
#[test]
@@ -490,6 +517,7 @@ mod tests {
let (results, metrics) = search_with_metrics(
&query,
&vectors,
&flatten(&vectors),
&norms,
&tombstones,
5,
@@ -520,6 +548,7 @@ mod tests {
let (results, metrics) = search_with_metrics(
&query,
&vectors,
&flatten(&vectors),
&norms,
&tombstones,
10,
@@ -545,6 +574,7 @@ mod tests {
let (_, metrics) = search_with_metrics(
&query,
&vectors,
&flatten(&vectors),
&norms,
&tombstones,
10,
@@ -570,6 +600,7 @@ mod tests {
let (results, _) = search_with_metrics(
&query,
&vectors,
&flatten(&vectors),
&norms,
&tombstones,
10,
@@ -603,6 +634,7 @@ mod tests {
let (results, metrics) = search_with_metrics(
&query,
&vectors,
&flatten(&vectors),
&norms,
&tombstones,
100,
@@ -647,6 +679,7 @@ mod tests {
let (_, metrics) = search_with_metrics(
&query,
&vectors,
&flatten(&vectors),
&norms,
&tombstones,
5,
@@ -718,6 +751,7 @@ mod tests {
let (results, metrics) = search_with_metrics(
&query,
&vectors,
&flatten(&vectors),
&norms,
&tombstones,
10,
@@ -744,6 +778,7 @@ mod tests {
let (results, metrics) = search_with_metrics(
&query,
&vectors,
&flatten(&vectors),
&norms,
&tombstones,
10,
@@ -822,6 +857,7 @@ mod tests {
let (results, metrics) = search_with_metrics(
&query,
&vectors,
&flatten(&vectors),
&norms,
&tombstones,
10,
+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));
+732 -68
View File
@@ -13,16 +13,57 @@ use crate::MemoryError;
const WAL_MAGIC: [u8; 4] = [0x45, 0x48, 0x57, 0x4C]; // "EHWL"
/// Current WAL format version: every entry ends with a 4-byte CRC32 trailer
/// (see [`TeeReader`]) so a bit-flip is detected and replay stops there
/// instead of silently accepting corrupted data.
const WAL_VERSION: u8 = 2;
/// Bytes before the first entry: [`WAL_MAGIC`] (4) + version (1) + entry
/// count (4). Named so the offset arithmetic in `open()` — which decides
/// where an append lands, and therefore whether it is replayable — reads as
/// a header length rather than a bare 9.
const WAL_HEADER_LEN: u64 = WAL_MAGIC.len() as u64 + 1 + 4;
/// The only other WAL version this crate still knows how to *read*: no
/// per-entry CRC trailer. Written by versions of this crate before the CRC32
/// hardening. `WalFile::open` migrates a legacy file to [`WAL_VERSION`] by
/// recreating it fresh — safe because every real call site reads existing
/// entries via [`WalFile::read_entries`] before calling `open` (see
/// Current WAL format version: every entry's CRC32 trailer is computed over
/// its own bytes *chained with the previous entry's stored CRC*
/// (`crc32(entry_bytes ++ prev_crc.to_le_bytes())`, seeded with 0 for the
/// first entry after a truncation). A per-entry CRC alone only detects a
/// bit-flip within that entry; chaining additionally detects entries being
/// reordered, duplicated, or spliced (e.g. a Tombstone moved before/after
/// its target Save) — the moved/inserted entry's stored CRC was computed
/// against a different predecessor than the one now in front of it on disk,
/// so the chain breaks at that point and replay stops there.
const WAL_VERSION: u8 = 4;
/// The chained-CRC format before [`WalEntryType::Update`] records existed.
/// Byte-for-byte the same framing as [`WAL_VERSION`], so it is read by the
/// same code, and `WalFile::open` upgrades it in place by rewriting the
/// header's version byte (the header is not covered by the CRC chain).
///
/// The bump exists for *older binaries*: they don't know record type 0x04,
/// would treat it as a torn tail, and would truncate it — and everything
/// after it — away. An unknown header version makes them refuse the file
/// with a clear error instead.
const WAL_VERSION_CHAINED_NO_UPDATE: u8 = 3;
/// The previous WAL format version: still a CRC32 per entry (so a bit-flip
/// within one entry is caught), but not chained to the previous entry's CRC
/// (so reordering/splicing whole entries is not detected). Written by
/// versions of this crate before the chaining hardening. Fully supported for
/// reading via [`WalFile::read_entries`] — not restricted like
/// [`WAL_VERSION_LEGACY_NO_CRC`], since it still verifies each entry
/// individually. `WalFile::open` migrates it to [`WAL_VERSION`] by
/// recreating the file fresh, the same as the legacy-no-CRC migration below.
const WAL_VERSION_CRC_UNCHAINED: u8 = 2;
/// The oldest WAL version this crate still knows how to *read*: no
/// per-entry CRC trailer at all, so a bit-flip anywhere is silently
/// accepted. Written by versions of this crate before the CRC32 hardening.
/// Because of that — unlike [`WAL_VERSION_CRC_UNCHAINED`] — this version is
/// deliberately *not* reachable through the public [`WalFile::read_entries`]
/// API; only [`WalFile::read_entries_for_migration`] (used exclusively by
/// `HDF5Memory::open`'s one-time migration path) will parse it. Flipping a
/// version byte from 2/3 down to 1 no longer silently downgrades a file to
/// the fully-unverified parser for an arbitrary caller.
///
/// `WalFile::open` migrates a legacy file to [`WAL_VERSION`] by recreating
/// it fresh — safe because every real call site reads existing entries via
/// [`WalFile::read_entries_for_migration`] before calling `open` (see
/// `HDF5Memory::open`), so no data is lost.
const WAL_VERSION_LEGACY_NO_CRC: u8 = 1;
@@ -37,6 +78,10 @@ pub enum WalEntryType {
Save = 0x01,
Tombstone = 0x02,
ActivationUpdate = 0x03,
/// Replace the record at `update_index` in place (`save_or_update` hit).
/// Logged as a plain `Save` before this existed, so replay appended a
/// duplicate instead of updating.
Update = 0x04,
}
impl WalEntryType {
@@ -45,6 +90,7 @@ impl WalEntryType {
0x01 => Some(Self::Save),
0x02 => Some(Self::Tombstone),
0x03 => Some(Self::ActivationUpdate),
0x04 => Some(Self::Update),
_ => None,
}
}
@@ -61,6 +107,8 @@ pub struct WalEntry {
pub tags: String,
/// For tombstone entries: the index of the entry to delete.
pub tombstone_index: Option<usize>,
/// For update entries: the index of the record to replace.
pub update_index: Option<usize>,
}
/// How many entries to accumulate before updating the header entry_count.
@@ -77,15 +125,77 @@ pub struct WalFile {
entry_count: u32,
/// Entries written since the last header count update.
pending_header_sync: u32,
/// CRC32 chain state: the previous entry's stored CRC (0 if this file
/// has no entries yet), folded into the next entry's CRC computation.
/// Reset to 0 by `truncate()`/`create_fresh_wal_file`, and re-derived by
/// scanning existing entries when `open()` attaches to a non-empty file.
running_crc: u32,
/// Bytes of verified entries after the header (the length of the chain
/// `running_crc` covers). Together they form the [`WalMark`].
chain_len: u64,
}
/// What a WAL file's 9-byte header looks like, without reading any entries.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalHeaderStatus {
/// A version this build can read (current or legacy).
Readable,
/// Shorter than a header — e.g. a crash while the file was being created.
/// It cannot contain entries.
Torn,
/// Not a WAL file at all.
BadMagic,
/// Well-formed header from a version this build doesn't know — most
/// likely written by a *newer* build. Never discard this: the entries are
/// probably fine, this binary just can't read them.
UnknownVersion(u8),
}
/// Classify the header of the WAL at `path`.
pub fn wal_header_status(path: &Path) -> std::io::Result<WalHeaderStatus> {
let mut header = [0u8; WAL_HEADER_LEN as usize];
let mut f = File::open(path)?;
let mut filled = 0;
while filled < header.len() {
match f.read(&mut header[filled..])? {
0 => return Ok(WalHeaderStatus::Torn),
n => filled += n,
}
}
if header[0..4] != WAL_MAGIC {
return Ok(WalHeaderStatus::BadMagic);
}
Ok(match header[4] {
WAL_VERSION
| WAL_VERSION_CHAINED_NO_UPDATE
| WAL_VERSION_CRC_UNCHAINED
| WAL_VERSION_LEGACY_NO_CRC => WalHeaderStatus::Readable,
v => WalHeaderStatus::UnknownVersion(v),
})
}
/// A position in a WAL's CRC chain: `len` bytes of entries after the header,
/// whose chained CRC is `crc`.
///
/// A checkpoint stores the mark of the WAL prefix it folded into the `.h5`
/// file. If the process dies after the new `.h5` is in place but before the
/// WAL is truncated, the next `open()` finds that exact prefix still in the
/// WAL and skips it instead of replaying it on top of data that already
/// contains it (which used to duplicate every pending entry).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WalMark {
pub len: u64,
pub crc: u32,
}
impl WalFile {
/// Open or create a WAL file. If it exists, read the header and entry count.
///
/// A legacy (pre-CRC) WAL file is migrated to the current format by
/// recreating it fresh — see [`WAL_VERSION_LEGACY_NO_CRC`]. Callers that
/// need the legacy file's entries must call [`WalFile::read_entries`]
/// first, before calling `open`.
/// A pre-chaining WAL file ([`WAL_VERSION_CRC_UNCHAINED`] or
/// [`WAL_VERSION_LEGACY_NO_CRC`]) is migrated to the current format by
/// recreating it fresh. Callers that need an existing file's entries must
/// call [`WalFile::read_entries`] (or, for a legacy-no-CRC file,
/// [`WalFile::read_entries_for_migration`]) first, before calling `open`.
pub fn open(path: &Path) -> Result<Self, MemoryError> {
if path.exists() {
// Read existing header
@@ -102,20 +212,71 @@ impl WalFile {
let mut ver = [0u8; 1];
f.read_exact(&mut ver)?;
match ver[0] {
WAL_VERSION => {
WAL_VERSION | WAL_VERSION_CHAINED_NO_UPDATE => {
if ver[0] == WAL_VERSION_CHAINED_NO_UPDATE {
// Same framing; stamp the current version so an older
// binary refuses this file rather than truncating an
// Update record it can't parse. See the constant.
f.seek(SeekFrom::Start(4))?;
f.write_all(&[WAL_VERSION])?;
f.seek(SeekFrom::Start(5))?;
}
let mut count_buf = [0u8; 4];
f.read_exact(&mut count_buf)?;
let entry_count = u32::from_le_bytes(count_buf);
// Seek to end for appending
f.seek(SeekFrom::End(0))?;
let header_count = u32::from_le_bytes(count_buf);
// Scan any existing entries to resume the CRC chain
// correctly for further appends (the header's count may
// be stale from deferred group-commit sync, same
// tolerance `read_entries` already has, so the scanned
// count is also the more accurate of the two).
let (entries, running_crc, verified_bytes) =
read_chained_entries(&mut f, 0, None);
let entry_count = if entries.is_empty() {
header_count
} else {
entries.len() as u32
};
// Position the append at the end of the VERIFIED prefix,
// and drop anything after it.
//
// This used to `seek(End(0))`, which appends PAST a torn
// tail — the ordinary outcome of a crash mid-append. The
// new entry is then chained to the last good entry, but
// sits on disk behind the garbage:
//
// [1..N verified][torn bytes][N+1 chained to N]
//
// Replay stops at the torn bytes, so N+1 is unreachable
// FOREVER even though its `append` returned Ok and synced.
// That is silent data loss in the one situation a WAL
// exists for. Truncating to the verified end is the
// standard recovery: the torn tail was never acknowledged
// to any caller, so discarding it loses nothing, and the
// chain then continues from a byte offset that matches
// `running_crc`.
let verified_end = WAL_HEADER_LEN + verified_bytes;
let file_len = f.metadata()?.len();
if file_len > verified_end {
eprintln!(
"clawhdf5-agent: WAL {} has {} unverifiable byte(s) after entry {}; \
discarding them so appends stay replayable",
path.display(),
file_len - verified_end,
entries.len()
);
f.set_len(verified_end)?;
}
f.seek(SeekFrom::Start(verified_end))?;
Ok(Self {
path: path.to_path_buf(),
file: Some(f),
entry_count,
pending_header_sync: 0,
running_crc,
chain_len: verified_bytes,
})
}
WAL_VERSION_LEGACY_NO_CRC => {
WAL_VERSION_CRC_UNCHAINED | WAL_VERSION_LEGACY_NO_CRC => {
drop(f);
let f = create_fresh_wal_file(path)?;
Ok(Self {
@@ -123,6 +284,8 @@ impl WalFile {
file: Some(f),
entry_count: 0,
pending_header_sync: 0,
running_crc: 0,
chain_len: 0,
})
}
v => Err(MemoryError::Schema(format!("unsupported WAL version {v}"))),
@@ -134,6 +297,8 @@ impl WalFile {
file: Some(f),
entry_count: 0,
pending_header_sync: 0,
running_crc: 0,
chain_len: 0,
})
}
}
@@ -157,8 +322,20 @@ impl WalFile {
4 + entry.session_id.len() +
4 + entry.tags.len(),
);
buf.push(WalEntryType::Save as u8);
buf.extend_from_slice(&entry.timestamp.to_le_bytes());
match entry.update_index {
Some(index) => {
let index = u32::try_from(index).map_err(|_| {
MemoryError::Schema(format!("WAL update index {index} exceeds u32"))
})?;
buf.push(WalEntryType::Update as u8);
buf.extend_from_slice(&entry.timestamp.to_le_bytes());
buf.extend_from_slice(&index.to_le_bytes());
}
None => {
buf.push(WalEntryType::Save as u8);
buf.extend_from_slice(&entry.timestamp.to_le_bytes());
}
}
serialize_str(&mut buf, &entry.chunk);
buf.extend_from_slice(&(emb_len as u32).to_le_bytes());
for &val in &entry.embedding {
@@ -168,7 +345,10 @@ impl WalFile {
serialize_str(&mut buf, &entry.session_id);
serialize_str(&mut buf, &entry.tags);
let crc = crc32(&buf);
// Chain this entry's CRC to the previous one's so reordering/
// splicing entries (not just flipping a bit within one) is detected
// on replay — see WAL_VERSION's doc comment.
let crc = chained_crc(&buf, self.running_crc);
buf.extend_from_slice(&crc.to_le_bytes());
let f = self
@@ -176,7 +356,9 @@ impl WalFile {
.as_mut()
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
f.write_all(&buf)?;
self.chain_len += buf.len() as u64;
self.running_crc = crc;
self.entry_count += 1;
self.pending_header_sync += 1;
if self.pending_header_sync >= GROUP_COMMIT_SIZE {
@@ -191,7 +373,7 @@ impl WalFile {
buf[0] = WalEntryType::Tombstone as u8;
buf[1..9].copy_from_slice(&timestamp.to_le_bytes());
buf[9..13].copy_from_slice(&(index as u32).to_le_bytes());
let crc = crc32(&buf[..13]);
let crc = chained_crc(&buf[..13], self.running_crc);
buf[13..17].copy_from_slice(&crc.to_le_bytes());
let f = self
@@ -199,7 +381,9 @@ impl WalFile {
.as_mut()
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
f.write_all(&buf)?;
self.chain_len += buf.len() as u64;
self.running_crc = crc;
self.entry_count += 1;
self.pending_header_sync += 1;
if self.pending_header_sync >= GROUP_COMMIT_SIZE {
@@ -214,9 +398,46 @@ impl WalFile {
/// (and may be stale if written with deferred group-commit updates). This
/// tolerates both truncated files (crash mid-write) and stale header counts
/// (crash before the next group-commit header sync). On a `WAL_VERSION`
/// file, a CRC32 mismatch on an entry is treated the same way — replay
/// stops there rather than accepting corrupted data.
/// file, a broken CRC chain (bit-flip, or an entry reordered/duplicated/
/// spliced in) is treated the same way — replay stops there rather than
/// accepting corrupted or tampered data. `WAL_VERSION_CRC_UNCHAINED`
/// files are read the same way minus the chain check (each entry's own
/// CRC is still verified).
///
/// Does **not** read [`WAL_VERSION_LEGACY_NO_CRC`] files — that format has
/// no integrity verification at all, so it's only reachable through
/// [`WalFile::read_entries_for_migration`], used exclusively by
/// `HDF5Memory::open`'s one-time migration path. Calling this on a
/// legacy-no-CRC file returns a typed error instead of silently
/// downgrading to the unverified parser.
pub fn read_entries(path: &Path) -> Result<Vec<WalEntry>, MemoryError> {
Self::read_entries_impl(path, false, None)
}
/// Like [`WalFile::read_entries`], but also accepts
/// [`WAL_VERSION_LEGACY_NO_CRC`] files (no per-entry integrity check at
/// all). Restricted to `pub(crate)` and named accordingly: the only
/// legitimate caller is `HDF5Memory::open`'s one-time migration of a
/// pre-CRC WAL file, which immediately recreates it in the current
/// format afterward. Do not use this for anything else.
///
/// `applied` is the checkpoint mark read from the `.h5` file, if any: if
/// the WAL's chain passes through it (same byte length, same chained
/// CRC), everything up to that point is already in the `.h5` and is
/// dropped. If it never does — the normal case, because the WAL was
/// truncated after the checkpoint — every entry is returned.
pub(crate) fn read_entries_for_migration(
path: &Path,
applied: Option<WalMark>,
) -> Result<Vec<WalEntry>, MemoryError> {
Self::read_entries_impl(path, true, applied)
}
fn read_entries_impl(
path: &Path,
allow_legacy_no_crc: bool,
applied: Option<WalMark>,
) -> Result<Vec<WalEntry>, MemoryError> {
if !path.exists() {
return Ok(Vec::new());
}
@@ -229,46 +450,62 @@ impl WalFile {
}
// entry_count is a pre-allocation hint only — we read until EOF.
let entry_count_hint = u32::from_le_bytes([header[5], header[6], header[7], header[8]]);
let mut entries = Vec::with_capacity(entry_count_hint as usize);
match header[4] {
WAL_VERSION => loop {
let raw_and_result = {
let mut tee = TeeReader::new(&mut f);
let result = read_one_entry(&mut tee);
(tee.into_buf(), result)
};
let (raw, result) = raw_and_result;
let entry_opt = match result {
Err(()) => break,
Ok(v) => v,
};
let mut crc_buf = [0u8; 4];
if f.read_exact(&mut crc_buf).is_err() {
break;
}
let stored_crc = u32::from_le_bytes(crc_buf);
if crc32(&raw) != stored_crc {
// Corruption detected — stop replay here, same as a clean
// truncation/EOF, rather than accepting the bad entry.
break;
}
if let Some(entry) = entry_opt {
entries.push(entry);
}
},
WAL_VERSION_LEGACY_NO_CRC => loop {
match read_one_entry(&mut f) {
Err(()) => break,
Ok(Some(entry)) => entries.push(entry),
Ok(None) => {}
}
},
v => {
return Err(MemoryError::Schema(format!("unsupported WAL version {v}")));
WAL_VERSION | WAL_VERSION_CHAINED_NO_UPDATE => {
let (entries, _final_crc, _verified_bytes) =
read_chained_entries(&mut f, 0, applied);
Ok(entries)
}
WAL_VERSION_CRC_UNCHAINED => {
let mut entries = Vec::with_capacity(entry_count_hint as usize);
loop {
let raw_and_result = {
let mut tee = TeeReader::new(&mut f);
let result = read_one_entry(&mut tee);
(tee.into_buf(), result)
};
let (raw, result) = raw_and_result;
let entry_opt = match result {
Err(()) => break,
Ok(v) => v,
};
let mut crc_buf = [0u8; 4];
if f.read_exact(&mut crc_buf).is_err() {
break;
}
let stored_crc = u32::from_le_bytes(crc_buf);
if crc32(&raw) != stored_crc {
// Corruption detected — stop replay here, same as a
// clean truncation/EOF, rather than accepting the bad
// entry.
break;
}
if let Some(entry) = entry_opt {
entries.push(entry);
}
}
Ok(entries)
}
WAL_VERSION_LEGACY_NO_CRC if allow_legacy_no_crc => {
let mut entries = Vec::with_capacity(entry_count_hint as usize);
loop {
match read_one_entry(&mut f) {
Err(()) => break,
Ok(Some(entry)) => entries.push(entry),
Ok(None) => {}
}
}
Ok(entries)
}
WAL_VERSION_LEGACY_NO_CRC => Err(MemoryError::Schema(
"WAL file is in the legacy no-CRC format (version 1), which read_entries() no \
longer accepts it has no per-entry integrity verification. Only the one-time \
migration path (WalFile::open) can read and upgrade it."
.into(),
)),
v => Err(MemoryError::Schema(format!("unsupported WAL version {v}"))),
}
Ok(entries)
}
/// Truncate the WAL (after merge into .h5).
@@ -279,9 +516,20 @@ impl WalFile {
self.file = Some(f);
self.entry_count = 0;
self.pending_header_sync = 0;
self.running_crc = 0;
self.chain_len = 0;
Ok(())
}
/// The mark covering every entry currently in this WAL. Store it with a
/// checkpoint taken from the state those entries produced.
pub fn mark(&self) -> WalMark {
WalMark {
len: self.chain_len,
crc: self.running_crc,
}
}
/// Number of pending entries.
pub fn pending_count(&self) -> u32 {
self.entry_count
@@ -321,6 +569,28 @@ pub fn replay_into_cache(entries: &[WalEntry], cache: &mut crate::cache::MemoryC
entry.tags.clone(),
);
}
WalEntryType::Update => match entry.update_index {
// The index was valid when the record was written; if the
// store no longer has it, keep the data rather than drop it.
Some(idx) if idx < cache.len() => cache.update(
idx,
entry.chunk.clone(),
entry.embedding.clone(),
entry.source_channel.clone(),
entry.timestamp,
entry.session_id.clone(),
),
_ => {
cache.push(
entry.chunk.clone(),
entry.embedding.clone(),
entry.source_channel.clone(),
entry.timestamp,
entry.session_id.clone(),
entry.tags.clone(),
);
}
},
WalEntryType::Tombstone => {
if let Some(idx) = entry.tombstone_index {
cache.mark_deleted(idx);
@@ -373,6 +643,81 @@ fn read_embedding<R: Read>(f: &mut R) -> Result<Vec<f32>, MemoryError> {
Ok(vals)
}
/// Compute the CRC32 trailer for a `WAL_VERSION` entry, chaining in the
/// previous entry's stored CRC (0 for the first entry after a truncation).
fn chained_crc(entry_bytes: &[u8], prev_crc: u32) -> u32 {
let mut chained = Vec::with_capacity(entry_bytes.len() + 4);
chained.extend_from_slice(entry_bytes);
chained.extend_from_slice(&prev_crc.to_le_bytes());
crc32(&chained)
}
/// Read and verify all entries from a `WAL_VERSION` (chained-CRC) stream
/// starting at the reader's current position, given the chain state to
/// resume from (0 for a stream starting at the beginning of a fresh WAL).
///
/// Returns the parsed entries, the final running CRC — the chain state to
/// continue from for further appends — and the number of BYTES consumed by
/// those verified entries. Stops (without erroring) at the first entry that
/// fails to parse or whose stored CRC doesn't match the expected chain value
/// — a bit-flip, truncation/EOF, or an entry having been
/// reordered/duplicated/spliced all produce a chain mismatch at that point,
/// and are all handled the same way: replay stops there.
///
/// The byte count is what lets `open()` position an append at the end of the
/// VERIFIED prefix rather than at end-of-file. Appending past a torn tail
/// writes entries that replay can never reach — see `open`.
///
/// `applied`, when given, is a checkpoint mark: once the chain reaches exactly
/// that position, the entries collected so far are discarded (they are
/// already in the `.h5` file). A zero-length mark matches nothing.
fn read_chained_entries<R: Read>(
f: &mut R,
start_crc: u32,
applied: Option<WalMark>,
) -> (Vec<WalEntry>, u32, u64) {
let applied = applied.filter(|m| m.len > 0);
let mut entries = Vec::new();
let mut running_crc = start_crc;
let mut verified_bytes: u64 = 0;
loop {
let raw_and_result = {
let mut tee = TeeReader::new(f);
let result = read_one_entry(&mut tee);
(tee.into_buf(), result)
};
let (raw, result) = raw_and_result;
let entry_opt = match result {
Err(()) => break,
Ok(v) => v,
};
let mut crc_buf = [0u8; 4];
if f.read_exact(&mut crc_buf).is_err() {
break;
}
let stored_crc = u32::from_le_bytes(crc_buf);
if chained_crc(&raw, running_crc) != stored_crc {
break;
}
running_crc = stored_crc;
// Only counted once the entry AND its CRC trailer verified, so the
// offset always points just past a complete, checked entry.
verified_bytes += raw.len() as u64 + crc_buf.len() as u64;
if let Some(entry) = entry_opt {
entries.push(entry);
}
if applied
== Some(WalMark {
len: verified_bytes,
crc: running_crc,
})
{
entries.clear();
}
}
(entries, running_crc, verified_bytes)
}
/// Create a fresh WAL file at `path` with the current-version header,
/// truncating/overwriting anything already there.
fn create_fresh_wal_file(path: &Path) -> Result<File, MemoryError> {
@@ -430,7 +775,14 @@ fn read_one_entry<R: Read>(r: &mut R) -> Result<Option<WalEntry>, ()> {
let timestamp = f64::from_le_bytes(ts_buf);
match entry_type {
WalEntryType::Save => {
WalEntryType::Save | WalEntryType::Update => {
let update_index = if entry_type == WalEntryType::Update {
let mut idx_buf = [0u8; 4];
r.read_exact(&mut idx_buf).map_err(|_| ())?;
Some(u32::from_le_bytes(idx_buf) as usize)
} else {
None
};
let chunk = read_len_prefixed_str(r).map_err(|_| ())?;
let embedding = read_embedding(r).map_err(|_| ())?;
let source_channel = read_len_prefixed_str(r).map_err(|_| ())?;
@@ -445,6 +797,7 @@ fn read_one_entry<R: Read>(r: &mut R) -> Result<Option<WalEntry>, ()> {
session_id,
tags,
tombstone_index: None,
update_index,
}))
}
WalEntryType::Tombstone => {
@@ -460,6 +813,7 @@ fn read_one_entry<R: Read>(r: &mut R) -> Result<Option<WalEntry>, ()> {
session_id: String::new(),
tags: String::new(),
tombstone_index: Some(idx),
update_index: None,
}))
}
WalEntryType::ActivationUpdate => Ok(None),
@@ -483,6 +837,7 @@ mod tests {
session_id: "sess-001".to_string(),
tags: "tag1,tag2".to_string(),
tombstone_index: None,
update_index: None,
}
}
@@ -601,7 +956,7 @@ mod tests {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("test.h5.wal");
let unicode_chunk = "Hello 世界! 🌍 émojis & ünïcödé";
let embedding = vec![0.1, -0.2, 3.14159, f32::MAX, f32::MIN_POSITIVE];
let embedding = vec![0.1, -0.2, 3.4567, f32::MAX, f32::MIN_POSITIVE];
{
let mut wal = WalFile::open(&wal_path).unwrap();
let entry = WalEntry {
@@ -613,6 +968,7 @@ mod tests {
session_id: "sess-öö-123".to_string(),
tags: "α,β,γ".to_string(),
tombstone_index: None,
update_index: None,
};
wal.append_save(&entry).unwrap();
}
@@ -747,6 +1103,148 @@ mod tests {
assert!(entries.is_empty());
}
/// Reopen `path` and return the stored chunks in order.
fn reopen_chunks(path: &std::path::Path) -> Vec<String> {
let mem = HDF5Memory::open(path).unwrap();
mem.cache.chunks.clone()
}
#[test]
fn crash_between_checkpoint_and_wal_truncate_does_not_duplicate() {
// flush() writes the new .h5 and only then truncates the WAL. Dying in
// between leaves BOTH a .h5 that contains the pending entries and a
// WAL that still lists them; replaying blindly used to double them.
let dir = TempDir::new().unwrap();
let config = make_config(&dir);
let h5_path = config.path.clone();
let wal_path = h5_path.with_extension("h5.wal");
let stale_wal = dir.path().join("stale.wal");
{
let mut mem = HDF5Memory::create(config).unwrap();
for name in ["a", "b", "c"] {
mem.save(make_entry(name, &[1.0, 0.0, 0.0, 0.0])).unwrap();
}
assert_eq!(mem.wal_pending_count(), 3);
std::fs::copy(&wal_path, &stale_wal).unwrap();
mem.flush_wal().unwrap();
}
// Undo the truncate: this is the on-disk state right after the crash.
std::fs::copy(&stale_wal, &wal_path).unwrap();
assert_eq!(WalFile::read_entries(&wal_path).unwrap().len(), 3);
assert_eq!(reopen_chunks(&h5_path), ["a", "b", "c"]);
// Entries appended to that same WAL after recovery are still replayed.
{
let mut mem = HDF5Memory::open(&h5_path).unwrap();
mem.save(make_entry("d", &[0.0, 1.0, 0.0, 0.0])).unwrap();
}
assert_eq!(reopen_chunks(&h5_path), ["a", "b", "c", "d"]);
}
#[test]
fn entries_written_after_a_completed_checkpoint_are_all_replayed() {
// Normal case: the checkpoint's mark refers to a WAL that has since
// been truncated, so it must not suppress anything in the new one —
// including when the new WAL grows past the old mark's length.
let dir = TempDir::new().unwrap();
let config = make_config(&dir);
let h5_path = config.path.clone();
{
let mut mem = HDF5Memory::create(config).unwrap();
mem.save(make_entry("a", &[1.0, 0.0, 0.0, 0.0])).unwrap();
mem.flush_wal().unwrap();
for name in ["b", "c", "d"] {
mem.save(make_entry(name, &[1.0, 0.0, 0.0, 0.0])).unwrap();
}
}
assert_eq!(reopen_chunks(&h5_path), ["a", "b", "c", "d"]);
}
#[test]
fn save_or_update_replays_as_update_not_duplicate() {
let dir = TempDir::new().unwrap();
let config = make_config(&dir);
let h5_path = config.path.clone();
{
let mut mem = HDF5Memory::create(config).unwrap();
let mut first = make_entry("v1", &[1.0, 0.0, 0.0, 0.0]);
first.tags = "key".into();
let mut second = make_entry("v2", &[0.0, 1.0, 0.0, 0.0]);
second.tags = "key".into();
let a = mem.save_or_update(first).unwrap();
mem.save(make_entry("other", &[0.0, 0.0, 1.0, 0.0]))
.unwrap();
let b = mem.save_or_update(second).unwrap();
assert_eq!(a, b);
assert_eq!(mem.cache.chunks, ["v2", "other"]);
// Dropped without a checkpoint: all three records live in the WAL.
}
let mem = HDF5Memory::open(&h5_path).unwrap();
assert_eq!(mem.cache.chunks, ["v2", "other"]);
assert_eq!(mem.cache.embeddings[0], [0.0, 1.0, 0.0, 0.0]);
}
#[test]
fn v3_wal_is_read_and_upgraded_in_place() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("old.wal");
{
let mut wal = WalFile::open(&wal_path).unwrap();
wal.append_save(&make_wal_entry("kept", &[1.0])).unwrap();
}
// Rewrite the header as the pre-Update chained format.
let mut bytes = std::fs::read(&wal_path).unwrap();
bytes[4] = WAL_VERSION_CHAINED_NO_UPDATE;
std::fs::write(&wal_path, &bytes).unwrap();
assert_eq!(WalFile::read_entries(&wal_path).unwrap().len(), 1);
{
let mut wal = WalFile::open(&wal_path).unwrap();
assert_eq!(wal.pending_count(), 1);
wal.append_save(&make_wal_entry("new", &[2.0])).unwrap();
}
assert_eq!(std::fs::read(&wal_path).unwrap()[4], WAL_VERSION);
let chunks: Vec<_> = WalFile::read_entries(&wal_path)
.unwrap()
.into_iter()
.map(|e| e.chunk)
.collect();
assert_eq!(chunks, ["kept", "new"]);
}
#[test]
fn mark_matching_is_exact() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("m.wal");
let mut wal = WalFile::open(&wal_path).unwrap();
wal.append_save(&make_wal_entry("one", &[1.0])).unwrap();
let after_one = wal.mark();
wal.append_save(&make_wal_entry("two", &[2.0])).unwrap();
let after_two = wal.mark();
drop(wal);
let read = |m| {
WalFile::read_entries_for_migration(&wal_path, m)
.unwrap()
.into_iter()
.map(|e| e.chunk)
.collect::<Vec<_>>()
};
assert_eq!(read(None), ["one", "two"]);
assert_eq!(read(Some(after_one)), ["two"]);
assert!(read(Some(after_two)).is_empty());
// Right length, wrong CRC (a different WAL generation): skip nothing.
let foreign = WalMark {
crc: after_one.crc ^ 1,
..after_one
};
assert_eq!(read(Some(foreign)), ["one", "two"]);
// Reopening resumes the same mark.
assert_eq!(WalFile::open(&wal_path).unwrap().mark(), after_two);
}
#[test]
fn test_wal_replay_on_open() {
// Test WAL replay using read_entries + replay_into_cache directly,
@@ -912,16 +1410,157 @@ mod tests {
assert_eq!(entries[0].chunk, "first");
}
/// A crash mid-append leaves a torn final entry. Reopening the WAL must
/// place the next append at the end of the VERIFIED prefix, not at
/// end-of-file, or that append is written behind garbage the replay
/// scanner stops at — unreachable forever despite having returned Ok.
///
/// This is the ordinary crash case, so getting it wrong loses
/// acknowledged writes in exactly the situation a WAL exists for.
#[test]
fn test_wal_reads_legacy_v1_format_without_crc() {
fn test_wal_append_after_torn_tail_stays_replayable() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("legacy.h5.wal");
let wal_path = dir.path().join("test.h5.wal");
let mut wal = WalFile::open(&wal_path).unwrap();
wal.append_save(&make_wal_entry("first", &[1.0, 2.0]))
.unwrap();
drop(wal);
// Simulate the crash: a partial entry appended after the good one.
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&wal_path)
.unwrap();
f.write_all(&[0xAB, 0xCD, 0xEF, 0x01, 0x02]).unwrap();
f.flush().unwrap();
}
// Reopen and append. The torn bytes must not survive between the
// verified prefix and the new entry.
let mut wal = WalFile::open(&wal_path).unwrap();
wal.append_save(&make_wal_entry("second", &[3.0, 4.0]))
.unwrap();
drop(wal);
let entries = WalFile::read_entries(&wal_path).unwrap();
assert_eq!(
entries.len(),
2,
"the append after a torn tail must be replayable; got {} entr(y/ies) — \
the post-crash write was silently lost",
entries.len()
);
}
/// Reordering two entries on disk must break the CRC chain — the
/// second entry's stored CRC was computed against the first entry's
/// real CRC, not against the chain state a reader sees after swapping
/// them, so replay stops immediately instead of accepting the tampered
/// order (INT-09).
#[test]
fn test_wal_detects_reordered_entries() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("test.h5.wal");
let mut wal = WalFile::open(&wal_path).unwrap();
wal.append_save(&make_wal_entry("first", &[1.0, 2.0]))
.unwrap();
let len_after_first = std::fs::metadata(&wal_path).unwrap().len() as usize;
wal.append_save(&make_wal_entry("second", &[3.0, 4.0]))
.unwrap();
let len_after_second = std::fs::metadata(&wal_path).unwrap().len() as usize;
drop(wal);
let bytes = std::fs::read(&wal_path).unwrap();
let header_len = 9usize;
let entry1_bytes = bytes[header_len..len_after_first].to_vec();
let entry2_bytes = bytes[len_after_first..len_after_second].to_vec();
let mut spliced = bytes[..header_len].to_vec();
spliced.extend_from_slice(&entry2_bytes);
spliced.extend_from_slice(&entry1_bytes);
std::fs::write(&wal_path, &spliced).unwrap();
let entries = WalFile::read_entries(&wal_path).unwrap();
assert!(
entries.is_empty(),
"reordered entries must break the CRC chain and stop replay, got {} entries",
entries.len()
);
}
/// Splicing a third-party entry in between two legitimate entries (e.g.
/// moving a Tombstone in front of the Save it's meant to follow) must
/// also break the chain for everything after the splice point.
#[test]
fn test_wal_detects_spliced_entry() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("test.h5.wal");
let mut wal = WalFile::open(&wal_path).unwrap();
wal.append_save(&make_wal_entry("first", &[1.0])).unwrap();
let len_after_first = std::fs::metadata(&wal_path).unwrap().len() as usize;
wal.append_save(&make_wal_entry("second", &[2.0])).unwrap();
let len_after_second = std::fs::metadata(&wal_path).unwrap().len() as usize;
wal.append_save(&make_wal_entry("third", &[3.0])).unwrap();
drop(wal);
let bytes = std::fs::read(&wal_path).unwrap();
let entry2_bytes = bytes[len_after_first..len_after_second].to_vec();
// Duplicate "second" right after itself: [first][second][second][third]
let mut spliced = bytes[..len_after_second].to_vec();
spliced.extend_from_slice(&entry2_bytes);
spliced.extend_from_slice(&bytes[len_after_second..]);
std::fs::write(&wal_path, &spliced).unwrap();
let entries = WalFile::read_entries(&wal_path).unwrap();
assert_eq!(
entries.len(),
2,
"replay must stop at the spliced duplicate, keeping only the entries before it"
);
assert_eq!(entries[0].chunk, "first");
assert_eq!(entries[1].chunk, "second");
}
/// A WAL closed (without truncating) and reopened must continue the CRC
/// chain correctly for newly appended entries — this is the normal
/// crash-restart-without-flush scenario (`HDF5Memory::open` replays
/// existing entries, then reopens the same file for further appends
/// without clearing it), and must not produce a false "reordering"
/// detection for its own legitimately-appended entries.
#[test]
fn test_wal_chain_continues_across_reopen() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("test.h5.wal");
let mut wal = WalFile::open(&wal_path).unwrap();
wal.append_save(&make_wal_entry("first", &[1.0])).unwrap();
drop(wal); // simulate a restart without ever truncating the WAL
let mut wal2 = WalFile::open(&wal_path).unwrap();
wal2.append_save(&make_wal_entry("second", &[2.0])).unwrap();
drop(wal2);
let entries = WalFile::read_entries(&wal_path).unwrap();
assert_eq!(
entries.len(),
2,
"both pre- and post-reopen entries must replay cleanly"
);
assert_eq!(entries[0].chunk, "first");
assert_eq!(entries[1].chunk, "second");
}
/// Build a legacy (WAL_VERSION_LEGACY_NO_CRC) WAL file containing one
/// Save entry, with no trailing CRC32.
fn build_legacy_v1_wal_bytes() -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(&WAL_MAGIC);
buf.push(WAL_VERSION_LEGACY_NO_CRC);
buf.extend_from_slice(&1u32.to_le_bytes());
// One Save entry in the old format: type + timestamp + fields, with
// no trailing CRC32.
buf.push(WalEntryType::Save as u8);
buf.extend_from_slice(&42.0f64.to_le_bytes());
serialize_str(&mut buf, "legacy-chunk");
@@ -933,14 +1572,39 @@ mod tests {
serialize_str(&mut buf, "chan");
serialize_str(&mut buf, "sess");
serialize_str(&mut buf, "tags");
std::fs::write(&wal_path, &buf).unwrap();
buf
}
let entries = WalFile::read_entries(&wal_path).unwrap();
#[test]
fn test_wal_reads_legacy_v1_format_without_crc() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("legacy.h5.wal");
std::fs::write(&wal_path, build_legacy_v1_wal_bytes()).unwrap();
// Only the migration-only reader may read a legacy no-CRC file.
let entries = WalFile::read_entries_for_migration(&wal_path, None).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].chunk, "legacy-chunk");
assert_eq!(entries[0].embedding, vec![1.0, 2.0]);
}
/// The public `read_entries` must reject a legacy no-CRC file instead of
/// silently downgrading to the fully-unverified parser (INT-09) — flipping
/// a version byte from 2/3 down to 1 must not be a way to bypass every
/// integrity check for an arbitrary caller of the public API.
#[test]
fn test_wal_read_entries_rejects_legacy_v1_format() {
let dir = TempDir::new().unwrap();
let wal_path = dir.path().join("legacy.h5.wal");
std::fs::write(&wal_path, build_legacy_v1_wal_bytes()).unwrap();
let result = WalFile::read_entries(&wal_path);
assert!(
result.is_err(),
"read_entries() must reject a legacy no-CRC WAL file, not silently parse it"
);
}
#[test]
fn test_wal_open_migrates_legacy_v1_to_current_version() {
let dir = TempDir::new().unwrap();
@@ -0,0 +1,187 @@
//! Crash-recovery matrix for `HDF5Memory`.
//!
//! A process crash leaves whatever reached the OS on disk. These tests build
//! the on-disk images such a crash can leave behind — after every operation,
//! inside the checkpoint window (new `.h5` in place, WAL not yet truncated),
//! and with the WAL torn at every possible length — then reopen each image
//! and check the recovered store against a model of what was acknowledged.
//!
//! Invariants:
//! * never a duplicated or invented record;
//! * an image taken between operations recovers *exactly* the acknowledged
//! state;
//! * a torn WAL recovers the last checkpoint plus a prefix of the operations
//! logged since.
use std::path::{Path, PathBuf};
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
use tempfile::TempDir;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
fn entry(chunk: &str, tags: &str) -> MemoryEntry {
MemoryEntry {
chunk: chunk.to_string(),
embedding: vec![1.0, 0.0, 0.0, 0.0],
source_channel: "test".into(),
timestamp: 1.0,
session_id: "s".into(),
tags: tags.to_string(),
}
}
fn wal_path(h5: &Path) -> PathBuf {
h5.with_extension("h5.wal")
}
/// Copy the store (`.h5` + WAL) into a fresh directory, as a crash image.
fn image(h5: &Path, into: &TempDir, name: &str) -> PathBuf {
let dest = into.path().join(format!("{name}.h5"));
std::fs::copy(h5, &dest).unwrap();
if wal_path(h5).exists() {
std::fs::copy(wal_path(h5), wal_path(&dest)).unwrap();
}
dest
}
fn recovered(h5: &Path) -> Vec<String> {
// Read-only: the image must not be modified, and no lock is needed.
HDF5Memory::open_read_only(h5).unwrap().cache.chunks.clone()
}
/// Apply one random operation to the store and to the model.
fn step(mem: &mut HDF5Memory, model: &mut Vec<String>, rng: &mut Rng, n: usize) {
match rng.below(6) {
0 => mem.flush_wal().unwrap(),
1 if !model.is_empty() => {
// Update an existing record in place, addressed by its tag.
let idx = rng.below(model.len());
let chunk = format!("u{n}");
assert_eq!(
mem.save_or_update(entry(&chunk, &format!("tag{idx}")))
.unwrap(),
idx
);
model[idx] = chunk;
}
_ => {
let chunk = format!("c{n}");
mem.save(entry(&chunk, &format!("tag{}", model.len())))
.unwrap();
model.push(chunk);
}
}
}
#[test]
fn image_after_every_operation_recovers_the_acknowledged_state() {
for seed in 0..40u64 {
let mut rng = Rng(seed);
let dir = TempDir::new().unwrap();
let images = TempDir::new().unwrap();
let mut config = MemoryConfig::new(dir.path().join("store.h5"), "agent", 4);
config.wal_enabled = true;
config.wal_max_entries = 1 + rng.below(6); // force frequent checkpoints
let h5 = config.path.clone();
let mut mem = HDF5Memory::create(config).unwrap();
let mut model = Vec::new();
for n in 0..30 {
step(&mut mem, &mut model, &mut rng, n);
let img = image(&h5, &images, &format!("s{seed}-{n}"));
assert_eq!(recovered(&img), model, "seed {seed}, after op {n}");
}
}
}
#[test]
fn crash_inside_the_checkpoint_window_never_duplicates() {
for seed in 0..40u64 {
let mut rng = Rng(seed ^ 0xABCD);
let dir = TempDir::new().unwrap();
let images = TempDir::new().unwrap();
let mut config = MemoryConfig::new(dir.path().join("store.h5"), "agent", 4);
config.wal_enabled = true;
config.wal_max_entries = 1000; // checkpoints only when we ask
let h5 = config.path.clone();
let mut mem = HDF5Memory::create(config).unwrap();
let mut model = Vec::new();
for round in 0..4 {
for n in 0..(1 + rng.below(6)) {
step(&mut mem, &mut model, &mut rng, round * 100 + n);
}
// The WAL as it is just before the checkpoint...
let stale_wal = images.path().join(format!("stale-{seed}-{round}.wal"));
if wal_path(&h5).exists() {
std::fs::copy(wal_path(&h5), &stale_wal).unwrap();
}
mem.flush_wal().unwrap();
// ...put back next to the NEW .h5: the crash-in-the-window image.
let img = image(&h5, &images, &format!("w{seed}-{round}"));
if stale_wal.exists() {
std::fs::copy(&stale_wal, wal_path(&img)).unwrap();
}
assert_eq!(recovered(&img), model, "seed {seed}, round {round}");
}
}
}
#[test]
fn torn_wal_recovers_checkpoint_plus_a_prefix() {
let dir = TempDir::new().unwrap();
let images = TempDir::new().unwrap();
let mut config = MemoryConfig::new(dir.path().join("store.h5"), "agent", 4);
config.wal_enabled = true;
config.wal_max_entries = 1000;
let h5 = config.path.clone();
let mut mem = HDF5Memory::create(config).unwrap();
for name in ["a", "b"] {
mem.save(entry(name, name)).unwrap();
}
mem.flush_wal().unwrap();
let checkpointed = vec!["a".to_string(), "b".to_string()];
// States the store passes through as each later op is logged.
let mut states = vec![checkpointed.clone()];
let mut model = checkpointed.clone();
mem.save(entry("c", "c")).unwrap();
model.push("c".into());
states.push(model.clone());
mem.save_or_update(entry("a2", "a")).unwrap();
model[0] = "a2".into();
states.push(model.clone());
mem.save(entry("d", "d")).unwrap();
model.push("d".into());
states.push(model.clone());
let full_wal = std::fs::read(wal_path(&h5)).unwrap();
let mut seen = std::collections::BTreeSet::new();
for len in 0..=full_wal.len() {
let img = image(&h5, &images, &format!("t{len}"));
std::fs::write(wal_path(&img), &full_wal[..len]).unwrap();
let got = recovered(&img);
let which = states
.iter()
.position(|s| *s == got)
.unwrap_or_else(|| panic!("WAL torn at {len} bytes recovered {got:?}"));
seen.insert(which);
}
// Every intermediate state is reachable, and the full WAL gives the last.
assert_eq!(seen.into_iter().collect::<Vec<_>>(), [0, 1, 2, 3]);
}
+12 -10
View File
@@ -196,7 +196,7 @@ fn test_migration_round_trip() {
mem.add_relation(e1, e2, "discusses", 0.8).unwrap();
// Verify all data transferred by reopening
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 500);
// Verify sessions
@@ -266,7 +266,7 @@ fn test_knowledge_graph_workflow() {
assert_eq!(entity.entity_type, "library");
// Persistence
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.knowledge().entities.len(), 4);
assert_eq!(reopened.knowledge().relations.len(), 4);
@@ -316,7 +316,7 @@ fn test_multi_session_workflow() {
assert_eq!(mem.count(), 100); // 5 sessions * 20 entries
// Reopen and verify sessions
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
for sess in 0..5 {
let summary = reopened
.get_session_summary(&format!("sess_{sess}"))
@@ -460,7 +460,7 @@ fn test_snapshot_and_continue() {
assert_eq!(snap_mem.count(), 50);
// Original should have 100
let orig_mem = HDF5Memory::open(&path).unwrap();
let orig_mem = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(orig_mem.count(), 100);
}
@@ -483,7 +483,7 @@ fn test_config_persistence_across_ops() {
mem.add_session("s1", 0, 0, "ch", "summary").unwrap();
mem.add_entity("Entity", "type", -1).unwrap();
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.config().embedding_dim, 128);
assert_eq!(reopened.config().embedder, "custom:my-embedder-v2");
assert_eq!(reopened.config().chunk_size, 2048);
@@ -695,7 +695,7 @@ fn test_large_text_chunks() {
mem.save_batch(entries).unwrap();
// Reopen and verify
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 10);
let (_, cache, _, _) = read_cache(&path);
@@ -752,7 +752,7 @@ fn test_interleaved_sessions_entries() {
mem.flush_wal().unwrap();
// Verify
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 6);
assert_eq!(
reopened.get_session_summary("s1").unwrap().as_deref(),
@@ -806,7 +806,7 @@ fn test_knowledge_graph_with_embeddings() {
mem.add_relation(e_python, e_hdf5, "reads", 0.9).unwrap();
// Verify entity-embedding linkage persists
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
let rust_entity = reopened.knowledge().get_entity(e_rust).unwrap();
assert_eq!(rust_entity.embedding_idx, idx0 as i64);
@@ -1048,7 +1048,7 @@ fn test_gpu_l2_fallback_works() {
let tombstones = vec![0u8; 3];
let gpu = clawhdf5_agent::gpu_search::GpuSearchBackend::try_init(&vectors, &norms, 2, 1);
let results = gpu.search_l2(&vec![0.0, 0.0], &vectors, &tombstones, 3);
let results = gpu.search_l2(&[0.0, 0.0], &vectors, &tombstones, 3);
assert_eq!(results.len(), 3);
assert_eq!(results[0].0, 0);
@@ -1099,7 +1099,7 @@ fn test_mmap_reader_direct_access() {
// Open via MmapReader directly
let mmap = clawhdf5_io::MmapReader::open(&path).unwrap();
assert!(mmap.len() > 0);
assert!(!mmap.is_empty());
// Verify we can read bytes at specific offsets
let bytes = mmap.read_at(0, 8);
assert!(bytes.is_some());
@@ -1144,9 +1144,11 @@ fn test_strategy_reports_backend() {
let tombstones = vec![0u8; n];
let query = vectors[0].clone();
let flat: Vec<f32> = vectors.iter().flatten().copied().collect();
let (_, metrics) = strategy::search_with_metrics(
&query,
&vectors,
&flat,
&norms,
&tombstones,
5,
@@ -165,3 +165,72 @@ 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);
}
@@ -137,10 +137,10 @@ fn bench_hit_at_1_1014_records() {
0.3,
1,
);
if let Some((top_idx, _)) = results.first() {
if *top_idx == target_indices[qi] {
hits += 1;
}
if let Some((top_idx, _)) = results.first()
&& *top_idx == target_indices[qi]
{
hits += 1;
}
}
+5 -5
View File
@@ -105,7 +105,7 @@ fn test_heavy_tombstoning() {
assert_eq!(mem.count_active(), 5000);
// Verify persistence
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 5000);
}
@@ -163,7 +163,7 @@ fn test_large_embeddings_1536() {
assert_eq!(mem.count(), 10_000);
// Verify persistence
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 10_000);
// Verify search works on large dims
@@ -545,7 +545,7 @@ fn test_delete_all_entries() {
assert_eq!(mem.count(), 0);
// Verify persistence
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 0);
}
@@ -639,7 +639,7 @@ fn test_unicode_content() {
];
mem.save_batch(entries).unwrap();
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 3);
let (_, cache, _, _) = clawhdf5_agent::storage::read_from_disk(&path).unwrap();
@@ -685,6 +685,6 @@ fn test_rapid_save_delete_cycles() {
assert_eq!(removed, 250);
assert_eq!(mem.count(), 250);
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 250);
}
@@ -0,0 +1,213 @@
//! Property tests for the write-ahead log.
//!
//! A deterministic generator (no external crates, reproducible from the seed
//! printed on failure) drives thousands of cases through two properties:
//!
//! 1. **Round trip** — whatever was appended is read back, in order, intact.
//! 2. **Prefix under corruption** — after *any* damage to the file (bit flips,
//! truncation, inserted or deleted bytes, duplicated or reordered regions),
//! reading never panics and yields an exact *prefix* of what was written.
//! This is the guarantee the chained CRC exists to provide: replay may stop
//! early, but it never returns a corrupted, reordered, or invented entry.
use clawhdf5_agent::wal::{WalEntry, WalEntryType, WalFile};
/// SplitMix64: tiny, well-distributed, and fully determined by its seed.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
fn string(&mut self, max_len: usize) -> String {
const ALPHABET: &[char] = &['a', 'Z', '0', ' ', '\n', '\0', 'é', '漢', '🦀', '"'];
(0..self.below(max_len + 1))
.map(|_| ALPHABET[self.below(ALPHABET.len())])
.collect()
}
}
/// What a test appended, in a form comparable with what is read back.
#[derive(Debug, Clone, PartialEq)]
enum Logged {
Save(String, Vec<u32>, String, String, String, u64),
Update(usize, String, Vec<u32>, u64),
Tombstone(usize, u64),
}
fn logged(entry: &WalEntry) -> Logged {
// Compare floats by bit pattern so NaN payloads and -0.0 count as intact.
let bits: Vec<u32> = entry.embedding.iter().map(|f| f.to_bits()).collect();
let ts = entry.timestamp.to_bits();
match entry.entry_type {
WalEntryType::Save => Logged::Save(
entry.chunk.clone(),
bits,
entry.source_channel.clone(),
entry.session_id.clone(),
entry.tags.clone(),
ts,
),
WalEntryType::Update => {
Logged::Update(entry.update_index.unwrap(), entry.chunk.clone(), bits, ts)
}
WalEntryType::Tombstone => Logged::Tombstone(entry.tombstone_index.unwrap(), ts),
WalEntryType::ActivationUpdate => unreachable!("never written by these tests"),
}
}
/// Append a random mix of records; return what was written.
fn write_random_wal(path: &std::path::Path, rng: &mut Rng) -> Vec<Logged> {
let mut wal = WalFile::open(path).unwrap();
let mut written = Vec::new();
for _ in 0..rng.below(12) {
let timestamp = f64::from_bits(rng.next());
if rng.below(5) == 0 {
let index = rng.below(1000);
wal.append_tombstone(index, timestamp).unwrap();
written.push(Logged::Tombstone(index, timestamp.to_bits()));
continue;
}
let update_index = (rng.below(4) == 0).then(|| rng.below(1000));
let entry = WalEntry {
entry_type: if update_index.is_some() {
WalEntryType::Update
} else {
WalEntryType::Save
},
timestamp,
chunk: rng.string(40),
embedding: (0..rng.below(9))
.map(|_| f32::from_bits(rng.next() as u32))
.collect(),
source_channel: rng.string(8),
session_id: rng.string(8),
tags: rng.string(8),
tombstone_index: None,
update_index,
};
wal.append_save(&entry).unwrap();
written.push(logged(&entry));
}
written
}
fn read_back(path: &std::path::Path) -> Option<Vec<Logged>> {
WalFile::read_entries(path)
.ok()
.map(|entries| entries.iter().map(logged).collect())
}
#[test]
fn everything_appended_is_read_back_intact() {
let dir = tempfile::TempDir::new().unwrap();
for seed in 0..300u64 {
let path = dir.path().join(format!("rt-{seed}.wal"));
let written = write_random_wal(&path, &mut Rng(seed));
assert_eq!(read_back(&path).unwrap(), written, "seed {seed}");
// Reopening (which scans and repositions) must not disturb anything.
drop(WalFile::open(&path).unwrap());
assert_eq!(
read_back(&path).unwrap(),
written,
"seed {seed} after reopen"
);
}
}
/// Damage `bytes` in one of several ways.
fn corrupt(bytes: &mut Vec<u8>, rng: &mut Rng) {
if bytes.is_empty() {
return;
}
match rng.below(7) {
0 => {
let i = rng.below(bytes.len());
bytes[i] ^= 1 << rng.below(8);
}
1 => bytes.truncate(rng.below(bytes.len())),
2 => {
let i = rng.below(bytes.len() + 1);
bytes.insert(i, rng.next() as u8);
}
3 => {
let i = rng.below(bytes.len());
bytes.remove(i);
}
4 => {
// Duplicate a region in place (a replayed/duplicated entry).
let a = rng.below(bytes.len());
let b = a + rng.below(bytes.len() - a);
let region = bytes[a..b].to_vec();
let at = rng.below(bytes.len() + 1);
bytes.splice(at..at, region);
}
5 => {
// Swap two regions (reordered entries).
let mid = rng.below(bytes.len());
bytes.rotate_left(mid);
}
_ => {
let i = rng.below(bytes.len());
let n = rng.below(bytes.len() - i + 1);
for b in &mut bytes[i..i + n] {
*b = rng.next() as u8;
}
}
}
}
#[test]
fn any_corruption_yields_a_prefix_never_a_wrong_entry() {
let dir = tempfile::TempDir::new().unwrap();
let mut shortened = 0u32;
for seed in 0..1500u64 {
let mut rng = Rng(seed ^ 0xC0FF_EE00);
let path = dir.path().join("c.wal");
let _ = std::fs::remove_file(&path);
let written = write_random_wal(&path, &mut rng);
let mut bytes = std::fs::read(&path).unwrap();
for _ in 0..=rng.below(3) {
corrupt(&mut bytes, &mut rng);
}
std::fs::write(&path, &bytes).unwrap();
// An unreadable header is a clean error; anything else is a prefix.
if let Some(read) = read_back(&path) {
assert!(
read.len() <= written.len() && read[..] == written[..read.len()],
"seed {seed}: read {read:?}\nis not a prefix of {written:?}"
);
if read.len() < written.len() {
shortened += 1;
}
// Opening for append repairs the tail; what was readable stays so,
// and a new entry lands right after it.
if let Ok(mut wal) = WalFile::open(&path) {
wal.append_tombstone(7, 1.0).unwrap();
drop(wal);
let mut expected = read.clone();
expected.push(Logged::Tombstone(7, 1.0f64.to_bits()));
assert_eq!(
read_back(&path).unwrap(),
expected,
"seed {seed} after repair"
);
}
}
}
assert!(
shortened > 100,
"corruption rarely took effect: {shortened}"
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-android"
version = "2.1.0"
version = "2.6.0"
edition = "2024"
description = "Android JNI bridge for edgehdf5-memory HDF5 backend"
license = "MIT"
+5 -4
View File
@@ -1,17 +1,18 @@
[package]
name = "clawhdf5-ann"
version = "2.1.0"
version = "2.6.0"
edition = "2024"
description = "HNSW approximate nearest neighbor index stored as HDF5"
license = "MIT"
repository = "https://github.com/redclawsystems/clawhdf5"
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
readme = "README.md"
keywords = ["hdf5", "ann", "hnsw", "nearest-neighbor"]
categories = ["algorithms", "science"]
[dependencies]
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.1.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
clawhdf5-io = { path = "../clawhdf5-io", version = "2.6.0" }
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.6.0" }
rayon = { version = "1", optional = true }
[features]
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,4 +5,4 @@
mod hnsw;
pub use hnsw::{DistanceMetric, HnswIndex};
pub use hnsw::{DistanceMetric, HnswIndex, Storage};
+12 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "clawhdf5-bench"
version = "2.1.0"
version = "2.6.0"
edition = "2024"
description = "Benchmark harnesses for clawhdf5-agent (Track 8)"
license = "MIT"
@@ -13,6 +13,14 @@ path = "src/bin/longmemeval_bench.rs"
name = "memory_arena"
path = "src/bin/memory_arena.rs"
[[bin]]
name = "read_harness"
path = "src/bin/read_harness.rs"
[[bin]]
name = "search_harness"
path = "src/bin/search_harness.rs"
[[bin]]
name = "footprint_bench"
path = "src/bin/footprint_bench.rs"
@@ -48,6 +56,9 @@ harness = false
[dependencies]
clawhdf5-agent = { path = "../clawhdf5-agent" }
clawhdf5-ann = { path = "../clawhdf5-ann" }
clawhdf5 = { path = "../clawhdf5" }
clawhdf5-format = { path = "../clawhdf5-format" }
clawhdf5-io = { path = "../clawhdf5-io" }
mpi = { version = "0.8", optional = true }
serde = { workspace = true }
@@ -22,7 +22,9 @@
use std::time::Instant;
use clawhdf5_agent::bm25::BM25Index;
use clawhdf5_agent::consolidation::{ConsolidationConfig, ConsolidationEngine, MemorySource};
use clawhdf5_agent::consolidation::{
ConsolidationConfig, ConsolidationEngine, TrustedSource, UntrustedSource,
};
use clawhdf5_agent::hybrid::hybrid_search;
const EMBEDDING_DIM: usize = 384;
@@ -232,7 +234,7 @@ fn run_quality_benchmark() {
for i in 0..SIGNAL_KEYWORDS.len() {
let chunk = make_signal_content(i);
let embedding = make_embedding(i * 1000);
let id = engine.add_memory(chunk, embedding, MemorySource::Correction, now);
let id = engine.add_trusted_memory(chunk, embedding, TrustedSource::Correction, now);
signal_ids.push(id);
}
@@ -240,7 +242,12 @@ fn run_quality_benchmark() {
for i in 0..990 {
let chunk = make_noise_content(i);
let embedding = make_embedding(i + 100);
engine.add_memory(chunk, embedding, MemorySource::System, now + i as f64 * 0.1);
engine.add_trusted_memory(
chunk,
embedding,
TrustedSource::System,
now + i as f64 * 0.1,
);
}
println!(" → Inserted {} records total", engine.records().len());
@@ -333,7 +340,7 @@ fn run_cycle_time_benchmark() {
for i in 0..n {
let chunk = make_noise_content(i);
let embedding = make_embedding(i);
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
engine.add_memory(chunk, embedding, UntrustedSource::User, now + i as f64);
}
// Warmup
@@ -344,7 +351,7 @@ fn run_cycle_time_benchmark() {
for i in n..(n * 2) {
let chunk = make_noise_content(i);
let embedding = make_embedding(i);
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
engine.add_memory(chunk, embedding, UntrustedSource::User, now + i as f64);
}
// Timed consolidation
@@ -410,13 +417,13 @@ fn run_memory_reduction_benchmark() {
for i in 0..signal_count {
let chunk = make_signal_content(i % SIGNAL_KEYWORDS.len());
let emb = make_embedding(i * 999);
let id = engine.add_memory(chunk, emb, MemorySource::Correction, now);
let id = engine.add_trusted_memory(chunk, emb, TrustedSource::Correction, now);
signal_ids.push(id);
}
for i in 0..noise_count {
let chunk = make_noise_content(i);
let emb = make_embedding(i + 200);
engine.add_memory(chunk, emb, MemorySource::System, now + i as f64 * 0.1);
engine.add_trusted_memory(chunk, emb, TrustedSource::System, now + i as f64 * 0.1);
}
// Access signal records heavily
@@ -55,44 +55,150 @@ use std::time::{Duration, Instant};
#[path = "longmemeval_bench/embedder.rs"]
mod embedder;
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
use clawhdf5_agent::bm25::TokenFilter;
use clawhdf5_agent::hybrid::Fusion;
use clawhdf5_agent::reranker::{ReRankConfig, RerankInput, rerank};
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry, SearchResult};
use serde::Deserialize;
use tempfile::TempDir;
const EMBEDDING_DIM: usize = 384;
/// A mode's fusion, as one short string for the reports.
fn describe(mode: Mode) -> String {
let fusion = match mode.fusion {
Fusion::Weighted { vector, keyword } => format!("vector_{vector:.1}_keyword_{keyword:.1}"),
Fusion::Rrf { k } => format!("rrf_k{k:.0}"),
};
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
),
}
}
/// A retrieval configuration: how much of the score comes from each stage.
#[derive(Clone, Copy)]
struct Mode {
label: &'static str,
vector_weight: f32,
keyword_weight: f32,
/// How the two retrieval stages are combined into one ranking.
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 {
const fn weighted(label: &'static str, vector: f32, keyword: f32) -> Self {
Self {
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;
self
}
}
/// The only mode available without real embeddings. Passing zero vectors with
/// `vector_weight = 0.0` is what made the vector stage inert.
const BM25_ONLY: Mode = Mode {
label: "BM25 only (vector stage inert)",
vector_weight: 0.0,
keyword_weight: 1.0,
};
const BM25_ONLY: Mode = Mode::weighted("BM25 only (vector stage inert)", 0.0, 1.0);
#[cfg(feature = "embeddings")]
const VECTOR_ONLY: Mode = Mode {
label: "Vector only (MiniLM + HNSW)",
vector_weight: 1.0,
keyword_weight: 0.0,
};
const VECTOR_ONLY: Mode = Mode::weighted("Vector only (MiniLM + HNSW)", 1.0, 0.0);
/// Tuned by `--sweep` over the full haystack. The former 0.7/0.3 was a
/// documented default that had never been searched, and the sweep found it
/// strictly dominated: 0.4/0.6 is better on Hit@1, Hit@5, Hit@10 and MRR at
/// both granularities.
#[cfg(feature = "embeddings")]
const HYBRID: Mode = Mode {
label: "Hybrid (0.4 vector / 0.6 BM25, tuned)",
vector_weight: 0.4,
keyword_weight: 0.6,
const HYBRID: Mode = Mode::weighted("Hybrid (0.4 vector / 0.6 BM25, tuned)", 0.4, 0.6);
/// Reciprocal rank fusion, the documented alternative to the weighted sum.
/// It ignores score magnitudes, so there is nothing to tune — which is the
/// claim being tested.
#[cfg(feature = "embeddings")]
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");
/// Every 0.1 step of vector weight, keyword weight taking the remainder.
///
/// Labels are leaked to `&'static str` because `Mode::label` is a `&'static
@@ -104,11 +210,11 @@ fn sweep_modes() -> Vec<Mode> {
(0..=10)
.map(|i| {
let v = i as f32 / 10.0;
Mode {
label: Box::leak(format!("sweep v={v:.1} / k={:.1}", 1.0 - v).into_boxed_str()),
vector_weight: v,
keyword_weight: 1.0 - v,
}
Mode::weighted(
Box::leak(format!("sweep v={v:.1} / k={:.1}", 1.0 - v).into_boxed_str()),
v,
1.0 - v,
)
})
.collect()
}
@@ -181,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)
}
// ---------------------------------------------------------------------------
@@ -199,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
}
@@ -261,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,
}
@@ -276,19 +433,26 @@ fn evaluate_question(
config.compact_threshold = 0.0;
let mut memory = HDF5Memory::create(config).expect("failed to create HDF5Memory");
memory.set_token_filter(mode.tokens);
// 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),
@@ -302,7 +466,6 @@ fn evaluate_question(
},
});
turn_has_answer.push(turn.has_answer);
ts += 1.0;
}
}
@@ -319,17 +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(
&query_emb,
&q.question,
mode.vector_weight,
mode.keyword_weight,
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;
@@ -384,6 +617,7 @@ fn evaluate_question(
hit5_turn,
hit10_turn,
rr_turn,
newest_gold_first,
latency,
}
}
@@ -472,10 +706,7 @@ fn print_report(
println!(" LongMemEval Benchmark — {}", mode.label);
println!("=================================================================");
println!();
println!(
"Mode: vector_weight={:.1} / keyword_weight={:.1}",
mode.vector_weight, mode.keyword_weight
);
println!("Mode: {}", describe(mode));
println!();
println!("Scoring target: RETRIEVAL RECALL (did the gold memory land in top-k).");
println!(" No answer is generated or scored. This is NOT the official");
@@ -538,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!(
@@ -602,10 +851,7 @@ fn print_report(
println!("```json");
println!("{{");
println!(" \"benchmark\": \"longmemeval\",");
println!(
" \"mode\": \"vector_{:.1}_keyword_{:.1}\",",
mode.vector_weight, mode.keyword_weight
);
println!(" \"mode\": \"{}\",", describe(mode));
println!(" \"dataset_variant\": \"{}\",", profile.variant());
println!(" \"scoring_target\": \"retrieval_recall\",");
println!(" \"k\": 10,");
@@ -654,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}",
@@ -676,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() {
@@ -684,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"));
}
@@ -702,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."
@@ -767,19 +1036,34 @@ 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, VECTOR_ONLY, HYBRID]
vec![
BM25_ONLY,
VECTOR_ONLY,
HYBRID,
RRF,
BM25_STEMMED,
HYBRID_STEMMED,
hybrid_rerank_metadata_only(),
hybrid_rerank_blended(),
]
}
}
#[cfg(not(feature = "embeddings"))]
{
vec![BM25_ONLY]
vec![BM25_ONLY, BM25_STEMMED]
}
} else {
if sweep {
eprintln!("warning: --sweep needs --embeddings; running BM25 only");
}
vec![BM25_ONLY]
// Stemming is a property of the keyword stage, so it can be compared
// without a model.
vec![BM25_ONLY, BM25_STEMMED]
};
for (mode_idx, mode) in modes.iter().enumerate() {
@@ -882,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);
@@ -893,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");
}
}
@@ -0,0 +1,176 @@
//! HDF5 read-path measurement harness: full reads vs. hyperslab selections on
//! a chunked 2-D dataset, compressed and uncompressed, plus a contiguous one.
//!
//! The question it answers for every read-path change: does the cost of a
//! selection scale with the *selection*, or with the whole dataset?
//!
//! ```text
//! cargo run --release -p clawhdf5-bench --bin read_harness
//! cargo run --release -p clawhdf5-bench --bin read_harness -- --large # 512 MB
//! ```
use std::time::{Duration, Instant};
use clawhdf5::{File, FileBuilder};
use clawhdf5_format::selection::Selection;
const CHUNK: u64 = 256;
struct Layout {
name: &'static str,
chunked: bool,
deflate: bool,
}
const LAYOUTS: [Layout; 3] = [
Layout {
name: "chunked + deflate",
chunked: true,
deflate: true,
},
Layout {
name: "chunked",
chunked: true,
deflate: false,
},
Layout {
name: "contiguous",
chunked: false,
deflate: false,
},
];
/// Smooth-ish, compressible data whose value encodes its position, so a read
/// can be verified exactly.
fn value(row: u64, col: u64) -> f64 {
(row * 100_003 + col) as f64 * 0.5
}
fn write_file(path: &std::path::Path, rows: u64, cols: u64) {
let data: Vec<f64> = (0..rows)
.flat_map(|r| (0..cols).map(move |c| value(r, c)))
.collect();
let mut builder = FileBuilder::new();
for (i, layout) in LAYOUTS.iter().enumerate() {
let ds = builder.create_dataset(&format!("d{i}"));
ds.with_f64_data(&data).with_shape(&[rows, cols]);
if layout.chunked {
ds.with_chunks(&[CHUNK, CHUNK]);
}
if layout.deflate {
ds.with_deflate(4);
}
}
builder.write(path).unwrap();
}
fn median(mut samples: Vec<Duration>) -> Duration {
samples.sort();
samples[samples.len() / 2]
}
fn time<T>(reps: usize, mut f: impl FnMut() -> T) -> Duration {
median(
(0..reps)
.map(|_| {
let t = Instant::now();
std::hint::black_box(f());
t.elapsed()
})
.collect(),
)
}
fn slab(start: [u64; 2], count: [u64; 2]) -> Selection {
Selection::Hyperslab {
start: start.to_vec(),
stride: vec![1, 1],
count: count.to_vec(),
block: vec![1, 1],
}
}
fn main() {
let large = std::env::args().any(|a| a == "--large");
let (rows, cols) = if large { (8192, 8192) } else { (4096, 2048) };
let total_mb = (rows * cols * 8) as f64 / (1 << 20) as f64;
if cfg!(debug_assertions) {
eprintln!("warning: debug build — numbers are meaningless. Use --release.");
}
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("read_harness.h5");
write_file(&path, rows, cols);
let file_mb = std::fs::metadata(&path).unwrap().len() as f64 / (1 << 20) as f64;
println!("## Read harness");
println!(
"\n{rows} x {cols} f64 ({total_mb:.0} MB per dataset), chunks {CHUNK} x {CHUNK}, file {file_mb:.0} MB\n"
);
// (label, selection, elements selected)
let selections: Vec<(&str, Selection, u64)> = vec![
(
"64 x 64 window (1 chunk)",
slab([300, 300], [64, 64]),
64 * 64,
),
(
"512 x 512 window (4-9 chunks)",
slab([1000, 700], [512, 512]),
512 * 512,
),
("one row", slab([rows / 2, 0], [1, cols]), cols),
("one column", slab([0, cols / 2], [rows, 1]), rows),
];
println!("| layout | read | selected | time ms | MB/s of selection | vs full read |");
println!("|---|---|---:|---:|---:|---:|");
for (i, layout) in LAYOUTS.iter().enumerate() {
// Fresh handle per layout so one dataset's cached chunks don't help
// (or evict) another's.
let file = File::open(&path).unwrap();
let ds = file.dataset(&format!("d{i}")).unwrap();
let full_cold = time(1, || ds.read_f64().unwrap());
let full = time(3, || ds.read_f64().unwrap());
println!(
"| {} | full (first) | {total_mb:.0} MB | {:.1} | {:.0} | |",
layout.name,
full_cold.as_secs_f64() * 1e3,
total_mb / full_cold.as_secs_f64()
);
println!(
"| {} | full (repeat) | {total_mb:.0} MB | {:.1} | {:.0} | 1.00x |",
layout.name,
full.as_secs_f64() * 1e3,
total_mb / full.as_secs_f64()
);
for (label, selection, elements) in &selections {
// A fresh handle again: measure the selection on its own, not
// served from chunks the full read just cached.
let file = File::open(&path).unwrap();
let ds = file.dataset(&format!("d{i}")).unwrap();
let got = ds.read_f64_selection(selection).unwrap();
assert_eq!(got.len() as u64, *elements, "{label}");
if let Selection::Hyperslab { start, .. } = selection {
assert_eq!(got[0], value(start[0], start[1]), "{label}: wrong data");
}
let took = time(5, || {
let file = File::open(&path).unwrap();
let ds = file.dataset(&format!("d{i}")).unwrap();
ds.read_f64_selection(selection).unwrap()
});
let mb = (*elements * 8) as f64 / (1 << 20) as f64;
println!(
"| {} | {label} | {:.2} MB | {:.2} | {:.0} | {:.3}x |",
layout.name,
mb,
took.as_secs_f64() * 1e3,
mb / took.as_secs_f64(),
took.as_secs_f64() / full_cold.as_secs_f64()
);
}
}
}
@@ -0,0 +1,679 @@
//! Search measurement harness: recall vs. speed for the HNSW index, and
//! end-to-end `hybrid_search` latency as the store grows.
//!
//! Every search-path change should be justified by a before/after run of this
//! binary. It reports, for deterministic synthetic data:
//!
//! * **ANN** — index build time, and for each `ef`: recall@10 against an exact
//! brute-force scan, queries/second, and p50/p99 latency.
//! * **End to end** — `HDF5Memory`: ingest time, checkpoint time, `open()`
//! time, the one-off cold index build (first query ever), the first query
//! after a reopen, and steady-state `hybrid_search` p50/p99 at each size.
//!
//! Data is *clustered* (points = cluster centre + noise, unit-normalised), not
//! uniform: uniform random high-dimensional vectors are nearly equidistant,
//! which makes recall numbers meaningless and is nothing like embeddings.
//!
//! ```text
//! cargo run --release -p clawhdf5-bench --bin search_harness # 1K, 10K
//! cargo run --release -p clawhdf5-bench --bin search_harness -- --full # + 100K
//! cargo run --release -p clawhdf5-bench --bin search_harness -- --json out.json
//! cargo run --release -p clawhdf5-bench --bin search_harness -- --ann-only --uniform
//! ```
use std::time::{Duration, Instant};
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
use clawhdf5_ann::{DistanceMetric, HnswIndex, Storage};
const DIM: usize = 384;
const K: usize = 10;
const N_QUERIES: usize = 200;
const HNSW_M: usize = 16;
const HNSW_EF_CONSTRUCTION: usize = 64;
const EF_VALUES: [usize; 5] = [16, 32, 64, 128, 256];
// ---------------------------------------------------------------------------
// Deterministic data
// ---------------------------------------------------------------------------
struct Rng(u64);
impl Rng {
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Uniform in [0, 1).
fn unit(&mut self) -> f32 {
(self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
}
/// Approximately standard normal (sum of uniforms).
fn gauss(&mut self) -> f32 {
let sum: f32 = (0..6).map(|_| self.unit()).sum();
(sum - 3.0) * std::f32::consts::SQRT_2
}
fn below(&mut self, n: usize) -> usize {
(self.next_u64() % n as u64) as usize
}
}
fn normalize(v: &mut [f32]) {
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 0.0 {
v.iter_mut().for_each(|x| *x /= norm);
}
}
struct Dataset {
vectors: Vec<Vec<f32>>,
queries: Vec<Vec<f32>>,
/// Cluster id of each vector (used to give records topical text).
cluster_of: Vec<usize>,
query_cluster: Vec<usize>,
}
/// `--uniform`: isotropic random unit vectors instead of clusters. Not a
/// realistic workload, but a useful second distribution — a recall problem
/// that appears only on clustered data points at graph connectivity.
static UNIFORM: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// `--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) {
let random_unit = |rng: &mut Rng| {
let mut v: Vec<f32> = (0..DIM).map(|_| rng.gauss()).collect();
normalize(&mut v);
v
};
return Dataset {
vectors: (0..n).map(|_| random_unit(&mut rng)).collect(),
queries: (0..N_QUERIES).map(|_| random_unit(&mut rng)).collect(),
cluster_of: vec![0; n],
query_cluster: vec![0; N_QUERIES],
};
}
let n_clusters = (n / 100).clamp(8, 512);
let centres: Vec<Vec<f32>> = (0..n_clusters)
.map(|_| {
let mut c: Vec<f32> = (0..DIM).map(|_| rng.gauss()).collect();
normalize(&mut c);
c
})
.collect();
let point = |rng: &mut Rng, cluster: usize| {
// Noise comparable to the centre's per-dimension magnitude, so
// clusters overlap and the nearest neighbours are non-trivial.
let scale = 0.6 / (DIM as f32).sqrt();
let mut v: Vec<f32> = centres[cluster]
.iter()
.map(|c| c + rng.gauss() * scale)
.collect();
normalize(&mut v);
v
};
let mut vectors = Vec::with_capacity(n);
let mut cluster_of = Vec::with_capacity(n);
for _ in 0..n {
let c = rng.below(n_clusters);
vectors.push(point(&mut rng, c));
cluster_of.push(c);
}
let mut queries = Vec::with_capacity(N_QUERIES);
let mut query_cluster = Vec::with_capacity(N_QUERIES);
for _ in 0..N_QUERIES {
let c = rng.below(n_clusters);
queries.push(point(&mut rng, c));
query_cluster.push(c);
}
Dataset {
vectors,
queries,
cluster_of,
query_cluster,
}
}
const WORDS: &[&str] = &[
"deploy", "latency", "cache", "schema", "index", "vector", "memory", "agent", "kernel",
"buffer", "socket", "thread", "tensor", "gradient", "ledger", "invoice", "meeting", "roadmap",
"customer", "contract", "sensor", "orbit", "protein", "genome", "harbor", "bridge", "engine",
"battery", "harvest", "weather", "museum", "recipe",
];
/// Text whose vocabulary is biased by cluster, so keyword and vector signals
/// agree the way they do for real embedded text.
fn text_for(cluster: usize, i: usize, rng: &mut Rng) -> String {
let topic = [
WORDS[cluster % WORDS.len()],
WORDS[(cluster / 7 + 3) % WORDS.len()],
];
let mut words = Vec::with_capacity(14);
for j in 0..14 {
if j % 3 == 0 {
words.push(topic[j / 3 % 2]);
} else {
words.push(WORDS[rng.below(WORDS.len())]);
}
}
format!("record {i}: {}", words.join(" "))
}
// ---------------------------------------------------------------------------
// Measurement helpers
// ---------------------------------------------------------------------------
/// 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
.iter()
.enumerate()
.map(|(i, v)| (i, v.iter().zip(query).map(|(a, b)| a * b).sum()))
.collect();
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
scored.truncate(k);
scored.into_iter().map(|(i, _)| i).collect()
}
struct Latency {
p50: Duration,
p99: Duration,
qps: f64,
}
fn summarize(mut samples: Vec<Duration>) -> Latency {
samples.sort();
let total: Duration = samples.iter().sum();
let at = |q: f64| samples[((samples.len() - 1) as f64 * q).round() as usize];
Latency {
p50: at(0.50),
p99: at(0.99),
qps: samples.len() as f64 / total.as_secs_f64(),
}
}
/// 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);
// 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() {
LIVE_BYTES.fetch_add(layout.size() as i64, std::sync::atomic::Ordering::Relaxed);
}
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() {
LIVE_BYTES.fetch_add(
new_size as i64 - layout.size() as i64,
std::sync::atomic::Ordering::Relaxed,
);
}
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
}
fn mib(bytes: u64) -> f64 {
bytes as f64 / (1 << 20) as f64
}
fn micros(d: Duration) -> f64 {
d.as_secs_f64() * 1e6
}
fn millis(d: Duration) -> f64 {
d.as_secs_f64() * 1e3
}
// ---------------------------------------------------------------------------
// ANN: recall vs speed
// ---------------------------------------------------------------------------
fn bench_ann(n: usize, json: &mut Vec<serde_json::Value>) {
let data = make_dataset(n, 0xA11CE ^ n as u64);
let truth: Vec<Vec<usize>> = data
.queries
.iter()
.map(|q| exact_top_k(&data.vectors, q, K))
.collect();
let started = Instant::now();
let index = HnswIndex::build_with(
&data.vectors,
HNSW_M,
HNSW_EF_CONSTRUCTION,
DistanceMetric::Cosine,
storage(),
);
let build = started.elapsed();
// Exact scan baseline, for scale.
let exact = summarize(
data.queries
.iter()
.map(|q| {
let t = Instant::now();
std::hint::black_box(exact_top_k(&data.vectors, q, K));
t.elapsed()
})
.collect(),
);
println!(
"\n### HNSW, N = {n}, dim = {DIM}, M = {HNSW_M}, ef_construction = {HNSW_EF_CONSTRUCTION}, storage = {:?}\n",
index.storage()
);
println!(
"build: {:.1} ms ({:.0} vectors/s) · exact scan: {:.0} QPS, p50 {:.0} µs\n",
millis(build),
n as f64 / build.as_secs_f64(),
exact.qps,
micros(exact.p50)
);
println!("| ef | recall@{K} | QPS | p50 µs | p99 µs |");
println!("|---:|---:|---:|---:|---:|");
// 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 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();
}
let recall = hits as f64 / (K * data.queries.len()) as f64;
let lat = summarize(samples);
println!(
"| {ef} | {recall:.4} | {:.0} | {:.0} | {:.0} |",
lat.qps,
micros(lat.p50),
micros(lat.p99)
);
json.push(serde_json::json!({
"bench": "hnsw", "n": n, "ef": ef, "recall_at_10": recall,
"qps": lat.qps, "p50_us": micros(lat.p50), "p99_us": micros(lat.p99),
"build_ms": millis(build),
}));
}
}
// ---------------------------------------------------------------------------
// End to end: HDF5Memory::hybrid_search
// ---------------------------------------------------------------------------
fn bench_end_to_end(n: usize, json: &mut Vec<serde_json::Value>) {
let data = make_dataset(n, 0xE2E ^ n as u64);
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("store.h5");
let mut rng = Rng(7);
let entries: Vec<MemoryEntry> = data
.vectors
.iter()
.enumerate()
.map(|(i, v)| MemoryEntry {
chunk: text_for(data.cluster_of[i], i, &mut rng),
embedding: v.clone(),
source_channel: "bench".into(),
timestamp: i as f64,
session_id: format!("s{}", i % 50),
tags: format!("t{i}"),
})
.collect();
let query_texts: Vec<String> = data
.query_cluster
.iter()
.enumerate()
.map(|(i, c)| text_for(*c, i, &mut rng))
.collect();
let mut mem = HDF5Memory::create(MemoryConfig::new(path.clone(), "bench", DIM)).unwrap();
let t = Instant::now();
mem.save_batch(entries).unwrap();
let ingest = t.elapsed();
// The very first query builds the vector and keyword indexes from
// scratch. It happens once per store, not once per session: the checkpoint
// below saves the vector index, so a later `open()` reloads it.
let t = Instant::now();
std::hint::black_box(mem.hybrid_search(&data.queries[1], &query_texts[1], 0.7, 0.3, K));
let cold_build = t.elapsed();
let t = Instant::now();
mem.flush_wal().unwrap();
let checkpoint = t.elapsed();
drop(mem);
let t = Instant::now();
let mut mem = HDF5Memory::open(&path).unwrap();
let open = t.elapsed();
// The first query after open pays for whatever is rebuilt lazily.
let t = Instant::now();
std::hint::black_box(mem.hybrid_search(&data.queries[0], &query_texts[0], 0.7, 0.3, K));
let first_query = t.elapsed();
// Fewer steady-state samples at large N: each query is currently O(N).
let samples_wanted = if n >= 100_000 { 20 } else { N_QUERIES.min(100) };
let steady = summarize(
(0..samples_wanted)
.map(|i| {
let t = Instant::now();
std::hint::black_box(mem.hybrid_search(
&data.queries[i % N_QUERIES],
&query_texts[i % N_QUERIES],
0.7,
0.3,
K,
));
t.elapsed()
})
.collect(),
);
println!(
"| {n} | {:.0} | {:.0} | {:.1} | {:.1} | {:.1} | {:.2} | {:.2} | {:.1} |",
millis(ingest),
millis(cold_build),
millis(checkpoint),
millis(open),
millis(first_query),
millis(steady.p50),
millis(steady.p99),
steady.qps
);
json.push(serde_json::json!({
"bench": "hybrid_search", "n": n,
"ingest_ms": millis(ingest), "cold_index_build_ms": millis(cold_build),
"checkpoint_ms": millis(checkpoint),
"open_ms": millis(open), "first_query_ms": millis(first_query),
"p50_ms": millis(steady.p50), "p99_ms": millis(steady.p99), "qps": steady.qps,
}));
}
// ---------------------------------------------------------------------------
// Fusion study: does capping the keyword candidate pool change the ranking?
// ---------------------------------------------------------------------------
/// `hybrid_search` min-max normalises each signal over the candidates it is
/// given. The vector stage supplies a pool of `max(8k, 64)`; the keyword stage
/// supplies *every* matching record, which is what now dominates query time.
/// This compares the current fusion with one whose keyword stage is capped to
/// a pool, reporting how often the final top-k agree and what each costs.
fn fusion_study(n: usize) {
use clawhdf5_agent::bm25::BM25Index;
use clawhdf5_agent::hybrid::merge_vector_keyword;
let data = make_dataset(n, 0xE2E ^ n as u64);
let mut rng = Rng(7);
let texts: Vec<String> = (0..n)
.map(|i| text_for(data.cluster_of[i], i, &mut rng))
.collect();
let query_texts: Vec<String> = data
.query_cluster
.iter()
.enumerate()
.map(|(i, c)| text_for(*c, i, &mut rng))
.collect();
let bm25 = BM25Index::build(&texts, &vec![0u8; n]);
let index = HnswIndex::build_with(
&data.vectors,
HNSW_M,
HNSW_EF_CONSTRUCTION,
DistanceMetric::Cosine,
storage(),
);
let vec_pool = (K * 8).max(64);
println!("\n### Fusion study, N = {n} (k = {K}, weights 0.7 / 0.3, vector pool {vec_pool})\n");
println!(
"| keyword pool | top-{K} overlap vs full | identical top-{K} | same #1 | keyword+merge µs |"
);
println!("|---:|---:|---:|---:|---:|");
let fuse = |q: usize, kw_pool: usize| -> (Vec<usize>, Duration) {
let vec_scores: Vec<(usize, f32)> = index
.search(&data.queries[q], vec_pool, vec_pool)
.into_iter()
.map(|(id, d)| (id, 1.0 - d))
.collect();
let t = Instant::now();
let kw = bm25.search(&query_texts[q], kw_pool);
let merged = merge_vector_keyword(vec_scores, kw, 0.7, 0.3, K);
let took = t.elapsed();
(merged.into_iter().map(|(id, _)| id).collect(), took)
};
let full: Vec<(Vec<usize>, Duration)> = (0..N_QUERIES).map(|q| fuse(q, n)).collect();
let full_time: Duration = full.iter().map(|f| f.1).sum();
println!(
"| all ({n}) | 1.0000 | 100.0% | 100.0% | {:.0} |",
micros(full_time) / N_QUERIES as f64
);
for pool in [vec_pool, vec_pool * 4, 1000] {
if pool >= n {
continue;
}
let (mut overlap, mut identical, mut same_first) = (0usize, 0usize, 0usize);
let mut time = Duration::ZERO;
for (q, (want, _)) in full.iter().enumerate() {
let (got, took) = fuse(q, pool);
time += took;
overlap += got.iter().filter(|id| want.contains(id)).count();
identical += usize::from(&got == want);
same_first += usize::from(got.first() == want.first());
}
println!(
"| {pool} | {:.4} | {:.1}% | {:.1}% | {:.0} |",
overlap as f64 / (K * N_QUERIES) as f64,
100.0 * identical as f64 / N_QUERIES as f64,
100.0 * same_first as f64 / N_QUERIES as f64,
micros(time) / N_QUERIES as f64
);
}
}
/// 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();
let reopened = HDF5Memory::open(&path).unwrap();
let after_open = heap_bytes();
let loaded = after_open.saturating_sub(before_open);
drop(reopened);
let raw = (n * DIM * 4) as u64;
println!(
"| {n} | {:.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),
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");
let ann_only = args.iter().any(|a| a == "--ann-only");
if args.iter().any(|a| a == "--fusion-study") {
for &n in if full {
&[10_000, 100_000][..]
} else {
&[10_000][..]
} {
fusion_study(n);
}
return;
}
if args.iter().any(|a| a == "--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)");
}
let json_path = args
.iter()
.position(|a| a == "--json")
.and_then(|i| args.get(i + 1))
.cloned();
let sizes: &[usize] = if full {
&[1_000, 10_000, 100_000]
} else {
&[1_000, 10_000]
};
if cfg!(debug_assertions) {
eprintln!("warning: debug build — numbers are meaningless. Use --release.");
}
let mut json = Vec::new();
println!("## Search harness");
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 | 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") {
for &n in sizes {
bench_ann(n, &mut json);
}
}
if ann_only {
return;
}
println!("\n### End to end: `HDF5Memory::hybrid_search` (k = {K}, weights 0.7 / 0.3)\n");
println!(
"| N | ingest ms | cold index build ms | checkpoint ms | open ms | first query after open ms | p50 ms | p99 ms | QPS |"
);
println!("|---:|---:|---:|---:|---:|---:|---:|---:|---:|");
for &n in sizes {
bench_end_to_end(n, &mut json);
}
if let Some(path) = json_path {
std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
eprintln!("wrote {path}");
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
[package]
name = "clawhdf5-cli"
version = "2.1.0"
version = "2.6.0"
edition = "2024"
license = "MIT"
description = "CLI for clawhdf5 agent memory — create, save, search, recall, stats"
repository = "https://github.com/redclawsystems/clawhdf5"
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
keywords = ["hdf5", "ai", "memory", "agent", "cli"]
categories = ["command-line-utilities", "science"]
readme = "../../README.md"
@@ -14,7 +14,7 @@ name = "clawhdf5"
path = "src/main.rs"
[dependencies]
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.1.0" }
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.6.0" }
clap = { version = "4", features = ["derive", "env"] }
serde_json = "1"
serde = { workspace = true }
+16 -5
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)?);
@@ -146,7 +157,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
}
Commands::Recall { index } => {
let mem = HDF5Memory::open(&cli.path)?;
let mem = HDF5Memory::open_read_only(&cli.path)?;
match mem.get_chunk(index) {
Some(content) => {
let j = serde_json::json!({ "index": index, "chunk": content });
@@ -160,7 +171,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
}
Commands::Stats => {
let mem = HDF5Memory::open(&cli.path)?;
let mem = HDF5Memory::open_read_only(&cli.path)?;
let cfg = mem.config();
let j = serde_json::json!({
"path": cli.path.display().to_string(),
@@ -187,7 +198,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
}
Commands::AgentsMd { output } => {
let mem = HDF5Memory::open(&cli.path)?;
let mem = HDF5Memory::open_read_only(&cli.path)?;
let md = mem.generate_agents_md();
match output {
Some(p) => {
@@ -199,7 +210,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
}
Commands::Export => {
let mem = HDF5Memory::open(&cli.path)?;
let mem = HDF5Memory::open_read_only(&cli.path)?;
for i in 0..mem.count() {
if let Some(chunk) = mem.get_chunk(i) {
let j = serde_json::json!({ "index": i, "chunk": chunk });
+2 -2
View File
@@ -1,10 +1,10 @@
[package]
name = "clawhdf5-derive"
version = "2.1.0"
version = "2.6.0"
edition = "2024"
description = "Derive macros for rustyhdf5 HDF5 traits"
license = "MIT"
repository = "https://github.com/redclawsystems/clawhdf5"
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
readme = "README.md"
keywords = ["hdf5", "derive", "macros", "science"]
categories = ["development-tools::procedural-macro-helpers"]
+2 -2
View File
@@ -1,10 +1,10 @@
[package]
name = "clawhdf5-filters"
version = "2.1.0"
version = "2.6.0"
edition = "2024"
description = "Filter and compression pipeline for clawhdf5"
license = "MIT"
repository = "https://github.com/redclawsystems/clawhdf5"
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
readme = "README.md"
keywords = ["hdf5", "compression", "deflate", "filters"]
categories = ["compression", "science"]
+3 -3
View File
@@ -1,10 +1,10 @@
[package]
name = "clawhdf5-format"
version = "2.1.0"
version = "2.6.0"
edition = "2024"
description = "Pure-Rust HDF5 binary format parsing and writing — no C dependencies"
license = "MIT"
repository = "https://github.com/redclawsystems/clawhdf5"
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
readme = "README.md"
keywords = ["hdf5", "science", "data", "binary", "no-std"]
categories = ["parser-implementations", "science", "encoding", "no-std"]
@@ -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.1.0" }
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.6.0" }
[[bench]]
name = "bench"
+115 -16
View File
@@ -1,7 +1,9 @@
//! HDF5 Attribute message parsing (message type 0x000C).
#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};
use alloc::{borrow::Cow, string::String, vec::Vec};
#[cfg(feature = "std")]
use std::borrow::Cow;
use crate::attribute_info::AttributeInfoMessage;
use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records};
@@ -48,17 +50,64 @@ impl AttributeMessage {
///
/// `length_size` is needed for dataspace dimension parsing.
pub fn parse(data: &[u8], length_size: u8) -> Result<AttributeMessage, FormatError> {
Self::parse_impl(data, length_size, None)
}
/// [`AttributeMessage::parse`] with access to the rest of the file, which
/// is needed when the attribute's datatype or dataspace is *shared* (v2/v3
/// flag bits 0/1) — e.g. an attribute created with a committed datatype.
/// In that case the embedded bytes are a reference to the real message,
/// not the message. Without file access such an attribute is an error
/// rather than a garbage datatype.
pub fn parse_in_file(
data: &[u8],
file_data: &[u8],
offset_size: u8,
length_size: u8,
) -> Result<AttributeMessage, FormatError> {
Self::parse_impl(data, length_size, Some((file_data, offset_size)))
}
fn parse_impl(
data: &[u8],
length_size: u8,
file: Option<(&[u8], u8)>,
) -> Result<AttributeMessage, FormatError> {
ensure_len(data, 0, 2)?;
let version = data[0];
match version {
1 => Self::parse_v1(data, length_size),
2 => Self::parse_v2(data, length_size),
3 => Self::parse_v3(data, length_size),
2 => Self::parse_v2(data, length_size, file),
3 => Self::parse_v3(data, length_size, file),
_ => Err(FormatError::InvalidAttributeVersion(version)),
}
}
/// The bytes of an embedded datatype/dataspace message, following the
/// shared-message reference when `shared` is set.
fn embedded_message<'a>(
bytes: &'a [u8],
shared: bool,
msg_type: MessageType,
length_size: u8,
file: Option<(&[u8], u8)>,
) -> Result<Cow<'a, [u8]>, FormatError> {
if !shared {
return Ok(Cow::Borrowed(bytes));
}
let (file_data, offset_size) = file.ok_or(FormatError::UnresolvedSharedMessage)?;
let shared_ref = shared_message::parse_shared_ref(bytes, offset_size)?;
shared_message::resolve_shared_message(
file_data,
&shared_ref,
msg_type,
offset_size,
length_size,
)
.map(Cow::Owned)
}
fn parse_v1(data: &[u8], length_size: u8) -> Result<AttributeMessage, FormatError> {
// version(1) + reserved(1) + name_size(2) + datatype_size(2) + dataspace_size(2) = 8
ensure_len(data, 0, 8)?;
@@ -94,7 +143,13 @@ impl AttributeMessage {
})
}
fn parse_v2(data: &[u8], length_size: u8) -> Result<AttributeMessage, FormatError> {
fn parse_v2(
data: &[u8],
length_size: u8,
file: Option<(&[u8], u8)>,
) -> Result<AttributeMessage, FormatError> {
// Flags: bit 0 = datatype is shared, bit 1 = dataspace is shared.
let flags = data.get(1).copied().unwrap_or(0);
// version(1) + flags(1) + name_size(2) + datatype_size(2) + dataspace_size(2) = 8
ensure_len(data, 0, 8)?;
let name_size = u16::from_le_bytes([data[2], data[3]]) as usize;
@@ -110,12 +165,26 @@ impl AttributeMessage {
// Datatype (NO padding)
ensure_len(data, pos, datatype_size)?;
let (datatype, _) = Datatype::parse(&data[pos..pos + datatype_size])?;
let dt_bytes = Self::embedded_message(
&data[pos..pos + datatype_size],
flags & 0x01 != 0,
MessageType::Datatype,
length_size,
file,
)?;
let (datatype, _) = Datatype::parse(&dt_bytes)?;
pos += datatype_size;
// Dataspace (NO padding)
ensure_len(data, pos, dataspace_size)?;
let dataspace = Dataspace::parse(&data[pos..pos + dataspace_size], length_size)?;
let ds_bytes = Self::embedded_message(
&data[pos..pos + dataspace_size],
flags & 0x02 != 0,
MessageType::Dataspace,
length_size,
file,
)?;
let dataspace = Dataspace::parse(&ds_bytes, length_size)?;
pos += dataspace_size;
let raw_data = compute_raw_data(data, pos, &dataspace, &datatype);
@@ -128,7 +197,13 @@ impl AttributeMessage {
})
}
fn parse_v3(data: &[u8], length_size: u8) -> Result<AttributeMessage, FormatError> {
fn parse_v3(
data: &[u8],
length_size: u8,
file: Option<(&[u8], u8)>,
) -> Result<AttributeMessage, FormatError> {
// Flags: bit 0 = datatype is shared, bit 1 = dataspace is shared.
let flags = data.get(1).copied().unwrap_or(0);
// version(1) + flags(1) + name_size(2) + datatype_size(2) + dataspace_size(2) + encoding(1) = 9
ensure_len(data, 0, 9)?;
let name_size = u16::from_le_bytes([data[2], data[3]]) as usize;
@@ -145,12 +220,26 @@ impl AttributeMessage {
// Datatype (NO padding)
ensure_len(data, pos, datatype_size)?;
let (datatype, _) = Datatype::parse(&data[pos..pos + datatype_size])?;
let dt_bytes = Self::embedded_message(
&data[pos..pos + datatype_size],
flags & 0x01 != 0,
MessageType::Datatype,
length_size,
file,
)?;
let (datatype, _) = Datatype::parse(&dt_bytes)?;
pos += datatype_size;
// Dataspace (NO padding)
ensure_len(data, pos, dataspace_size)?;
let dataspace = Dataspace::parse(&data[pos..pos + dataspace_size], length_size)?;
let ds_bytes = Self::embedded_message(
&data[pos..pos + dataspace_size],
flags & 0x02 != 0,
MessageType::Dataspace,
length_size,
file,
)?;
let dataspace = Dataspace::parse(&ds_bytes, length_size)?;
pos += dataspace_size;
let raw_data = compute_raw_data(data, pos, &dataspace, &datatype);
@@ -326,10 +415,20 @@ pub fn extract_attributes_full(
offset_size,
length_size,
)?;
let attr = AttributeMessage::parse(&resolved_data, length_size)?;
let attr = AttributeMessage::parse_in_file(
&resolved_data,
file_data,
offset_size,
length_size,
)?;
attrs.push(attr);
} else {
let attr = AttributeMessage::parse(&msg.data, length_size)?;
let attr = AttributeMessage::parse_in_file(
&msg.data,
file_data,
offset_size,
length_size,
)?;
attrs.push(attr);
}
}
@@ -399,7 +498,8 @@ fn extract_dense_attributes(
let attr_data = fh.read_managed_object(file_data, id_bytes, offset_size)?;
// The data in the heap is a complete attribute message
let attr = AttributeMessage::parse(&attr_data, length_size)?;
let attr =
AttributeMessage::parse_in_file(&attr_data, file_data, offset_size, length_size)?;
attrs.push(attr);
}
@@ -472,14 +572,13 @@ mod tests {
// Name padded to 8 bytes
data.extend_from_slice(name);
while data.len() % 8 != 0 || data.len() == 8 {
if data.len() % 8 != 0 || data.len() == 8 {
// Pad name to 8-byte boundary from start of name
let name_start = 8;
let name_padded = pad8(name_size);
while data.len() < name_start + name_padded {
data.push(0);
}
break;
}
// Datatype padded to 8 bytes
@@ -749,11 +848,11 @@ mod tests {
data.extend_from_slice(name);
data.extend_from_slice(&dt_bytes);
data.extend_from_slice(&ds_bytes);
data.extend_from_slice(&3.14f64.to_le_bytes());
data.extend_from_slice(&3.25f64.to_le_bytes());
let attr = AttributeMessage::parse(&data, 8).unwrap();
let vals = attr.read_as_f64().unwrap();
assert_eq!(vals, vec![3.14]);
assert_eq!(vals, vec![3.25]);
}
#[test]
+1
View File
@@ -416,6 +416,7 @@ fn header_max_total_records(max_leaf_nrec: u64, depth: u16) -> u64 {
mod tests {
use super::*;
#[allow(clippy::too_many_arguments)]
fn build_btree_v2_header(
tree_type: u8,
node_size: u32,
@@ -374,6 +374,11 @@ impl ChunkCache {
// ----- Index operations -----
/// The most decompressed bytes this cache will hold.
pub fn max_bytes(&self) -> usize {
self.inner.lock().map(|g| g.max_bytes).unwrap_or(0)
}
/// Bind the cache to the dataset at chunk-index address `addr`.
///
/// The cache is shared per file across all of its datasets. If the cache
+409 -318
View File
@@ -132,6 +132,64 @@ fn ensure_len(data: &[u8], offset: usize, needed: usize) -> Result<(), FormatErr
Ok(())
}
/// `elements * elem_size` for sizes that come from the file. Dataspace and
/// chunk dimensions are untrusted 64-bit fields, so a crafted file can make
/// the plain product wrap to a small number (or to something enormous).
pub(crate) fn checked_byte_len(elements: u64, elem_size: usize) -> Result<usize, FormatError> {
usize::try_from(elements)
.ok()
.and_then(|n| n.checked_mul(elem_size))
.ok_or_else(|| {
FormatError::Overflow(format!(
"{elements} elements of {elem_size} bytes exceeds the addressable size"
))
})
}
/// Product of chunk dimensions times the element size, overflow-checked.
pub(crate) fn checked_chunk_byte_len(
chunk_dims: &[usize],
elem_size: usize,
) -> Result<usize, FormatError> {
chunk_dims
.iter()
.try_fold(elem_size, |acc, &d| acc.checked_mul(d))
.ok_or_else(|| {
FormatError::Overflow(format!(
"chunk dimensions {chunk_dims:?} x {elem_size} bytes exceeds the addressable size"
))
})
}
/// A zero-filled output buffer of `len` bytes. `vec![0; len]` aborts the
/// process when the allocation fails; a size taken from the file must surface
/// as an error instead.
pub(crate) fn alloc_output(len: usize) -> Result<Vec<u8>, FormatError> {
if len == 0 {
return Ok(Vec::new());
}
let failed =
|| FormatError::Overflow(format!("cannot allocate {len} bytes for dataset output"));
let layout = core::alloc::Layout::array::<u8>(len).map_err(|_| failed())?;
// Ask the allocator for zeroed memory instead of reserving and then
// writing zeros: for a large buffer the OS hands out already-zero pages
// lazily, where an explicit fill touches every page up front — and most of
// the buffer is about to be overwritten with chunk data anyway.
//
// SAFETY (both arms): `layout` has non-zero size (len > 0) and alignment 1.
#[cfg(feature = "std")]
let ptr = unsafe { std::alloc::alloc_zeroed(layout) };
#[cfg(not(feature = "std"))]
let ptr = unsafe { alloc::alloc::alloc_zeroed(layout) };
if ptr.is_null() {
return Err(failed());
}
// SAFETY: `ptr` came from the global allocator with the layout of
// `[u8; len]`, which is exactly what `Vec<u8>` with capacity `len` frees;
// all `len` bytes are initialised (zero).
Ok(unsafe { Vec::from_raw_parts(ptr, len, len) })
}
fn read_offset(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
let s = size as usize;
if pos.checked_add(s).is_none_or(|end| end > data.len()) {
@@ -321,15 +379,127 @@ pub fn generate_implicit_chunks(
}
/// Read a chunked dataset, decompressing chunks as needed.
pub fn read_chunked_data(
/// Chunks decompressed together before being copied out, bounding the extra
/// memory a parallel full read holds at once.
const DECODE_BATCH: usize = 128;
/// B-tree v2 record types used for chunk indexing.
const BT2_CHUNK_UNFILTERED: u8 = 10;
const BT2_CHUNK_FILTERED: u8 = 11;
/// Chunks indexed by a version-2 B-tree (layout v4, index type 5).
///
/// Record layouts (all little endian):
/// * type 10, unfiltered: address, then one 8-byte *scaled* offset per
/// dimension (offset / chunk dimension);
/// * type 11, filtered: address, stored chunk size (a variable number of
/// bytes), 4-byte filter mask, then the scaled offsets.
///
/// The width of the stored-size field depends on the largest possible chunk;
/// rather than re-derive the library's formula it is taken from the record
/// size the tree header declares, which is what actually governs the bytes.
fn read_btree_v2_chunks(
file_data: &[u8],
addr: u64,
chunk_dims: &[usize],
elem_size: usize,
offset_size: u8,
length_size: u8,
) -> Result<Vec<ChunkInfo>, FormatError> {
use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records};
let bad = |what: &str| FormatError::ChunkedReadError(format!("B-tree v2 chunk index: {what}"));
let header = BTreeV2Header::parse(file_data, addr as usize, offset_size, length_size)?;
let rank = chunk_dims.len();
let os = offset_size as usize;
let record_size = header.record_size as usize;
let size_len = match header.tree_type {
BT2_CHUNK_UNFILTERED => {
if record_size != os + 8 * rank {
return Err(bad("unexpected record size for unfiltered chunks"));
}
0
}
BT2_CHUNK_FILTERED => {
let fixed = os + 4 + 8 * rank;
let size_len = record_size
.checked_sub(fixed)
.ok_or_else(|| bad("record too small"))?;
if !(1..=8).contains(&size_len) {
return Err(bad("implausible chunk-size field width"));
}
size_len
}
_ => return Err(bad("tree is not a chunk index")),
};
let unfiltered_bytes = checked_chunk_byte_len(chunk_dims, elem_size)?;
let unfiltered_bytes =
u32::try_from(unfiltered_bytes).map_err(|_| bad("chunk larger than 4 GiB"))?;
let records = collect_btree_v2_records(file_data, &header, offset_size, length_size)?;
let mut chunks = Vec::with_capacity(records.len());
for record in &records {
let data = record.data.as_slice();
if data.len() < record_size {
return Err(bad("truncated record"));
}
let address = read_offset(data, 0, offset_size)?;
let mut pos = os;
let (chunk_size, filter_mask) = if size_len == 0 {
(unfiltered_bytes, 0)
} else {
let mut size = 0u64;
for (i, &b) in data[pos..pos + size_len].iter().enumerate() {
size |= u64::from(b) << (8 * i);
}
pos += size_len;
let mask = u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
pos += 4;
(
u32::try_from(size).map_err(|_| bad("stored chunk larger than 4 GiB"))?,
mask,
)
};
let mut offsets = Vec::with_capacity(rank);
for &dim in chunk_dims {
let scaled = u64::from_le_bytes([
data[pos],
data[pos + 1],
data[pos + 2],
data[pos + 3],
data[pos + 4],
data[pos + 5],
data[pos + 6],
data[pos + 7],
]);
pos += 8;
offsets.push(
scaled
.checked_mul(dim as u64)
.ok_or_else(|| bad("chunk offset overflows"))?,
);
}
chunks.push(ChunkInfo {
chunk_size,
filter_mask,
offsets,
address,
});
}
Ok(chunks)
}
/// Every allocated chunk of a chunked dataset, for any supported chunk index,
/// plus the spatial chunk dimensions. Chunks the file never allocated (sparse
/// datasets) are simply absent from the list.
pub fn list_chunks(
file_data: &[u8],
layout: &DataLayout,
dataspace: &Dataspace,
datatype: &Datatype,
pipeline: Option<&FilterPipeline>,
elem_size: usize,
offset_size: u8,
length_size: u8,
) -> Result<Vec<u8>, FormatError> {
) -> Result<(Vec<ChunkInfo>, Vec<usize>), FormatError> {
let (
chunk_dimensions,
version,
@@ -363,8 +533,6 @@ pub fn read_chunked_data(
let addr = addr_opt
.ok_or_else(|| FormatError::ChunkedReadError("no address for chunked layout".into()))?;
let elem_size = datatype.type_size() as usize;
// Both v3 and v4 include element size as last dim (rank+1)
let ndims = chunk_dimensions.len();
let rank = ndims
@@ -393,7 +561,7 @@ pub fn read_chunked_data(
}
(4, Some(1)) => {
// Single chunk — one chunk covering the entire dataset
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
let chunk_byte_size = checked_chunk_byte_len(&chunk_dims, elem_size)?;
let (csize, fmask) = if let Some(fs) = single_filtered_size {
(fs as u32, single_filter_mask.unwrap_or(0))
} else {
@@ -446,6 +614,18 @@ pub fn read_chunked_data(
length_size,
)?
}
(4, Some(5)) => {
// Version-2 B-tree: what the library uses for a dataset with two
// or more unlimited dimensions.
read_btree_v2_chunks(
file_data,
addr,
&chunk_dims,
elem_size,
offset_size,
length_size,
)?
}
(v, idx) => {
return Err(FormatError::ChunkedReadError(format!(
"unsupported chunked layout version={v}, index_type={idx:?}"
@@ -453,10 +633,38 @@ pub fn read_chunked_data(
}
};
Ok((chunks, chunk_dims))
}
pub fn read_chunked_data(
file_data: &[u8],
layout: &DataLayout,
dataspace: &Dataspace,
datatype: &Datatype,
pipeline: Option<&FilterPipeline>,
offset_size: u8,
length_size: u8,
) -> Result<Vec<u8>, FormatError> {
let elem_size = datatype.type_size() as usize;
let (chunks, chunk_dims) = list_chunks(
file_data,
layout,
dataspace,
elem_size,
offset_size,
length_size,
)?;
let rank = chunk_dims.len();
let ds_dims: Vec<usize> = dataspace.dimensions.iter().map(|&d| d as usize).collect();
// Assemble output
let total_elements = dataspace.num_elements() as usize;
let total_bytes = total_elements * elem_size;
let mut output = vec![0u8; total_bytes];
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
if total_bytes == 0 {
// Also keeps the stride products below in range: with a zero-sized
// dimension the total is 0 even if other dimensions are huge.
return Ok(Vec::new());
}
let mut output = alloc_output(total_bytes)?;
let mut ds_strides = vec![1usize; rank];
for i in (0..rank.saturating_sub(1)).rev() {
@@ -468,8 +676,7 @@ pub fn read_chunked_data(
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
}
let chunk_total_elements: usize = chunk_dims.iter().product();
let chunk_total_bytes = chunk_total_elements * elem_size;
let chunk_total_bytes = checked_chunk_byte_len(&chunk_dims, elem_size)?;
// Fast path: no filters — copy directly from file_data without intermediate alloc
if pipeline.is_none() {
@@ -561,29 +768,12 @@ pub fn read_chunked_data_cached(
length_size: u8,
cache: &ChunkCache,
) -> Result<Vec<u8>, FormatError> {
let (
chunk_dimensions,
version,
chunk_index_type,
addr_opt,
single_filtered_size,
single_filter_mask,
) = match layout {
let (chunk_dimensions, addr_opt) = match layout {
DataLayout::Chunked {
chunk_dimensions,
btree_address,
version,
chunk_index_type,
single_chunk_filtered_size,
single_chunk_filter_mask,
} => (
chunk_dimensions,
*version,
*chunk_index_type,
*btree_address,
*single_chunk_filtered_size,
*single_chunk_filter_mask,
),
..
} => (chunk_dimensions, *btree_address),
_ => {
return Err(FormatError::ChunkedReadError(
"expected chunked layout".into(),
@@ -620,78 +810,27 @@ pub fn read_chunked_data_cached(
// Populate chunk index on first access
if !cache.has_index() {
let chunks = match (version, chunk_index_type) {
(3, _) => collect_chunk_info(file_data, addr, ndims, offset_size, length_size)?,
(4, Some(1)) => {
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
let (csize, fmask) = if let Some(fs) = single_filtered_size {
(fs as u32, single_filter_mask.unwrap_or(0))
} else {
(chunk_byte_size as u32, 0)
};
vec![ChunkInfo {
chunk_size: csize,
filter_mask: fmask,
offsets: vec![0u64; rank],
address: addr,
}]
}
(4, Some(2)) => {
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
generate_implicit_chunks(
addr,
&dataspace.dimensions,
spatial_chunk_dims,
elem_size as u32,
)
}
(4, Some(3)) => {
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
let header =
FixedArrayHeader::parse(file_data, addr as usize, offset_size, length_size)?;
read_fixed_array_chunks(
file_data,
&header,
&dataspace.dimensions,
spatial_chunk_dims,
elem_size as u32,
offset_size,
length_size,
)?
}
(4, Some(4)) => {
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
let header = ExtensibleArrayHeader::parse(
file_data,
addr as usize,
offset_size,
length_size,
)?;
read_extensible_array_chunks(
file_data,
&header,
&dataspace.dimensions,
spatial_chunk_dims,
elem_size as u32,
offset_size,
length_size,
)?
}
(v, idx) => {
return Err(FormatError::ChunkedReadError(format!(
"unsupported chunked layout version={v}, index_type={idx:?}"
)));
}
};
let (chunks, _) = list_chunks(
file_data,
layout,
dataspace,
elem_size,
offset_size,
length_size,
)?;
cache.populate_index(&chunks, rank);
}
let chunks = cache.all_indexed_chunks().unwrap_or_default();
// Assemble output
let total_elements = dataspace.num_elements() as usize;
let total_bytes = total_elements * elem_size;
let mut output = vec![0u8; total_bytes];
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
if total_bytes == 0 {
// Also keeps the stride products below in range: with a zero-sized
// dimension the total is 0 even if other dimensions are huge.
return Ok(Vec::new());
}
let mut output = alloc_output(total_bytes)?;
let mut ds_strides = vec![1usize; rank];
for i in (0..rank.saturating_sub(1)).rev() {
@@ -703,55 +842,88 @@ pub fn read_chunked_data_cached(
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
}
let chunk_total_elements: usize = chunk_dims.iter().product();
let chunk_total_bytes = chunk_total_elements * elem_size;
for chunk_info in &chunks {
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
// Try decompressed cache first
let decompressed = if let Some(cached) = cache.get_decompressed_aligned(&coord) {
cached
} else {
// Decompress from file
let c_addr = chunk_info.address as usize;
let size = chunk_info.chunk_size as usize;
ensure_len(file_data, c_addr, size)?;
let raw_chunk = &file_data[c_addr..c_addr + size];
let dec = if let Some(pl) = pipeline {
if chunk_info.filter_mask == 0 {
decompress_chunk(raw_chunk, pl, chunk_total_bytes, elem_size as u32)?
} else {
raw_chunk.to_vec()
}
} else {
raw_chunk.to_vec()
};
cache.put_decompressed(coord, dec)
};
let chunk_total_bytes = checked_chunk_byte_len(&chunk_dims, elem_size)?;
let mut place = |data: &[u8], chunk_info: &ChunkInfo| {
if rank == 0 {
let copy_len = data.len().min(output.len());
output[..copy_len].copy_from_slice(&data[..copy_len]);
return;
}
let chunk_offsets: Vec<usize> = chunk_info
.offsets
.iter()
.take(rank)
.map(|&o| o as usize)
.collect();
copy_chunk_to_output(
data,
&mut output,
&chunk_offsets,
&chunk_dims,
&ds_dims,
&ds_strides,
&chunk_strides,
elem_size,
rank,
);
};
let raw_bytes = |chunk_info: &ChunkInfo| -> Result<&[u8], FormatError> {
let c_addr = chunk_info.address as usize;
let size = chunk_info.chunk_size as usize;
ensure_len(file_data, c_addr, size)?;
Ok(&file_data[c_addr..c_addr + size])
};
if rank == 0 {
let copy_len = decompressed.len().min(output.len());
output[..copy_len].copy_from_slice(&decompressed[..copy_len]);
} else {
copy_chunk_to_output(
&decompressed,
&mut output,
&chunk_offsets,
&chunk_dims,
&ds_dims,
&ds_strides,
&chunk_strides,
elem_size,
rank,
);
// Chunks stored as-is (no pipeline, or the filter mask says this chunk
// skipped it) are copied straight from the file bytes: they are already in
// memory, so routing them through a Vec and then an aligned cache buffer
// was two extra copies of the whole dataset for nothing.
let stored_raw = |c: &ChunkInfo| pipeline.is_none() || c.filter_mask != 0;
let mut misses: Vec<&ChunkInfo> = Vec::new();
for chunk_info in &chunks {
if stored_raw(chunk_info) {
place(raw_bytes(chunk_info)?, chunk_info);
continue;
}
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
match cache.get_decompressed_aligned(&coord) {
Some(cached) => place(&cached, chunk_info),
None => misses.push(chunk_info),
}
}
// Decompress what the cache didn't have, a bounded batch at a time — in
// parallel with the `parallel` feature (this path, the one the facade
// uses, was sequential; only the uncached reader was parallel). Chunks are
// cached only when the whole dataset fits: pushing a larger dataset
// through the cache just evicts each chunk moments after inserting it.
let cache_them = total_bytes <= cache.max_bytes();
if let Some(pl) = pipeline {
let decode = |c: &&ChunkInfo| -> Result<Vec<u8>, FormatError> {
decompress_chunk(raw_bytes(c)?, pl, chunk_total_bytes, elem_size as u32)
};
for batch in misses.chunks(DECODE_BATCH) {
#[cfg(feature = "parallel")]
let decoded: Vec<Result<Vec<u8>, FormatError>> = if batch.len() >= 4 {
use rayon::prelude::*;
batch.par_iter().map(decode).collect()
} else {
batch.iter().map(decode).collect()
};
#[cfg(not(feature = "parallel"))]
let decoded: Vec<Result<Vec<u8>, FormatError>> = batch.iter().map(decode).collect();
for (chunk_info, data) in batch.iter().zip(decoded) {
let data = data?;
if cache_them {
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
let cached = cache.put_decompressed(coord, data);
place(&cached, chunk_info);
} else {
place(&data, chunk_info);
}
}
}
}
@@ -914,29 +1086,12 @@ pub fn read_chunked_data_sweep(
cache: &ChunkCache,
sweep: &mut SweepContext,
) -> Result<Vec<u8>, FormatError> {
let (
chunk_dimensions,
version,
chunk_index_type,
addr_opt,
single_filtered_size,
single_filter_mask,
) = match layout {
let (chunk_dimensions, addr_opt) = match layout {
DataLayout::Chunked {
chunk_dimensions,
btree_address,
version,
chunk_index_type,
single_chunk_filtered_size,
single_chunk_filter_mask,
} => (
chunk_dimensions,
*version,
*chunk_index_type,
*btree_address,
*single_chunk_filtered_size,
*single_chunk_filter_mask,
),
..
} => (chunk_dimensions, *btree_address),
_ => {
return Err(FormatError::ChunkedReadError(
"expected chunked layout".into(),
@@ -973,78 +1128,27 @@ pub fn read_chunked_data_sweep(
// Populate chunk index on first access
if !cache.has_index() {
let chunks = match (version, chunk_index_type) {
(3, _) => collect_chunk_info(file_data, addr, ndims, offset_size, length_size)?,
(4, Some(1)) => {
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
let (csize, fmask) = if let Some(fs) = single_filtered_size {
(fs as u32, single_filter_mask.unwrap_or(0))
} else {
(chunk_byte_size as u32, 0)
};
vec![ChunkInfo {
chunk_size: csize,
filter_mask: fmask,
offsets: vec![0u64; rank],
address: addr,
}]
}
(4, Some(2)) => {
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
generate_implicit_chunks(
addr,
&dataspace.dimensions,
spatial_chunk_dims,
elem_size as u32,
)
}
(4, Some(3)) => {
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
let header =
FixedArrayHeader::parse(file_data, addr as usize, offset_size, length_size)?;
read_fixed_array_chunks(
file_data,
&header,
&dataspace.dimensions,
spatial_chunk_dims,
elem_size as u32,
offset_size,
length_size,
)?
}
(4, Some(4)) => {
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
let header = ExtensibleArrayHeader::parse(
file_data,
addr as usize,
offset_size,
length_size,
)?;
read_extensible_array_chunks(
file_data,
&header,
&dataspace.dimensions,
spatial_chunk_dims,
elem_size as u32,
offset_size,
length_size,
)?
}
(v, idx) => {
return Err(FormatError::ChunkedReadError(format!(
"unsupported chunked layout version={v}, index_type={idx:?}"
)));
}
};
let (chunks, _) = list_chunks(
file_data,
layout,
dataspace,
elem_size,
offset_size,
length_size,
)?;
cache.populate_index(&chunks, rank);
}
let chunks = cache.all_indexed_chunks().unwrap_or_default();
// Assemble output
let total_elements = dataspace.num_elements() as usize;
let total_bytes = total_elements * elem_size;
let mut output = vec![0u8; total_bytes];
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
if total_bytes == 0 {
// Also keeps the stride products below in range: with a zero-sized
// dimension the total is 0 even if other dimensions are huge.
return Ok(Vec::new());
}
let mut output = alloc_output(total_bytes)?;
let mut ds_strides = vec![1usize; rank];
for i in (0..rank.saturating_sub(1)).rev() {
@@ -1056,8 +1160,7 @@ pub fn read_chunked_data_sweep(
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
}
let chunk_total_elements: usize = chunk_dims.iter().product();
let chunk_total_bytes = chunk_total_elements * elem_size;
let chunk_total_bytes = checked_chunk_byte_len(&chunk_dims, elem_size)?;
for chunk_info in &chunks {
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
@@ -1137,29 +1240,12 @@ pub fn read_chunked_data_indexed(
length_size: u8,
cache: &ChunkCache,
) -> Result<Vec<u8>, FormatError> {
let (
chunk_dimensions,
version,
chunk_index_type,
addr_opt,
single_filtered_size,
single_filter_mask,
) = match layout {
let (chunk_dimensions, addr_opt) = match layout {
DataLayout::Chunked {
chunk_dimensions,
btree_address,
version,
chunk_index_type,
single_chunk_filtered_size,
single_chunk_filter_mask,
} => (
chunk_dimensions,
*version,
*chunk_index_type,
*btree_address,
*single_chunk_filtered_size,
*single_chunk_filter_mask,
),
..
} => (chunk_dimensions, *btree_address),
_ => {
return Err(FormatError::ChunkedReadError(
"expected chunked layout".into(),
@@ -1196,69 +1282,14 @@ pub fn read_chunked_data_indexed(
// Build chunk index on first access
if !cache.has_chunk_index() {
let chunks = match (version, chunk_index_type) {
(3, _) => collect_chunk_info(file_data, addr, ndims, offset_size, length_size)?,
(4, Some(1)) => {
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
let (csize, fmask) = if let Some(fs) = single_filtered_size {
(fs as u32, single_filter_mask.unwrap_or(0))
} else {
(chunk_byte_size as u32, 0)
};
vec![ChunkInfo {
chunk_size: csize,
filter_mask: fmask,
offsets: vec![0u64; rank],
address: addr,
}]
}
(4, Some(2)) => {
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
generate_implicit_chunks(
addr,
&dataspace.dimensions,
spatial_chunk_dims,
elem_size as u32,
)
}
(4, Some(3)) => {
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
let header =
FixedArrayHeader::parse(file_data, addr as usize, offset_size, length_size)?;
read_fixed_array_chunks(
file_data,
&header,
&dataspace.dimensions,
spatial_chunk_dims,
elem_size as u32,
offset_size,
length_size,
)?
}
(4, Some(4)) => {
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
let header = ExtensibleArrayHeader::parse(
file_data,
addr as usize,
offset_size,
length_size,
)?;
read_extensible_array_chunks(
file_data,
&header,
&dataspace.dimensions,
spatial_chunk_dims,
elem_size as u32,
offset_size,
length_size,
)?
}
(v, idx) => {
return Err(FormatError::ChunkedReadError(format!(
"unsupported chunked layout version={v}, index_type={idx:?}"
)));
}
};
let (chunks, _) = list_chunks(
file_data,
layout,
dataspace,
elem_size,
offset_size,
length_size,
)?;
cache.populate_chunk_index(&chunks, rank);
// Also populate the legacy index for compatibility
if !cache.has_index() {
@@ -1463,6 +1494,64 @@ fn copy_chunk_to_output(
mod tests {
use super::*;
fn simple_space(dimensions: Vec<u64>) -> Dataspace {
Dataspace {
space_type: crate::dataspace::DataspaceType::Simple,
rank: dimensions.len() as u8,
dimensions,
max_dimensions: None,
}
}
#[test]
fn crafted_dimensions_are_errors_not_wraparound() {
// 2^63 * 2 wraps to 0 with a plain product; 2^40 * 2^40 wraps too.
for dims in [
vec![1u64 << 63, 2],
vec![1 << 40, 1 << 40],
vec![u64::MAX, u64::MAX],
] {
let space = simple_space(dims.clone());
assert!(
matches!(space.checked_num_elements(), Err(FormatError::Overflow(_))),
"{dims:?}"
);
// The infallible accessor saturates instead of wrapping.
assert_eq!(space.num_elements(), u64::MAX, "{dims:?}");
}
assert_eq!(simple_space(vec![3, 4]).checked_num_elements().unwrap(), 12);
// A zero-sized dimension makes the whole product 0, not an overflow.
assert_eq!(
simple_space(vec![0, 1 << 40, 1 << 40])
.checked_num_elements()
.unwrap(),
0
);
}
#[test]
fn byte_length_helpers_check_overflow() {
assert_eq!(checked_byte_len(10, 8).unwrap(), 80);
assert!(matches!(
checked_byte_len(u64::MAX, 8),
Err(FormatError::Overflow(_))
));
assert_eq!(checked_chunk_byte_len(&[10, 10], 4).unwrap(), 400);
assert!(matches!(
checked_chunk_byte_len(&[usize::MAX, 2], 4),
Err(FormatError::Overflow(_))
));
}
#[test]
fn unallocatable_output_is_an_error_not_an_abort() {
assert_eq!(alloc_output(16).unwrap(), vec![0u8; 16]);
assert!(matches!(
alloc_output(usize::MAX / 2),
Err(FormatError::Overflow(_))
));
}
fn write_offset(buf: &mut Vec<u8>, val: u64, size: u8) {
match size {
4 => buf.extend_from_slice(&(val as u32).to_le_bytes()),
@@ -1657,9 +1746,9 @@ mod tests {
let chunk_bytes = chunk_size_elems * elem_size; // full chunk allocation
// Write chunk data (full chunk size, padding with zeros)
for i in start..end {
for (i, value) in values.iter().enumerate().take(end).skip(start) {
let byte_offset = data_offset + (i - start) * elem_size;
file_data[byte_offset..byte_offset + 8].copy_from_slice(&values[i].to_le_bytes());
file_data[byte_offset..byte_offset + 8].copy_from_slice(&value.to_le_bytes());
}
chunk_infos.push(ChunkInfo {
@@ -1837,8 +1926,8 @@ mod tests {
for chunk_idx in 0..2 {
let start = chunk_idx * chunk_elems;
let mut chunk_bytes = Vec::new();
for i in start..start + chunk_elems {
chunk_bytes.extend_from_slice(&values[i].to_le_bytes());
for value in values.iter().skip(start).take(chunk_elems) {
chunk_bytes.extend_from_slice(&value.to_le_bytes());
}
let compressed = compress_chunk(&chunk_bytes, &pipeline, elem_size as u32).unwrap();
@@ -2152,21 +2241,23 @@ mod tests {
}
#[test]
fn cached_read_second_call_uses_cache() {
fn cached_read_second_call_reuses_the_index() {
let values: Vec<f64> = (0..20).map(|i| i as f64).collect();
let (file_data, layout, dataspace) = build_1d_chunked_file(&values, 10);
let datatype = make_f64_type();
let cache = ChunkCache::new();
// First read — populates index + decompressed cache
// First read — populates the chunk index. These chunks are stored
// unfiltered, so they are copied straight from the file bytes and the
// decompressed-chunk cache is (deliberately) not involved.
let raw1 = read_chunked_data_cached(
&file_data, &layout, &dataspace, &datatype, None, 8, 8, &cache,
)
.unwrap();
assert!(cache.has_index());
assert!(cache.cached_chunk_count() > 0);
assert_eq!(cache.cached_chunk_count(), 0);
// Second read — should hit the decompressed cache
// Second read — reuses the cached index
let raw2 = read_chunked_data_cached(
&file_data, &layout, &dataspace, &datatype, None, 8, 8, &cache,
)
+97 -9
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
@@ -49,6 +48,38 @@ pub struct ChunkOptions {
pub pcodec: bool,
}
/// Largest chunk the automatic choice produces, in bytes.
const AUTO_CHUNK_TARGET_BYTES: u64 = 1 << 20;
/// Extent assumed for a dimension that is currently empty (an unlimited
/// dimension not yet written to) — the same stand-in h5py uses.
const AUTO_CHUNK_EMPTY_DIM: u64 = 1024;
/// Choose chunk dimensions for a dataset nobody specified them for.
///
/// Asking for compression (or any filter) without chunk dimensions used to
/// make the whole dataset one chunk. That defeats the point of chunking: any
/// read — even a single row — must decompress everything, and a large dataset
/// cannot be decompressed in parallel. Datasets up to the target size stay a
/// single chunk, exactly as before; larger ones are split by halving the
/// dimensions in turn (so chunks keep roughly the dataset's proportions, the
/// approach h5py takes) until a chunk fits the target.
pub fn auto_chunk_dims(shape: &[u64], elem_size: usize) -> Vec<u64> {
let mut dims: Vec<u64> = shape
.iter()
.map(|&d| if d == 0 { AUTO_CHUNK_EMPTY_DIM } else { d })
.collect();
let elem = elem_size.max(1) as u64;
let bytes = |dims: &[u64]| dims.iter().fold(elem, |acc, &d| acc.saturating_mul(d));
let mut axis = 0;
while bytes(&dims) > AUTO_CHUNK_TARGET_BYTES && dims.iter().any(|&d| d > 1) {
let i = axis % dims.len();
dims[i] = dims[i].div_ceil(2);
axis += 1;
}
dims
}
impl ChunkOptions {
/// Whether any chunking option is enabled.
pub fn is_chunked(&self) -> bool {
@@ -135,11 +166,17 @@ impl ChunkOptions {
/// Determine chunk dimensions, using user-specified or auto-computing.
pub fn resolve_chunk_dims(&self, shape: &[u64]) -> Vec<u64> {
if let Some(ref dims) = self.chunk_dims {
dims.clone()
} else {
// Auto chunk: use the full dataset shape (single chunk)
shape.to_vec()
// Without the element size, assume 8 bytes (the widest common scalar);
// the writer uses `resolve_chunk_dims_for`.
self.resolve_chunk_dims_for(shape, 8)
}
/// Chunk dimensions for a dataset of `shape` whose elements are `elem_size`
/// bytes: the caller's if given, otherwise chosen automatically.
pub fn resolve_chunk_dims_for(&self, shape: &[u64], elem_size: usize) -> Vec<u64> {
match self.chunk_dims {
Some(ref dims) => dims.clone(),
None => auto_chunk_dims(shape, elem_size),
}
}
}
@@ -890,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;
@@ -1143,6 +1181,45 @@ mod tests {
assert_eq!(dims, vec![100, 50]);
}
#[test]
fn auto_chunking_splits_only_large_datasets() {
let bytes = |dims: &[u64], elem: u64| dims.iter().product::<u64>() * elem;
// Up to the target: one chunk, as before.
assert_eq!(auto_chunk_dims(&[100, 50], 8), [100, 50]);
assert_eq!(auto_chunk_dims(&[131_072], 8), [131_072]); // exactly 1 MiB
// Larger: split, keeping proportions, never above the target.
let big = auto_chunk_dims(&[4096, 2048], 8);
assert!(bytes(&big, 8) <= AUTO_CHUNK_TARGET_BYTES, "{big:?}");
assert!(bytes(&big, 8) > AUTO_CHUNK_TARGET_BYTES / 4, "{big:?}");
assert_eq!(big[0] / big[1], 2, "proportions kept: {big:?}");
// Every dimension stays within the dataset and at least 1.
for shape in [
vec![10_000_000u64],
vec![3, 5_000_000],
vec![1, 1, 9_000_000],
vec![7; 9],
] {
let dims = auto_chunk_dims(&shape, 4);
assert!(
dims.iter().zip(&shape).all(|(c, s)| *c >= 1 && c <= s),
"{shape:?} -> {dims:?}"
);
assert!(
bytes(&dims, 4) <= AUTO_CHUNK_TARGET_BYTES,
"{shape:?} -> {dims:?}"
);
}
// An empty (unlimited, unwritten) dimension still gets a usable chunk.
let growable = auto_chunk_dims(&[0, 128], 8);
assert!(growable[0] >= 1 && bytes(&growable, 8) <= AUTO_CHUNK_TARGET_BYTES);
// Explicit dimensions always win.
let explicit = ChunkOptions {
chunk_dims: Some(vec![10, 10]),
..Default::default()
};
assert_eq!(explicit.resolve_chunk_dims_for(&[4096, 2048], 8), [10, 10]);
}
#[test]
fn chunk_options_pipeline_deflate() {
// Auto-shuffle is applied before compression by default (matches h5py).
@@ -1435,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())
@@ -1449,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));
}
+123 -39
View File
@@ -307,6 +307,24 @@ pub fn read_raw_data_selection(
) -> Result<Vec<u8>, FormatError> {
use crate::selection::Selection;
crate::partial_read::validate(selection, &dataspace.dimensions)?;
// Read only what the selection's bounding box touches when that is
// possible; everything below is the decode-everything-then-pick path,
// kept for the cases `partial_read` declines.
if let Some(selected) = crate::partial_read::read_selection(
file_data,
layout,
dataspace,
datatype.type_size() as usize,
pipeline,
offset_size,
length_size,
selection,
)? {
return Ok(selected);
}
match selection {
Selection::All => {
return read_raw_data_full(
@@ -475,8 +493,10 @@ fn read_virtual_data(
use crate::selection::Selection;
let elem_size = datatype.type_size() as usize;
let total_elems = dataspace.num_elements() as usize;
let mut out = vec![0u8; total_elems.saturating_mul(elem_size)];
let mut out = crate::chunked_read::alloc_output(crate::chunked_read::checked_byte_len(
dataspace.checked_num_elements()?,
elem_size,
)?)?;
let virtual_dims = &dataspace.dimensions;
@@ -598,7 +618,7 @@ fn read_named_dataset_raw(
}
/// Extract selected elements from a full dataset buffer.
fn extract_selection_from_buffer(
pub fn extract_selection_from_buffer(
full_data: &[u8],
dims: &[u64],
elem_size: usize,
@@ -616,12 +636,14 @@ fn extract_selection_from_buffer(
block,
} => {
let rank = dims.len();
let output_elements: usize = count
let output_elements = count
.iter()
.zip(block.iter())
.map(|(&c, &b)| (c * b) as usize)
.product();
let mut output = vec![0u8; output_elements * elem_size];
.try_fold(1u64, |acc, (&c, &b)| acc.checked_mul(c.checked_mul(b)?))
.ok_or_else(|| FormatError::Overflow("hyperslab count x block overflows".into()))?;
let mut output = crate::chunked_read::alloc_output(
crate::chunked_read::checked_byte_len(output_elements, elem_size)?,
)?;
// Compute dataset strides (row-major)
let mut ds_strides = vec![1usize; rank];
@@ -854,6 +876,30 @@ fn get_size(dt: &Datatype) -> usize {
dt.type_size() as usize
}
/// Reinterpret little-endian bytes as `count` native values of `T` on a
/// little-endian target, in one copy.
///
/// The buffer is allocated uninitialised and filled by the copy. It used to be
/// `vec![0; count]` first, which for a large dataset meant writing every page
/// twice (zero it, then overwrite it) — about as expensive as the copy itself.
#[cfg(target_endian = "little")]
fn native_le_to_vec<T: Copy>(raw: &[u8], count: usize) -> Vec<T> {
let bytes = count * core::mem::size_of::<T>();
debug_assert!(bytes <= raw.len());
let mut result: Vec<T> = Vec::with_capacity(count);
// SAFETY: `result` has capacity for `count` values of `T`, i.e. `bytes`
// bytes; `raw` holds at least `bytes` bytes (callers derive `count` from
// `raw.len() / size_of::<T>()`); the regions cannot overlap because
// `result` was just allocated. Every `T` used here (f32/f64/i32/i64) is
// valid for any bit pattern, so after the copy all `count` values are
// initialised and `set_len` is sound.
unsafe {
core::ptr::copy_nonoverlapping(raw.as_ptr(), result.as_mut_ptr().cast::<u8>(), bytes);
result.set_len(count);
}
result
}
/// Convert raw bytes to `f64` values.
pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatError> {
// Array datatypes (e.g. an array-typed compound member) are read as a flat
@@ -881,14 +927,7 @@ pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatEr
..
}
) {
let mut result = vec![0.0f64; count];
// SAFETY: On LE platforms, f64 in-memory representation matches LE bytes.
// We copy raw bytes directly into the f64 buffer.
// SAFETY: The byte slice is properly aligned for this type and the length is divisible by size_of::<T>().
unsafe {
core::ptr::copy_nonoverlapping(raw.as_ptr(), result.as_mut_ptr() as *mut u8, raw.len());
}
return Ok(result);
return Ok(native_le_to_vec::<f64>(raw, count));
}
let order = get_byte_order(datatype);
@@ -971,12 +1010,7 @@ pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatEr
}
)
{
let mut result = vec![0i64; count];
// SAFETY: The byte slice is properly aligned for this type and the length is divisible by size_of::<T>().
unsafe {
core::ptr::copy_nonoverlapping(raw.as_ptr(), result.as_mut_ptr() as *mut u8, raw.len());
}
return Ok(result);
return Ok(native_le_to_vec::<i64>(raw, count));
}
let order = get_byte_order(datatype);
@@ -1040,12 +1074,7 @@ pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatEr
..
}
) {
let mut result = vec![0.0f32; count];
// SAFETY: The byte slice is properly aligned for this type and the length is divisible by size_of::<T>().
unsafe {
core::ptr::copy_nonoverlapping(raw.as_ptr(), result.as_mut_ptr() as *mut u8, raw.len());
}
return Ok(result);
return Ok(native_le_to_vec::<f32>(raw, count));
}
let order = get_byte_order(datatype);
@@ -1122,12 +1151,7 @@ pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatEr
}
)
{
let mut result = vec![0i32; count];
// SAFETY: The byte slice is properly aligned for this type and the length is divisible by size_of::<T>().
unsafe {
core::ptr::copy_nonoverlapping(raw.as_ptr(), result.as_mut_ptr() as *mut u8, raw.len());
}
return Ok(result);
return Ok(native_le_to_vec::<i32>(raw, count));
}
let order = get_byte_order(datatype);
@@ -1403,6 +1427,26 @@ pub fn read_object_references(
}
Ok(result)
}
Datatype::Reference {
ref_type: crate::datatype::ReferenceType::Object2,
size,
} => {
let elem_size = *size as usize;
if elem_size == 0 {
return Ok(Vec::new());
}
if !raw.len().is_multiple_of(elem_size) {
return Err(FormatError::DataSizeMismatch {
expected: 0,
actual: raw.len(),
});
}
raw.chunks_exact(elem_size)
.map(|element| {
decode_std_object_ref(element).map(|address| ObjectReference { address })
})
.collect()
}
_ => Err(FormatError::TypeMismatch {
expected: "Reference(Object)",
actual: datatype_name(datatype),
@@ -1410,6 +1454,46 @@ pub fn read_object_references(
}
}
/// Decode one `H5T_STD_REF` object reference as stored in a dataset:
/// `type(1) flags(1) token_size(1) token(token_size)`, zero-padded to the
/// element size. For a reference within the same file the token is the target
/// object's header address. An all-zero element is a null reference and
/// decodes to the undefined address (`u64::MAX`).
fn decode_std_object_ref(element: &[u8]) -> Result<u64, FormatError> {
const STD_REF_OBJECT: u8 = 2;
const FLAG_EXTERNAL: u8 = 0x01;
if element.iter().all(|&b| b == 0) {
return Ok(u64::MAX);
}
let [ref_type, flags, token_size, token @ ..] = element else {
return Err(FormatError::UnexpectedEof {
expected: 3,
available: element.len(),
});
};
if *ref_type != STD_REF_OBJECT {
return Err(FormatError::InvalidReferenceType(*ref_type));
}
if flags & FLAG_EXTERNAL != 0 {
// Carries a file name as well; nothing here follows those.
return Err(FormatError::TypeMismatch {
expected: "object reference within this file",
actual: "external object reference",
});
}
let n = *token_size as usize;
if n == 0 || n > 8 || n > token.len() {
return Err(FormatError::UnexpectedEof {
expected: 3 + n,
available: element.len(),
});
}
Ok(token[..n]
.iter()
.rev()
.fold(0u64, |addr, &byte| (addr << 8) | u64::from(byte)))
}
/// Read region references from raw bytes.
///
/// Region references encode a dataset selection (hyperslab, point list, etc.)
@@ -1763,11 +1847,11 @@ mod tests {
fn f16_bits(v: f32) -> u16 {
// Encode a few exact values used by the test.
match v {
x if x == 0.0 => 0x0000,
x if x == 1.0 => 0x3c00,
x if x == -2.0 => 0xc000,
x if x == 0.5 => 0x3800,
x if x == 65504.0 => 0x7bff, // f16 max
0.0 => 0x0000,
1.0 => 0x3c00,
-2.0 => 0xc000,
0.5 => 0x3800,
65504.0 => 0x7bff, // f16 max
_ => panic!("unsupported test value {v}"),
}
}
@@ -2186,7 +2270,7 @@ mod tests {
],
};
let mut raw = Vec::new();
raw.extend_from_slice(&3.14f64.to_le_bytes());
raw.extend_from_slice(&3.25f64.to_le_bytes());
raw.extend_from_slice(&42i32.to_le_bytes());
let field = read_compound_field(&raw, &dt, "id").unwrap();
+32 -16
View File
@@ -1,5 +1,7 @@
//! HDF5 Dataspace message parsing (message type 0x0001).
#[cfg(not(feature = "std"))]
use alloc::format;
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
@@ -167,6 +169,27 @@ impl Dataspace {
}
}
/// [`Dataspace::num_elements`] with the product overflow-checked. The
/// dimensions are untrusted 64-bit fields; read paths that size a buffer
/// from them must use this one.
pub fn checked_num_elements(&self) -> Result<u64, FormatError> {
match self.space_type {
DataspaceType::Null => Ok(0),
DataspaceType::Scalar => Ok(1),
DataspaceType::Simple if self.dimensions.is_empty() => Ok(0),
DataspaceType::Simple => self
.dimensions
.iter()
.try_fold(1u64, |acc, &d| acc.checked_mul(d))
.ok_or_else(|| {
FormatError::Overflow(format!(
"dataspace dimensions {:?} overflow the element count",
self.dimensions
))
}),
}
}
/// Total number of elements. Scalar = 1, Null = 0.
pub fn num_elements(&self) -> u64 {
match self.space_type {
@@ -176,7 +199,12 @@ impl Dataspace {
if self.dimensions.is_empty() {
0
} else {
self.dimensions.iter().product()
// Saturate rather than wrap: a wrapped product could
// under-size a buffer. Size-critical callers use
// `checked_num_elements`.
self.dimensions
.iter()
.fold(1u64, |acc, &d| acc.saturating_mul(d))
}
}
}
@@ -189,11 +217,7 @@ mod tests {
fn build_v1_dataspace(rank: u8, flags: u8, dims: &[u64], max_dims: Option<&[u64]>) -> Vec<u8> {
let length_size = 8u8;
let mut buf = Vec::new();
buf.push(1); // version
buf.push(rank);
buf.push(flags);
buf.push(0); // reserved
let mut buf = vec![1, rank, flags, 0]; // version, rank, flags, reserved
buf.extend_from_slice(&[0u8; 4]); // reserved(4)
for &d in dims {
buf.extend_from_slice(&d.to_le_bytes());
@@ -214,11 +238,7 @@ mod tests {
dims: &[u64],
max_dims: Option<&[u64]>,
) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(2); // version
buf.push(rank);
buf.push(flags);
buf.push(type_byte);
let mut buf = vec![2, rank, flags, type_byte]; // version, rank, flags, type
for &d in dims {
buf.extend_from_slice(&d.to_le_bytes());
}
@@ -298,11 +318,7 @@ mod tests {
#[test]
fn v1_with_4byte_length() {
let mut buf = Vec::new();
buf.push(1); // version
buf.push(1); // rank
buf.push(0); // flags
buf.push(0); // reserved
let mut buf = vec![1, 1, 0, 0]; // version, rank, flags, reserved
buf.extend_from_slice(&[0u8; 4]); // reserved(4)
buf.extend_from_slice(&10u32.to_le_bytes()); // dim with length_size=4
let ds = Dataspace::parse(&buf, 4).unwrap();
+293 -40
View File
@@ -36,8 +36,18 @@ pub enum CharacterSet {
/// Reference type.
#[derive(Debug, Clone, PartialEq)]
pub enum ReferenceType {
/// Legacy object reference: the target's object header address.
Object,
/// Legacy dataset region reference.
DatasetRegion,
/// `H5T_STD_REF` object reference (HDF5 1.12+, datatype message version
/// 4): a small header followed by an object token. Decoded by
/// `data_read::read_object_references`.
Object2,
/// `H5T_STD_REF` dataset region reference.
DatasetRegion2,
/// `H5T_STD_REF` attribute reference.
Attribute,
}
/// A member of a compound datatype.
@@ -204,11 +214,25 @@ fn read_uint(data: &[u8], offset: usize, nbytes: usize) -> Result<u64, FormatErr
})
}
/// Maximum recursion depth for nested datatypes (Compound/Enumeration/
/// VariableLength/Array). A crafted file can nest a message-size-capped
/// (65535 byte) datatype message ~8000 levels deep, which would blow the
/// stack — especially on the project's no_std/embedded targets where
/// available stack is a few KB.
const MAX_DATATYPE_DEPTH: u16 = 64;
impl Datatype {
/// Parse a datatype message from raw bytes.
///
/// Returns `(Datatype, bytes_consumed)` for recursive parsing.
pub fn parse(data: &[u8]) -> Result<(Datatype, usize), FormatError> {
Self::parse_with_depth(data, 0)
}
fn parse_with_depth(data: &[u8], depth: u16) -> Result<(Datatype, usize), FormatError> {
if depth >= MAX_DATATYPE_DEPTH {
return Err(FormatError::NestingDepthExceeded);
}
// Minimum header: 4 bytes (class_and_version + 3 bytes bit field) + 4 bytes size = 8
ensure_len(data, 0, 8)?;
@@ -358,7 +382,8 @@ impl Datatype {
pos += name_len;
let byte_offset = read_uint(data, pos, ob)?;
pos += ob;
let (member_dt, consumed) = Datatype::parse(&data[pos..])?;
let (member_dt, consumed) =
Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed;
members.push(CompoundMember {
name,
@@ -367,24 +392,29 @@ impl Datatype {
});
}
} else if version == 1 || version == 2 {
// v1/v2: name, offset(4), dimensionality(1), reserved(3), dim_perm(4),
// reserved_dims(up to 4*4=16), member datatype
// v1/v2: name (null-terminated, padded to a multiple of 8
// bytes), offset(4), member datatype. v1 additionally
// carries the legacy per-member array fields between the
// offset and the member datatype: dimensionality(1),
// reserved(3), dim_perm(4), reserved(4), 4 dim sizes(16).
// v1 is what default (non-`latest`) libver bounds emit.
for _ in 0..num_members {
let (name, name_len) = read_null_terminated_string(data, pos)?;
pos += name_len;
// v1: names padded to 8-byte boundary
if version == 1 {
let total_name_bytes = name_len;
let padded = (total_name_bytes + 7) & !7;
pos = pos - name_len + padded;
}
let padded = name_len.checked_add(7).ok_or(FormatError::UnexpectedEof {
expected: usize::MAX,
available: data.len(),
})? & !7;
ensure_len(data, pos, padded)?;
pos += padded;
ensure_len(data, pos, 4)?;
let byte_offset = LittleEndian::read_u32(&data[pos..pos + 4]) as u64;
pos += 4;
// dimensionality(1) + reserved(3) + dim_perm(4) + 4 dim slots(16) = 24
ensure_len(data, pos, 24)?;
pos += 24;
let (member_dt, consumed) = Datatype::parse(&data[pos..])?;
if version == 1 {
ensure_len(data, pos, 28)?;
pos += 28;
}
let (member_dt, consumed) =
Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed;
members.push(CompoundMember {
name,
@@ -404,9 +434,15 @@ impl Datatype {
7 => {
// Reference
let ref_type_val = bf0 & 0x0F;
let ref_type = match ref_type_val {
0 => ReferenceType::Object,
1 => ReferenceType::DatasetRegion,
// Datatype message version 4 (HDF5 1.12) revised this class:
// types 2-4 are the new `H5T_STD_REF` references, and the high
// nibble of the first flag byte carries their encoding version.
let ref_type = match (ref_type_val, version) {
(0, _) => ReferenceType::Object,
(1, _) => ReferenceType::DatasetRegion,
(2, 4..) => ReferenceType::Object2,
(3, 4..) => ReferenceType::DatasetRegion2,
(4, 4..) => ReferenceType::Attribute,
_ => return Err(FormatError::InvalidReferenceType(ref_type_val)),
};
Ok((Datatype::Reference { size, ref_type }, pos))
@@ -415,7 +451,7 @@ impl Datatype {
// Enumeration
let num_members = (bf0 as u16) | ((bf1 as u16) << 8);
// Parse base type
let (base_type, base_consumed) = Datatype::parse(&data[pos..])?;
let (base_type, base_consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += base_consumed;
let base_size = base_type.type_size();
let mut members = Vec::with_capacity(num_members as usize);
@@ -468,7 +504,7 @@ impl Datatype {
} else {
None
};
let (base_type, consumed) = Datatype::parse(&data[pos..])?;
let (base_type, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed;
Ok((
Datatype::VariableLength {
@@ -494,7 +530,7 @@ impl Datatype {
}
// skip permutation indices
pos += ndims * 4;
let (base_type, consumed) = Datatype::parse(&data[pos..])?;
let (base_type, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed;
Ok((
Datatype::Array {
@@ -515,7 +551,7 @@ impl Datatype {
dimensions.push(LittleEndian::read_u32(&data[pos..pos + 4]));
pos += 4;
}
let (base_type, consumed) = Datatype::parse(&data[pos..])?;
let (base_type, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed;
Ok((
Datatype::Array {
@@ -532,27 +568,39 @@ impl Datatype {
}
}
11 => {
// Complex number — store as compound of two floats internally
// Parse like compound with version 3 and 2 members
// But actually class 11 has no special properties beyond class 6 compound.
// It's just recognized as a separate class. For now parse the 2 members
// as compound.
let num_members = (bf0 as u16) | ((bf1 as u16) << 8);
let mut members = Vec::with_capacity(num_members as usize);
let ob = offset_bytes_for_size(size);
for _ in 0..num_members {
let (name, name_len) = read_null_terminated_string(data, pos)?;
pos += name_len;
let byte_offset = read_uint(data, pos, ob)?;
pos += ob;
let (member_dt, consumed) = Datatype::parse(&data[pos..])?;
pos += consumed;
members.push(CompoundMember {
name,
byte_offset,
datatype: member_dt,
// Complex number (HDF5 2.0, datatype version 5). The properties
// are a single base floating-point datatype message; an element
// is two consecutive base-type values (real, imaginary). There
// is no member list. Surface it as the equivalent two-member
// compound `{r, i}` — the same shape h5py writes for numpy
// complex dtypes — so downstream compound readers work as-is.
if version != 5 {
return Err(FormatError::InvalidDatatypeVersion {
class: class_id,
version,
});
}
let (base_type, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed;
let base_size = base_type.type_size();
if base_size.checked_mul(2) != Some(size) {
return Err(FormatError::DataSizeMismatch {
expected: (base_size as usize).saturating_mul(2),
actual: size as usize,
});
}
let members = vec![
CompoundMember {
name: String::from("r"),
byte_offset: 0,
datatype: base_type.clone(),
},
CompoundMember {
name: String::from("i"),
byte_offset: base_size as u64,
datatype: base_type,
},
];
Ok((Datatype::Compound { size, members }, pos))
}
_ => Err(FormatError::InvalidDatatypeClass(class_id)),
@@ -814,6 +862,39 @@ mod tests {
buf
}
/// A crafted datatype message nesting Variable-Length wrappers deeper
/// than `MAX_DATATYPE_DEPTH` must return `NestingDepthExceeded`
/// instead of overflowing the stack.
#[test]
fn nested_variable_length_exceeds_depth_limit() {
// Each VL level is just an 8-byte header (class 9, vl_type=0 =>
// sequence, no padding/charset fields) immediately followed by the
// next level's bytes, terminated by a fixed-point base type.
let levels = MAX_DATATYPE_DEPTH as usize + 10;
let mut data = Vec::new();
for _ in 0..levels {
data.extend_from_slice(&build_dt_header(9, 3, [0, 0, 0], 0));
}
data.extend_from_slice(&build_fixed_point(4, false, false, 0, 32));
let result = Datatype::parse(&data);
assert!(matches!(result, Err(FormatError::NestingDepthExceeded)));
}
/// A datatype nested just within the depth limit must still parse fine.
#[test]
fn nested_variable_length_within_depth_limit_ok() {
let levels = MAX_DATATYPE_DEPTH as usize - 1;
let mut data = Vec::new();
for _ in 0..levels {
data.extend_from_slice(&build_dt_header(9, 3, [0, 0, 0], 0));
}
data.extend_from_slice(&build_fixed_point(4, false, false, 0, 32));
let result = Datatype::parse(&data);
assert!(result.is_ok());
}
#[test]
fn test_fixed_point_u8() {
let data = build_fixed_point(1, false, false, 0, 8);
@@ -1079,6 +1160,156 @@ mod tests {
}
}
/// Real datatype message bytes emitted by h5py 3.16 / HDF5 2.0 with
/// *default* libver bounds for [('x','f8'),('y','f8'),('id','i4')]:
/// compound datatype version 1 (padded names + 28 bytes of legacy
/// per-member array fields).
fn compound_v1_bytes() -> Vec<u8> {
let f64le: [u8; 20] = [
0x11, 0x20, 0x3f, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x34, 0x0b,
0x00, 0x34, 0xff, 0x03, 0x00, 0x00,
];
let i32le: [u8; 12] = [
0x10, 0x08, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00,
];
let mut b = vec![0x16, 0x03, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00];
for (name, offset, dt) in [
(&b"x"[..], 0u32, &f64le[..]),
(&b"y"[..], 8, &f64le[..]),
(&b"id"[..], 16, &i32le[..]),
] {
let mut padded = name.to_vec();
padded.resize((name.len() + 1 + 7) & !7, 0);
b.extend_from_slice(&padded);
b.extend_from_slice(&offset.to_le_bytes());
b.extend_from_slice(&[0u8; 28]);
b.extend_from_slice(dt);
}
b
}
fn assert_xyid_compound(dt: Datatype) {
match dt {
Datatype::Compound { size, members } => {
assert_eq!(size, 20);
let got: Vec<(&str, u64, u32)> = members
.iter()
.map(|m| (m.name.as_str(), m.byte_offset, m.datatype.type_size()))
.collect();
assert_eq!(got, vec![("x", 0, 8), ("y", 8, 8), ("id", 16, 4)]);
}
other => panic!("expected Compound, got {other:?}"),
}
}
#[test]
fn test_compound_v1_default_libver() {
let bytes = compound_v1_bytes();
let (dt, consumed) = Datatype::parse(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_xyid_compound(dt);
}
#[test]
fn test_compound_v2_padded_names_no_array_fields() {
// v2 = v1 without the 28 bytes of per-member array fields; names are
// still padded to a multiple of 8 (matches libhdf5's H5O decoder).
let v1 = compound_v1_bytes();
let mut v2 = vec![0x26, 0x03, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00];
let mut pos = 8;
for dt_len in [20usize, 20, 12] {
v2.extend_from_slice(&v1[pos..pos + 8 + 4]); // padded name + offset
pos += 8 + 4 + 28;
v2.extend_from_slice(&v1[pos..pos + dt_len]);
pos += dt_len;
}
let (dt, consumed) = Datatype::parse(&v2).unwrap();
assert_eq!(consumed, v2.len());
assert_xyid_compound(dt);
}
#[test]
fn test_compound_v1_truncated_is_error_not_panic() {
let bytes = compound_v1_bytes();
for cut in 8..bytes.len() {
assert!(Datatype::parse(&bytes[..cut]).is_err(), "cut at {cut}");
}
}
/// Real datatype message bytes emitted by HDF5 2.0 for the native complex
/// type `H5T_COMPLEX_IEEE_F64LE`: class 11, version 5, size 16, followed by
/// the base IEEE f64 datatype message.
const COMPLEX_F64_HDF5_2_0: [u8; 28] = [
0x5b, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x11, 0x20, 0x3f, 0x00, 0x08, 0x00, 0x00,
0x00, 0x00, 0x00, 0x40, 0x00, 0x34, 0x0b, 0x00, 0x34, 0xff, 0x03, 0x00, 0x00,
];
#[test]
fn test_complex_v5_from_hdf5_2_0() {
let (dt, consumed) = Datatype::parse(&COMPLEX_F64_HDF5_2_0).unwrap();
assert_eq!(consumed, COMPLEX_F64_HDF5_2_0.len());
match dt {
Datatype::Compound { size, members } => {
assert_eq!(size, 16);
assert_eq!(members.len(), 2);
assert_eq!((members[0].name.as_str(), members[0].byte_offset), ("r", 0));
assert_eq!((members[1].name.as_str(), members[1].byte_offset), ("i", 8));
for m in &members {
assert!(matches!(
m.datatype,
Datatype::FloatingPoint { size: 8, .. }
));
}
}
other => panic!("expected Compound, got {other:?}"),
}
}
#[test]
fn test_compound_with_complex_member_from_hdf5_2_0() {
// Compound { z: complex f64 @0, k: i64 @16 } as written by HDF5 2.0.
// Regression guard: the complex member must consume exactly its own
// bytes so the following member parses.
let mut bytes = vec![
0x56, 0x02, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, b'z', 0x00, 0x00,
];
bytes.extend_from_slice(&COMPLEX_F64_HDF5_2_0);
bytes.extend_from_slice(&[b'k', 0x00, 0x10]);
bytes.extend_from_slice(&[
0x10, 0x08, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00,
]);
let (dt, consumed) = Datatype::parse(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
match dt {
Datatype::Compound { size, members } => {
assert_eq!(size, 24);
assert_eq!(members.len(), 2);
assert!(matches!(
&members[0].datatype,
Datatype::Compound { size: 16, members } if members.len() == 2
));
assert_eq!(
(members[1].name.as_str(), members[1].byte_offset),
("k", 16)
);
}
other => panic!("expected Compound, got {other:?}"),
}
}
#[test]
fn test_complex_size_mismatch_rejected() {
let mut bytes = COMPLEX_F64_HDF5_2_0;
bytes[4] = 0x0c; // claims 12 bytes, base type is 8
assert!(matches!(
Datatype::parse(&bytes),
Err(FormatError::DataSizeMismatch {
expected: 16,
actual: 12
})
));
}
#[test]
fn test_reference_object() {
let buf = build_dt_header(7, 1, [0, 0, 0], 8);
@@ -1348,6 +1579,28 @@ mod tests {
assert_eq!(err, FormatError::InvalidCharacterSet(2));
}
#[test]
fn test_reference_v4_std_ref_from_hdf5_2_0() {
// Datatype message of an H5T_STD_REF dataset written by HDF5 2.0:
// class 7, version 4, type 2 (object), encoding version 1, 18 bytes.
let bytes = [0x47, 0x12, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00];
let (dt, consumed) = Datatype::parse(&bytes).unwrap();
assert_eq!(consumed, 8);
assert_eq!(
dt,
Datatype::Reference {
size: 18,
ref_type: ReferenceType::Object2
}
);
// The new types are only valid from datatype version 4.
let old_version = [0x37, 0x12, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00];
assert_eq!(
Datatype::parse(&old_version).unwrap_err(),
FormatError::InvalidReferenceType(2)
);
}
#[test]
fn test_error_invalid_reference_type() {
let buf = build_dt_header(7, 1, [5, 0, 0], 8);
+36
View File
@@ -114,6 +114,23 @@ pub enum FormatError {
InvalidAttributeInfoVersion(u8),
/// Invalid shared message version.
InvalidSharedMessageVersion(u8),
/// A message is marked shared but was parsed without access to the file,
/// so the reference to the real message could not be followed.
UnresolvedSharedMessage,
/// A selection does not fit the dataset it was applied to (wrong rank, or
/// it reaches past a dimension's extent).
SelectionOutOfBounds(String),
/// The dataset's raw data is stored in external files (External Data
/// Files message), which this reader does not follow.
ExternalDataFilesUnsupported,
/// The path goes through an external link (a link into another file),
/// which this reader does not follow.
ExternalLinkUnsupported {
/// The file the link points into.
filename: String,
/// The object path within that file.
object_path: String,
},
/// Invalid SOHM table version.
InvalidSohmTableVersion(u8),
/// Invalid SOHM table signature (expected "SMTB").
@@ -307,6 +324,25 @@ impl fmt::Display for FormatError {
FormatError::InvalidSharedMessageVersion(v) => {
write!(f, "invalid shared message version: {v}")
}
FormatError::ExternalLinkUnsupported {
filename,
object_path,
} => write!(
f,
"path goes through an external link to {object_path} in {filename}, which is \
not supported"
),
FormatError::ExternalDataFilesUnsupported => write!(
f,
"dataset raw data is stored in external file(s), which is not supported"
),
FormatError::SelectionOutOfBounds(msg) => {
write!(f, "selection out of bounds: {msg}")
}
FormatError::UnresolvedSharedMessage => write!(
f,
"message is shared but no file data was available to resolve it"
),
FormatError::InvalidSohmTableVersion(v) => {
write!(f, "invalid SOHM table version: {v}")
}
+44 -24
View File
@@ -54,6 +54,19 @@ fn read_offset(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
})
}
fn ensure_len(data: &[u8], offset: usize, needed: usize) -> Result<(), FormatError> {
if offset
.checked_add(needed)
.is_none_or(|end| end > data.len())
{
return Err(FormatError::UnexpectedEof {
expected: offset.saturating_add(needed),
available: data.len(),
});
}
Ok(())
}
fn is_undefined_addr(addr: u64, offset_size: u8) -> bool {
match offset_size {
2 => addr == 0xFFFF,
@@ -98,12 +111,7 @@ impl ExtensibleArrayHeader {
// 6 stats fields (each length_size) + index_block_address(offset_size) + checksum(4)
let min_size =
4 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 6 * length_size as usize + offset_size as usize + 4;
if offset + min_size > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: offset + min_size,
available: file_data.len(),
});
}
ensure_len(file_data, offset, min_size)?;
let d = &file_data[offset..];
if &d[0..4] != b"EAHD" {
@@ -275,12 +283,7 @@ fn read_data_block_elements(
) -> 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;
if db_offset + db_header_size > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: db_offset + db_header_size,
available: file_data.len(),
});
}
ensure_len(file_data, db_offset, db_header_size)?;
let d = &file_data[db_offset..];
if &d[0..4] != b"EADB" {
@@ -427,12 +430,7 @@ pub fn read_extensible_array_chunks(
// Parse index block (AEIB)
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
if ib_offset + ib_header_size > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: ib_offset + ib_header_size,
available: file_data.len(),
});
}
ensure_len(file_data, ib_offset, ib_header_size)?;
let ib = &file_data[ib_offset..];
if &ib[0..4] != b"EAIB" {
@@ -628,12 +626,7 @@ fn read_super_block(
// AESB: signature(4) + version(1) + client_id(1) + header_address(offset_size)
let sb_header_size = 4 + 1 + 1 + os;
if sb_offset + sb_header_size > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: sb_offset + sb_header_size,
available: file_data.len(),
});
}
ensure_len(file_data, sb_offset, sb_header_size)?;
if &file_data[sb_offset..sb_offset + 4] != b"EASB" {
return Err(FormatError::ChunkedReadError(
@@ -759,6 +752,33 @@ mod tests {
assert!(result.is_err());
}
/// A near-`usize::MAX` offset must error cleanly, not overflow/panic.
#[test]
fn parse_rejects_offset_overflow() {
let buf = vec![0u8; 64];
let result = ExtensibleArrayHeader::parse(&buf, usize::MAX - 4, 8, 8);
assert!(result.is_err());
}
/// A near-`usize::MAX` index block address must error cleanly, not overflow/panic.
#[test]
fn read_rejects_index_block_offset_overflow() {
let header = ExtensibleArrayHeader {
client_id: 0,
element_size: 8,
max_nelmts_bits: 10,
idx_blk_elmts: 2,
min_dblk_nelmts: 4,
super_blk_min_nelmts: 2,
max_dblk_nelmts_bits: 8,
num_elements: 5,
index_block_address: (usize::MAX - 4) as u64,
};
let buf = vec![0u8; 64];
let r = read_extensible_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
assert!(r.is_err());
}
#[test]
fn parse_header_invalid_version() {
let mut buf = vec![0u8; 256];
+3 -1
View File
@@ -1221,8 +1221,10 @@ impl FileWriter {
precompressed: None,
});
} else if is_chunked[i] {
let chunk_dims = d.chunk_options.resolve_chunk_dims(&d.ds.dimensions);
let elem_size = d.dt.type_size() as usize;
let chunk_dims = d
.chunk_options
.resolve_chunk_dims_for(&d.ds.dimensions, elem_size);
// Compress once in Pass 1; cache the result so Pass 2 can skip
// re-compression and just rebuild the index with real addresses.
let pre = precompress_chunks(
+407
View File
@@ -0,0 +1,407 @@
//! Fill Value messages (0x0005, and the old 0x0004) and applying them on read.
//!
//! HDF5 allocates storage lazily: a chunk nobody wrote to does not exist in the
//! file, and a contiguous dataset nobody wrote to has no data address at all.
//! Reading such a region must yield the dataset's *fill value* (zeros unless
//! the creator chose otherwise). The readers in [`crate::chunked_read`] leave
//! those regions zeroed; [`apply_to_unallocated_chunks`] then overwrites exactly
//! the chunk-grid cells that are absent from the chunk index — so it can never
//! mistake a stored zero for a hole — and is skipped entirely in the common
//! case of a zero fill value.
#[cfg(not(feature = "std"))]
use alloc::{format, vec, vec::Vec};
use crate::chunked_read::{alloc_output, checked_byte_len, list_chunks};
use crate::data_layout::DataLayout;
use crate::dataspace::Dataspace;
use crate::error::FormatError;
use crate::message_type::MessageType;
use crate::object_header::HeaderMessage;
/// Largest fill value accepted. A fill value is one element of the dataset's
/// datatype; this only bounds the allocation driven by the message's size field.
const MAX_FILL_VALUE_SIZE: usize = 1 << 20;
/// Parse a Fill Value message, returning the user-defined fill value bytes, or
/// `None` when the dataset uses the default (all zeros) or has the fill value
/// explicitly undefined.
pub fn parse_fill_value(msg: &HeaderMessage) -> Result<Option<Vec<u8>>, FormatError> {
let data = msg.data.as_slice();
let value_at = |pos: usize| -> Result<Option<Vec<u8>>, FormatError> {
let size_bytes = data.get(pos..pos + 4).ok_or(FormatError::UnexpectedEof {
expected: pos + 4,
available: data.len(),
})?;
let size = u32::from_le_bytes([size_bytes[0], size_bytes[1], size_bytes[2], size_bytes[3]])
as usize;
if size == 0 {
return Ok(None);
}
if size > MAX_FILL_VALUE_SIZE {
return Err(FormatError::Overflow(format!(
"fill value of {size} bytes exceeds the {MAX_FILL_VALUE_SIZE}-byte limit"
)));
}
let start = pos + 4;
let value =
data.get(start..start.saturating_add(size))
.ok_or(FormatError::UnexpectedEof {
expected: start.saturating_add(size),
available: data.len(),
})?;
Ok(Some(value.to_vec()))
};
match msg.msg_type {
// Old fill value message: size(4), value.
MessageType::FillValueOld => value_at(0),
MessageType::FillValue => {
let version = *data.first().ok_or(FormatError::UnexpectedEof {
expected: 1,
available: 0,
})?;
match version {
// version, alloc time, write time, defined, [size, value]
1 | 2 => {
let defined = *data.get(3).ok_or(FormatError::UnexpectedEof {
expected: 4,
available: data.len(),
})?;
if version == 2 && defined == 0 {
Ok(None)
} else if data.len() < 8 && version == 1 {
// v1 always carries a size, but tolerate its absence.
Ok(None)
} else {
value_at(4)
}
}
// version, flags (bit 4 = undefined, bit 5 = defined), [size, value]
3 => {
let flags = *data.get(1).ok_or(FormatError::UnexpectedEof {
expected: 2,
available: data.len(),
})?;
if flags & 0x10 != 0 || flags & 0x20 == 0 {
Ok(None)
} else {
value_at(2)
}
}
v => Err(FormatError::UnsupportedVersion(v)),
}
}
_ => Ok(None),
}
}
/// The fill value that applies to a dataset given its header messages. The new
/// message wins over the old one when both are present.
pub fn dataset_fill_value(messages: &[HeaderMessage]) -> Result<Option<Vec<u8>>, FormatError> {
for wanted in [MessageType::FillValue, MessageType::FillValueOld] {
if let Some(msg) = messages.iter().find(|m| m.msg_type == wanted) {
if crate::shared_message::is_shared(msg.flags) {
// A shared fill value is legal but vanishingly rare; treat it
// as the default rather than misparsing the reference.
return Ok(None);
}
if let Some(value) = parse_fill_value(msg)? {
return Ok(Some(value));
}
}
}
Ok(None)
}
/// `true` when a fill value is absent or all zeros, i.e. identical to what the
/// readers already produce for unallocated storage.
pub fn is_default(fill: Option<&[u8]>) -> bool {
fill.is_none_or(|f| f.iter().all(|&b| b == 0))
}
/// A whole dataset's worth of fill value: what reading a dataset with no
/// allocated storage at all must return.
pub fn filled_dataset(
dataspace: &Dataspace,
elem_size: usize,
fill: Option<&[u8]>,
) -> Result<Vec<u8>, FormatError> {
let total = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
let mut out = alloc_output(total)?;
if let Some(fill) = fill.filter(|f| f.len() == elem_size && !is_default(Some(f))) {
for element in out.chunks_exact_mut(elem_size) {
element.copy_from_slice(fill);
}
}
Ok(out)
}
/// Whether the layout has any storage in the file at all. A dataset that was
/// created but never written to has none.
pub fn has_storage(layout: &DataLayout) -> bool {
!matches!(
layout,
DataLayout::Contiguous { address: None, .. }
| DataLayout::Chunked {
btree_address: None,
..
}
)
}
/// Run a full-dataset `read`, giving unallocated storage its fill value: a
/// dataset with no storage at all reads as entirely fill value (instead of
/// failing), and a chunked dataset has the fill value written into every
/// chunk the file never allocated.
#[allow(clippy::too_many_arguments)]
pub fn read_full_with_fill<E: From<FormatError>>(
messages: &[HeaderMessage],
file_data: &[u8],
layout: &DataLayout,
dataspace: &Dataspace,
elem_size: usize,
offset_size: u8,
length_size: u8,
read: impl FnOnce() -> Result<Vec<u8>, E>,
) -> Result<Vec<u8>, E> {
// A dataset with external raw data also has no data address in this
// file. It is NOT unallocated — its values live elsewhere — so it must
// never be answered with the fill value.
if messages
.iter()
.any(|m| m.msg_type == MessageType::ExternalDataFiles)
{
return Err(FormatError::ExternalDataFilesUnsupported.into());
}
let fill = dataset_fill_value(messages)?;
if !has_storage(layout) {
return Ok(filled_dataset(dataspace, elem_size, fill.as_deref())?);
}
let mut output = read()?;
apply_to_unallocated_chunks(
&mut output,
file_data,
layout,
dataspace,
elem_size,
fill.as_deref(),
offset_size,
length_size,
)?;
Ok(output)
}
/// Overwrite, in a fully read chunked dataset `output`, every region whose
/// chunk was never allocated with `fill`. No-op for non-chunked layouts, a
/// default fill value, or a fill value whose size doesn't match the element.
#[allow(clippy::too_many_arguments)]
pub fn apply_to_unallocated_chunks(
output: &mut [u8],
file_data: &[u8],
layout: &DataLayout,
dataspace: &Dataspace,
elem_size: usize,
fill: Option<&[u8]>,
offset_size: u8,
length_size: u8,
) -> Result<(), FormatError> {
let Some(fill) = fill.filter(|f| f.len() == elem_size && !is_default(Some(f))) else {
return Ok(());
};
if !matches!(layout, DataLayout::Chunked { .. }) || elem_size == 0 {
return Ok(());
}
let (chunks, chunk_dims) = list_chunks(
file_data,
layout,
dataspace,
elem_size,
offset_size,
length_size,
)?;
let rank = chunk_dims.len();
let ds_dims: Vec<usize> = dataspace.dimensions.iter().map(|&d| d as usize).collect();
if rank == 0 || ds_dims.len() != rank || chunk_dims.contains(&0) {
return Ok(());
}
// Row-major strides over the dataset and over the chunk grid.
let mut ds_strides = vec![1usize; rank];
for i in (0..rank - 1).rev() {
ds_strides[i] = ds_strides[i + 1].saturating_mul(ds_dims[i + 1]);
}
let grid: Vec<usize> = ds_dims
.iter()
.zip(&chunk_dims)
.map(|(&d, &c)| d.div_ceil(c))
.collect();
let cells = grid
.iter()
.try_fold(1usize, |acc, &g| acc.checked_mul(g))
.ok_or_else(|| FormatError::Overflow("chunk grid size overflows".into()))?;
if cells == 0 {
return Ok(());
}
let mut allocated = vec![false; cells];
for chunk in &chunks {
// Undefined address: the index has a slot for the chunk but no storage.
if chunk.address == u64::MAX || chunk.offsets.len() < rank {
continue;
}
let mut cell = 0usize;
let mut in_range = true;
for d in 0..rank {
let coord = chunk.offsets[d] as usize / chunk_dims[d];
if coord >= grid[d] {
in_range = false;
break;
}
cell = cell * grid[d] + coord;
}
if in_range {
allocated[cell] = true;
}
}
let mut coord = vec![0usize; rank];
for (cell, is_allocated) in allocated.iter().enumerate() {
if *is_allocated {
continue;
}
// Decode the cell index into grid coordinates.
let mut rem = cell;
for d in (0..rank).rev() {
coord[d] = rem % grid[d];
rem /= grid[d];
}
fill_cell(
output,
&coord,
&chunk_dims,
&ds_dims,
&ds_strides,
elem_size,
fill,
);
}
Ok(())
}
/// Fill the part of chunk-grid cell `coord` that lies inside the dataset.
fn fill_cell(
output: &mut [u8],
coord: &[usize],
chunk_dims: &[usize],
ds_dims: &[usize],
ds_strides: &[usize],
elem_size: usize,
fill: &[u8],
) {
let rank = coord.len();
let start: Vec<usize> = (0..rank).map(|d| coord[d] * chunk_dims[d]).collect();
let end: Vec<usize> = (0..rank)
.map(|d| (start[d] + chunk_dims[d]).min(ds_dims[d]))
.collect();
if (0..rank).any(|d| start[d] >= end[d]) {
return;
}
// Walk every row (all dims but the last) and fill the run along the last.
let run = end[rank - 1] - start[rank - 1];
let mut idx = start.clone();
loop {
let first: usize = (0..rank).map(|d| idx[d] * ds_strides[d]).sum();
let from = first * elem_size;
let to = from + run * elem_size;
if let Some(region) = output.get_mut(from..to) {
for element in region.chunks_exact_mut(elem_size) {
element.copy_from_slice(fill);
}
}
// Advance the odometer over dims 0..rank-1.
let mut d = rank - 1;
loop {
if d == 0 {
return;
}
d -= 1;
idx[d] += 1;
if idx[d] < end[d] {
break;
}
idx[d] = start[d];
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn msg(msg_type: MessageType, data: &[u8]) -> HeaderMessage {
HeaderMessage {
msg_type,
size: data.len(),
flags: 0,
creation_order: None,
data: data.to_vec(),
}
}
#[test]
fn parses_v3_defined_undefined_and_default() {
// Real message for h5py `fillvalue=-1` on an i4 dataset (HDF5 2.0).
let defined = msg(
MessageType::FillValue,
&[3, 0x2b, 4, 0, 0, 0, 0xff, 0xff, 0xff, 0xff],
);
assert_eq!(parse_fill_value(&defined).unwrap(), Some(vec![0xff; 4]));
let default = msg(MessageType::FillValue, &[3, 0x0a]);
assert_eq!(parse_fill_value(&default).unwrap(), None);
let undefined = msg(MessageType::FillValue, &[3, 0x19]);
assert_eq!(parse_fill_value(&undefined).unwrap(), None);
}
#[test]
fn parses_v2_and_old_messages() {
let v2 = msg(MessageType::FillValue, &[2, 2, 2, 1, 2, 0, 0, 0, 7, 0]);
assert_eq!(parse_fill_value(&v2).unwrap(), Some(vec![7, 0]));
let v2_undefined = msg(MessageType::FillValue, &[2, 2, 2, 0]);
assert_eq!(parse_fill_value(&v2_undefined).unwrap(), None);
let old = msg(MessageType::FillValueOld, &[2, 0, 0, 0, 9, 9]);
assert_eq!(parse_fill_value(&old).unwrap(), Some(vec![9, 9]));
}
#[test]
fn truncated_or_oversized_fill_is_an_error() {
let short = msg(MessageType::FillValue, &[3, 0x29, 4, 0, 0, 0, 0xff]);
assert!(parse_fill_value(&short).is_err());
let huge = msg(MessageType::FillValue, &[3, 0x29, 0xff, 0xff, 0xff, 0x7f]);
assert!(matches!(
parse_fill_value(&huge),
Err(FormatError::Overflow(_))
));
}
#[test]
fn fill_cell_clips_edge_chunks_in_2d() {
// 3x5 dataset, 2x2 chunks; fill grid cell (1, 2): rows 2..3, cols 4..5.
let mut out = vec![0u8; 15];
fill_cell(&mut out, &[1, 2], &[2, 2], &[3, 5], &[5, 1], 1, &[9]);
let mut expected = vec![0u8; 15];
expected[2 * 5 + 4] = 9;
assert_eq!(out, expected);
// Interior cell (0, 1): rows 0..2, cols 2..4.
let mut out = vec![0u8; 15];
fill_cell(&mut out, &[0, 1], &[2, 2], &[3, 5], &[5, 1], 1, &[7]);
let filled: Vec<usize> = out
.iter()
.enumerate()
.filter(|(_, b)| **b == 7)
.map(|(i, _)| i)
.collect();
assert_eq!(filled, [2, 3, 7, 8]);
}
}
+64 -18
View File
@@ -845,9 +845,33 @@ fn shuffle_decompress(data: &[u8], element_size: usize) -> Result<Vec<u8>, Forma
let num_elements = data.len() / element_size;
let mut result = vec![0u8; data.len()];
for i in 0..num_elements {
for j in 0..element_size {
result[i * element_size + j] = data[j * num_elements + i];
// The shuffled stream is `element_size` byte planes of `num_elements`
// bytes each; un-shuffling interleaves them. This is on the read path of
// every compressed dataset (shuffle is applied automatically before
// compression). The naive `result[i * es + j] = data[j * n + i]` form does
// a multiply and two bounds checks per byte and defeats vectorisation;
// fixed-width plane arrays sliced to a common length let the compiler
// hoist the checks and emit interleaves for the common 4- and 8-byte
// element sizes.
fn interleave<const W: usize>(data: &[u8], n: usize, out: &mut [u8]) {
let planes: [&[u8]; W] = core::array::from_fn(|j| &data[j * n..(j + 1) * n]);
for (i, element) in out.as_chunks_mut::<W>().0.iter_mut().enumerate() {
for (byte, plane) in element.iter_mut().zip(&planes) {
*byte = plane[i];
}
}
}
match element_size {
2 => interleave::<2>(data, num_elements, &mut result),
4 => interleave::<4>(data, num_elements, &mut result),
8 => interleave::<8>(data, num_elements, &mut result),
16 => interleave::<16>(data, num_elements, &mut result),
_ => {
for (i, element) in result.chunks_exact_mut(element_size).enumerate() {
for (j, byte) in element.iter_mut().enumerate() {
*byte = data[j * num_elements + i];
}
}
}
}
@@ -1045,24 +1069,30 @@ fn pcodec_compress(data: &[u8], element_size: usize) -> Result<Vec<u8>, FormatEr
match element_size {
4 => {
let nums: Vec<f32> = data
.chunks_exact(4)
.map(|b| f32::from_le_bytes(b.try_into().unwrap()))
.as_chunks::<4>()
.0
.iter()
.map(|b| f32::from_le_bytes(*b))
.collect();
simple_compress(&nums, &config)
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
}
8 => {
let nums: Vec<f64> = data
.chunks_exact(8)
.map(|b| f64::from_le_bytes(b.try_into().unwrap()))
.as_chunks::<8>()
.0
.iter()
.map(|b| f64::from_le_bytes(*b))
.collect();
simple_compress(&nums, &config)
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
}
_ => {
let nums: Vec<u32> = data
.chunks_exact(4)
.map(|b| u32::from_le_bytes(b.try_into().unwrap()))
.as_chunks::<4>()
.0
.iter()
.map(|b| u32::from_le_bytes(*b))
.collect();
simple_compress(&nums, &config)
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
@@ -1092,11 +1122,7 @@ fn pcodec_decompress(
} else {
MAX_DECOMPRESS_SIZE
};
let n = if element_size != 0 {
limit_bytes / element_size
} else {
0
};
let n = limit_bytes.checked_div(element_size).unwrap_or(0);
match element_size {
4 => {
let mut buf = vec![0f32; n];
@@ -1543,8 +1569,10 @@ mod tests {
fn as_f32(bytes: &[u8]) -> Vec<f32> {
bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.as_chunks::<4>()
.0
.iter()
.map(|c| f32::from_le_bytes(*c))
.collect()
}
@@ -1578,8 +1606,10 @@ mod tests {
fn as_f64(bytes: &[u8]) -> Vec<f64> {
bytes
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.as_chunks::<8>()
.0
.iter()
.map(|c| f64::from_le_bytes(*c))
.collect()
}
@@ -1842,4 +1872,20 @@ mod tests {
};
assert!(decompress_chunk(&data, &pipeline, 16, 1).is_err());
}
#[test]
fn unshuffle_inverts_shuffle_for_every_element_size() {
for element_size in [1usize, 2, 3, 4, 5, 8, 12, 16, 24] {
for elements in [0usize, 1, 2, 7, 64, 1000] {
let original: Vec<u8> = (0..element_size * elements)
.map(|i| (i * 31 + 7) as u8)
.collect();
let shuffled = shuffle_compress(&original, element_size).unwrap();
assert_eq!(
shuffle_decompress(&shuffled, element_size).unwrap(),
original,
"element_size {element_size}, {elements} elements"
);
}
}
}
}
+38 -12
View File
@@ -47,6 +47,19 @@ fn read_length(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
read_offset(data, pos, size)
}
fn ensure_len(data: &[u8], offset: usize, needed: usize) -> Result<(), FormatError> {
if offset
.checked_add(needed)
.is_none_or(|end| end > data.len())
{
return Err(FormatError::UnexpectedEof {
expected: offset.saturating_add(needed),
available: data.len(),
});
}
Ok(())
}
fn is_undefined(data: &[u8], pos: usize, size: u8) -> bool {
let s = size as usize;
if pos + s > data.len() {
@@ -66,12 +79,7 @@ impl FixedArrayHeader {
// FAHD signature(4) + version(1) + client_id(1) + element_size(1) +
// max_nelmts_bits(1) + num_elements(length_size) + data_block_addr(offset_size) + checksum(4)
let min_size = 4 + 1 + 1 + 1 + 1 + length_size as usize + offset_size as usize + 4;
if offset + min_size > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: offset + min_size,
available: file_data.len(),
});
}
ensure_len(file_data, offset, min_size)?;
let d = &file_data[offset..];
if &d[0..4] != b"FAHD" {
@@ -126,12 +134,7 @@ pub fn read_fixed_array_chunks(
// Parse data block header: FADB(4) + version(1) + client_id(1) + header_address(offset_size)
let db_header_size = 4 + 1 + 1 + offset_size as usize;
if db_offset + db_header_size > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: db_offset + db_header_size,
available: file_data.len(),
});
}
ensure_len(file_data, db_offset, db_header_size)?;
let d = &file_data[db_offset..];
if &d[0..4] != b"FADB" {
@@ -489,6 +492,29 @@ mod tests {
assert!(r.is_err());
}
/// A near-`usize::MAX` offset must error cleanly, not overflow/panic.
#[test]
fn parse_rejects_offset_overflow() {
let buf = vec![0u8; 64];
let result = FixedArrayHeader::parse(&buf, usize::MAX - 4, 8, 8);
assert!(result.is_err());
}
/// A near-`usize::MAX` data block address must error cleanly, not overflow/panic.
#[test]
fn read_rejects_data_block_offset_overflow() {
let header = FixedArrayHeader {
client_id: 0,
element_size: 8,
max_nelmts_bits: 10,
num_elements: 1,
data_block_address: (usize::MAX - 4) as u64,
};
let buf = vec![0u8; 64];
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8);
assert!(r.is_err());
}
#[test]
fn parse_fixed_array_header_invalid_version() {
let mut buf = vec![0u8; 256];
+1 -3
View File
@@ -184,9 +184,7 @@ mod tests {
buf.extend_from_slice(data);
// Pad to 8 bytes
let padded = pad8(data.len());
for _ in data.len()..padded {
buf.push(0);
}
buf.resize(buf.len() + (padded - data.len()), 0);
}
// Free space marker
+48
View File
@@ -60,6 +60,54 @@ pub fn resolve_v1_group_entries(
Ok(entries)
}
/// Symbol table cache type for a soft link: the scratch pad's first four bytes
/// are the local-heap offset of the link's target path, and the entry's object
/// header address is undefined.
const CACHE_TYPE_SOFT_LINK: u32 = 2;
/// The target path of the soft link called `name` in a v1 group, if any.
pub fn find_v1_soft_link(
file_data: &[u8],
sym_table_msg: &SymbolTableMessage,
name: &str,
offset_size: u8,
length_size: u8,
) -> Result<Option<String>, FormatError> {
let heap = LocalHeap::parse(
file_data,
sym_table_msg.local_heap_address as usize,
offset_size,
length_size,
)?;
let snod_addrs = collect_symbol_table_nodes(
file_data,
sym_table_msg.btree_address,
offset_size,
length_size,
)?;
for snod_addr in snod_addrs {
let snod = SymbolTableNode::parse(file_data, snod_addr as usize, offset_size)?;
for entry in &snod.entries {
if entry.cache_type != CACHE_TYPE_SOFT_LINK {
continue;
}
if heap.read_string(file_data, entry.link_name_offset)? != name {
continue;
}
let value_offset = u32::from_le_bytes([
entry.scratch_pad[0],
entry.scratch_pad[1],
entry.scratch_pad[2],
entry.scratch_pad[3],
]);
return heap
.read_string(file_data, u64::from(value_offset))
.map(Some);
}
}
Ok(None)
}
/// Extract the SymbolTableMessage from an object header's messages.
fn find_symbol_table_message(
obj_header: &ObjectHeader,
+135 -18
View File
@@ -63,14 +63,15 @@ fn resolve_compact_entries(
Ok(entries)
}
/// Resolve entries from dense storage (fractal heap + B-tree v2).
fn resolve_dense_entries(
/// Visit every link in dense storage (fractal heap + B-tree v2 name index).
fn for_each_dense_link(
file_data: &[u8],
link_info: &LinkInfoMessage,
fh_addr: u64,
offset_size: u8,
length_size: u8,
) -> Result<Vec<GroupEntry>, FormatError> {
mut visit: impl FnMut(LinkMessage),
) -> Result<(), FormatError> {
// Parse fractal heap
let fh = FractalHeapHeader::parse(file_data, fh_addr as usize, offset_size, length_size)?;
@@ -81,7 +82,6 @@ fn resolve_dense_entries(
let btree_hdr = BTreeV2Header::parse(file_data, btree_addr as usize, offset_size, length_size)?;
let records = collect_btree_v2_records(file_data, &btree_hdr, offset_size, length_size)?;
let mut entries = Vec::new();
for record in &records {
// For type 5 (name index): hash(4) + heap_id(heap_id_length)
// For type 6 (creation order): creation_order(8) + heap_id(heap_id_length)
@@ -98,22 +98,94 @@ fn resolve_dense_entries(
// Read managed object from fractal heap
let link_data = fh.read_managed_object(file_data, id_bytes, offset_size)?;
visit(LinkMessage::parse(&link_data, offset_size)?);
}
Ok(())
}
// Parse as Link message
let link = LinkMessage::parse(&link_data, offset_size)?;
if let LinkTarget::Hard {
object_header_address,
} = link.link_target
{
entries.push(GroupEntry {
name: link.name,
/// Resolve entries from dense storage (fractal heap + B-tree v2).
fn resolve_dense_entries(
file_data: &[u8],
link_info: &LinkInfoMessage,
fh_addr: u64,
offset_size: u8,
length_size: u8,
) -> Result<Vec<GroupEntry>, FormatError> {
let mut entries = Vec::new();
for_each_dense_link(
file_data,
link_info,
fh_addr,
offset_size,
length_size,
|link| {
if let LinkTarget::Hard {
object_header_address,
cache_type: 0,
});
} = link.link_target
{
entries.push(GroupEntry {
name: link.name,
object_header_address,
cache_type: 0,
});
}
},
)?;
Ok(entries)
}
/// The soft or external link called `name` in this group, if there is one.
/// Hard links are what `resolve_group_entries` returns; this is consulted only
/// when a path component isn't among them.
fn find_symbolic_link(
file_data: &[u8],
object_header: &ObjectHeader,
name: &str,
offset_size: u8,
length_size: u8,
) -> Result<Option<LinkTarget>, FormatError> {
if is_v1_group(object_header) {
let Some(sym_msg) = object_header
.messages
.iter()
.find(|m| m.msg_type == MessageType::SymbolTable)
else {
return Ok(None);
};
let stm = SymbolTableMessage::parse(&sym_msg.data, offset_size)?;
return group_v1::find_v1_soft_link(file_data, &stm, name, offset_size, length_size)
.map(|target| target.map(|target_path| LinkTarget::Soft { target_path }));
}
if !is_v2_group(object_header) {
return Ok(None);
}
let is_symbolic = |t: &LinkTarget| !matches!(t, LinkTarget::Hard { .. });
let link_info = find_link_info(object_header, offset_size)?;
let mut found = None;
if let Some(fh_addr) = link_info.fractal_heap_address {
for_each_dense_link(
file_data,
&link_info,
fh_addr,
offset_size,
length_size,
|link| {
if link.name == name && is_symbolic(&link.link_target) {
found = Some(link.link_target);
}
},
)?;
} else {
for msg in &object_header.messages {
if msg.msg_type == MessageType::Link {
let link = LinkMessage::parse(&msg.data, offset_size)?;
if link.name == name && is_symbolic(&link.link_target) {
found = Some(link.link_target);
}
}
}
}
Ok(entries)
Ok(found)
}
/// Find and parse the Link Info message from an object header.
@@ -158,6 +230,19 @@ pub fn resolve_path_any(
file_data: &[u8],
superblock: &Superblock,
path: &str,
) -> Result<u64, FormatError> {
resolve_path_following_links(file_data, superblock, path, 0)
}
/// Soft links followed while resolving one path. Guards against link cycles
/// (`a -> b -> a`), which are legal to create.
const MAX_SOFT_LINK_DEPTH: u8 = 16;
fn resolve_path_following_links(
file_data: &[u8],
superblock: &Superblock,
path: &str,
depth: u8,
) -> Result<u64, FormatError> {
let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if components.is_empty() {
@@ -176,7 +261,9 @@ pub fn resolve_path_any(
for (i, component) in components.iter().enumerate() {
let entries = resolve_group_entries(file_data, &current_header, os, ls)?;
let found = entries.iter().find(|e| e.name == *component);
let found = entries
.iter()
.find(|e| e.name == *component && e.object_header_address != u64::MAX);
match found {
Some(entry) => {
if i == components.len() - 1 {
@@ -186,7 +273,37 @@ pub fn resolve_path_any(
current_header = ObjectHeader::parse(file_data, current_addr as usize, os, ls)?;
}
None => {
return Err(FormatError::PathNotFound(String::from(*component)));
return match find_symbolic_link(file_data, &current_header, component, os, ls)? {
Some(LinkTarget::Soft { target_path }) => {
if depth >= MAX_SOFT_LINK_DEPTH {
return Err(FormatError::NestingDepthExceeded);
}
// A relative target is relative to the group holding
// the link; then the rest of the original path.
let mut full = String::new();
if !target_path.starts_with('/') {
for parent in &components[..i] {
full.push('/');
full.push_str(parent);
}
}
full.push('/');
full.push_str(&target_path);
for rest in &components[i + 1..] {
full.push('/');
full.push_str(rest);
}
resolve_path_following_links(file_data, superblock, &full, depth + 1)
}
Some(LinkTarget::External {
filename,
object_path,
}) => Err(FormatError::ExternalLinkUnsupported {
filename,
object_path,
}),
_ => Err(FormatError::PathNotFound(String::from(*component))),
};
}
}
}
+2
View File
@@ -67,6 +67,7 @@ pub mod ea_writer;
pub mod error;
pub mod extensible_array;
pub mod file_writer;
pub mod fill_value;
pub mod filter_pipeline;
pub mod filters;
mod filters_szip;
@@ -88,6 +89,7 @@ pub mod object_header;
pub mod object_header_writer;
#[cfg(feature = "parallel")]
pub mod parallel_read;
pub mod partial_read;
pub mod profiling;
pub mod property_list;
pub mod selection;
+4 -11
View File
@@ -413,11 +413,8 @@ mod tests {
#[test]
fn soft_link() {
let target = "/group1/dataset";
let mut data = Vec::new();
data.push(1); // version
data.push(0x08); // flags: bit 3 = link type present, name size = 1 byte (bits 0-1 = 0)
data.push(1); // link type = soft
data.push(4); // name length = 4
// version, flags (bit 3 = link type present, name size = 1 byte), link type = soft, name length = 4
let mut data = vec![1, 0x08, 1, 4];
data.extend_from_slice(b"link");
data.extend_from_slice(&(target.len() as u16).to_le_bytes());
data.extend_from_slice(target.as_bytes());
@@ -455,12 +452,8 @@ mod tests {
#[test]
fn invalid_link_type() {
let mut data = Vec::new();
data.push(1); // version
data.push(0x08); // flags: bit 3 = link type present
data.push(99); // invalid link type
data.push(1); // name length = 1
data.push(b'x');
// version, flags (bit 3 = link type present), invalid link type = 99, name length = 1, name = 'x'
let data = vec![1, 0x08, 99, 1, b'x'];
let err = LinkMessage::parse(&data, 8).unwrap_err();
assert_eq!(err, FormatError::InvalidLinkType(99));
}
+14 -3
View File
@@ -9,6 +9,9 @@ pub enum MessageType {
Datatype,
FillValueOld,
FillValue,
/// External Data Files (0x0007): the dataset's raw data lives in other
/// files, listed by this message.
ExternalDataFiles,
Link,
DataLayout,
GroupInfo,
@@ -36,6 +39,7 @@ impl MessageType {
0x0004 => MessageType::FillValueOld,
0x0005 => MessageType::FillValue,
0x0006 => MessageType::Link,
0x0007 => MessageType::ExternalDataFiles,
0x0008 => MessageType::DataLayout,
0x000A => MessageType::GroupInfo,
0x000B => MessageType::FilterPipeline,
@@ -60,6 +64,7 @@ impl MessageType {
MessageType::Datatype => 0x0003,
MessageType::FillValueOld => 0x0004,
MessageType::FillValue => 0x0005,
MessageType::ExternalDataFiles => 0x0007,
MessageType::Link => 0x0006,
MessageType::DataLayout => 0x0008,
MessageType::GroupInfo => 0x000A,
@@ -90,6 +95,7 @@ mod tests {
(0x0003, MessageType::Datatype),
(0x0004, MessageType::FillValueOld),
(0x0005, MessageType::FillValue),
(0x0007, MessageType::ExternalDataFiles),
(0x0006, MessageType::Link),
(0x0008, MessageType::DataLayout),
(0x000A, MessageType::GroupInfo),
@@ -119,8 +125,13 @@ mod tests {
#[test]
fn unknown_type_zero_gap() {
// 0x0007 is not a defined type
let mt = MessageType::from_u16(0x0007);
assert_eq!(mt, MessageType::Unknown(0x0007));
// 0x0009 is reserved for the library's own testing; no file uses it.
let mt = MessageType::from_u16(0x0009);
assert_eq!(mt, MessageType::Unknown(0x0009));
// 0x0007 used to be treated as unknown: it is External Data Files.
assert_eq!(
MessageType::from_u16(0x0007),
MessageType::ExternalDataFiles
);
}
}
+15 -6
View File
@@ -73,9 +73,12 @@ pub fn decompress_chunks_lane_partitioned(
let c_addr = chunk_info.address as usize;
let size = chunk_info.chunk_size as usize;
if c_addr + size > file_data.len() {
if c_addr
.checked_add(size)
.is_none_or(|end| end > file_data.len())
{
return Err(FormatError::UnexpectedEof {
expected: c_addr + size,
expected: c_addr.saturating_add(size),
available: file_data.len(),
});
}
@@ -144,9 +147,12 @@ pub fn decompress_chunks_parallel(
.map(|(index, chunk_info)| {
let c_addr = chunk_info.address as usize;
let size = chunk_info.chunk_size as usize;
if c_addr + size > file_data.len() {
if c_addr
.checked_add(size)
.is_none_or(|end| end > file_data.len())
{
return Err(FormatError::UnexpectedEof {
expected: c_addr + size,
expected: c_addr.saturating_add(size),
available: file_data.len(),
});
}
@@ -182,9 +188,12 @@ pub fn decompress_chunks_sequential(
for chunk_info in chunks {
let c_addr = chunk_info.address as usize;
let size = chunk_info.chunk_size as usize;
if c_addr + size > file_data.len() {
if c_addr
.checked_add(size)
.is_none_or(|end| end > file_data.len())
{
return Err(FormatError::UnexpectedEof {
expected: c_addr + size,
expected: c_addr.saturating_add(size),
available: file_data.len(),
});
}
+359
View File
@@ -0,0 +1,359 @@
//! Selection reads that cost what the selection costs, not what the dataset
//! costs.
//!
//! [`crate::data_read::read_raw_data_selection`] used to decode the *entire*
//! dataset and then pick elements out of it, so reading a 64x64 window of a
//! large dataset took about as long as reading all of it. Here the selection's
//! bounding box is materialised instead — only the rows of a contiguous
//! dataset, or only the chunks, that overlap it — and the existing extractor
//! runs over that small buffer with the selection translated to the box's
//! origin. Extraction semantics are therefore exactly the full-read ones.
#[cfg(not(feature = "std"))]
use alloc::string as alloc_or_std;
#[cfg(not(feature = "std"))]
use alloc::{format, vec, vec::Vec};
#[cfg(feature = "std")]
use std::string as alloc_or_std;
use crate::chunked_read::{alloc_output, checked_byte_len, list_chunks};
use crate::data_layout::DataLayout;
use crate::data_read::extract_selection_from_buffer;
use crate::dataspace::Dataspace;
use crate::error::FormatError;
use crate::filter_pipeline::FilterPipeline;
use crate::filters::decompress_chunk;
use crate::selection::Selection;
/// The smallest axis-aligned box containing every selected element, as
/// `(start, extent)` per dimension. `None` when there is nothing to gain or
/// the selection is not valid for `dims` (the caller's full path then reports
/// the error exactly as before).
fn bounding_box(selection: &Selection, dims: &[u64]) -> Option<(Vec<u64>, Vec<u64>)> {
match selection {
Selection::Hyperslab {
start,
stride,
count,
block,
} => {
let rank = dims.len();
if [start.len(), stride.len(), count.len(), block.len()] != [rank; 4] {
return None;
}
let mut extent = Vec::with_capacity(rank);
for d in 0..rank {
if count[d] == 0 || block[d] == 0 {
return None;
}
// Last selected index + 1, relative to start.
let span = (count[d] - 1)
.checked_mul(stride[d])?
.checked_add(block[d])?;
if start[d].checked_add(span)? > dims[d] {
return None;
}
extent.push(span);
}
Some((start.clone(), extent))
}
Selection::Points(points) => {
let rank = dims.len();
let first = points.first()?;
if first.len() != rank {
return None;
}
let (mut lo, mut hi) = (first.clone(), first.clone());
for p in points {
if p.len() != rank {
return None;
}
for d in 0..rank {
if p[d] >= dims[d] {
return None;
}
lo[d] = lo[d].min(p[d]);
hi[d] = hi[d].max(p[d]);
}
}
let extent = lo.iter().zip(&hi).map(|(l, h)| h - l + 1).collect();
Some((lo, extent))
}
Selection::All | Selection::None => None,
}
}
/// Check that `selection` addresses only elements that exist in a dataset of
/// shape `dims`. Without this an out-of-range selection read *something*: a
/// hyperslab past the edge came back padded with zeros, and a point whose
/// column was out of range wrapped into the next row.
pub fn validate(selection: &Selection, dims: &[u64]) -> Result<(), FormatError> {
let rank = dims.len();
let bad = |msg: alloc_or_std::String| Err(FormatError::SelectionOutOfBounds(msg));
match selection {
Selection::All | Selection::None => Ok(()),
Selection::Hyperslab {
start,
stride,
count,
block,
} => {
if [start.len(), stride.len(), count.len(), block.len()] != [rank; 4] {
return bad(format!("hyperslab rank does not match dataset rank {rank}"));
}
for d in 0..rank {
if count[d] == 0 || block[d] == 0 {
continue; // selects nothing along this dimension
}
let end = (count[d] - 1)
.checked_mul(stride[d])
.and_then(|v| v.checked_add(block[d]))
.and_then(|v| v.checked_add(start[d]));
if !end.is_some_and(|end| end <= dims[d]) {
return bad(format!(
"dimension {d}: start {} stride {} count {} block {} exceeds extent {}",
start[d], stride[d], count[d], block[d], dims[d]
));
}
if block[d] > stride[d] && count[d] > 1 {
return bad(format!(
"dimension {d}: block {} larger than stride {} (overlapping blocks)",
block[d], stride[d]
));
}
}
Ok(())
}
Selection::Points(points) => {
for p in points {
if p.len() != rank {
return bad(format!("point {p:?} does not match dataset rank {rank}"));
}
if let Some(d) = (0..rank).find(|&d| p[d] >= dims[d]) {
return bad(format!(
"point {p:?}: coordinate {} exceeds extent {} of dimension {d}",
p[d], dims[d]
));
}
}
Ok(())
}
}
}
/// The same selection expressed relative to `origin`.
fn translate(selection: &Selection, origin: &[u64]) -> Selection {
match selection {
Selection::Hyperslab {
start,
stride,
count,
block,
} => Selection::Hyperslab {
start: start.iter().zip(origin).map(|(s, o)| s - o).collect(),
stride: stride.clone(),
count: count.clone(),
block: block.clone(),
},
Selection::Points(points) => Selection::Points(
points
.iter()
.map(|p| p.iter().zip(origin).map(|(c, o)| c - o).collect())
.collect(),
),
other => other.clone(),
}
}
/// Copy the part of a source region that overlaps the box into `out` (which
/// is the box, row-major).
///
/// The source region starts at `src_origin` in dataset coordinates, has shape
/// `src_shape`, and its elements are in `src` row-major. One `memcpy` per
/// overlapping row of the last dimension.
#[allow(clippy::too_many_arguments)]
fn copy_overlap(
src: &[u8],
src_origin: &[u64],
src_shape: &[u64],
out: &mut [u8],
box_start: &[u64],
box_extent: &[u64],
elem_size: usize,
) {
let rank = box_start.len();
// Overlap in dataset coordinates.
let mut lo = vec![0u64; rank];
let mut hi = vec![0u64; rank];
for d in 0..rank {
lo[d] = src_origin[d].max(box_start[d]);
hi[d] = (src_origin[d] + src_shape[d]).min(box_start[d] + box_extent[d]);
if lo[d] >= hi[d] {
return;
}
}
let strides = |shape: &[u64]| {
let mut s = vec![1u64; rank];
for d in (0..rank.saturating_sub(1)).rev() {
s[d] = s[d + 1] * shape[d + 1];
}
s
};
let (src_strides, out_strides) = (strides(src_shape), strides(box_extent));
let last = rank - 1;
let run = ((hi[last] - lo[last]) as usize) * elem_size;
let mut idx = lo.clone();
loop {
let src_at: u64 = (0..rank)
.map(|d| (idx[d] - src_origin[d]) * src_strides[d])
.sum();
let out_at: u64 = (0..rank)
.map(|d| (idx[d] - box_start[d]) * out_strides[d])
.sum();
let (s, o) = (src_at as usize * elem_size, out_at as usize * elem_size);
if let (Some(from), Some(to)) = (src.get(s..s + run), out.get_mut(o..o + run)) {
to.copy_from_slice(from);
}
// Advance over every dimension but the last.
let mut d = last;
loop {
if d == 0 {
return;
}
d -= 1;
idx[d] += 1;
if idx[d] < hi[d] {
break;
}
idx[d] = lo[d];
}
}
}
/// Read `selection` without materialising the whole dataset, when that is
/// possible and worthwhile. `Ok(None)` means "use the full-read path": an
/// `All`/`None`/invalid selection, a layout this doesn't handle (compact,
/// virtual, storage-less), or a bounding box covering most of the dataset.
#[allow(clippy::too_many_arguments)]
pub fn read_selection(
file_data: &[u8],
layout: &DataLayout,
dataspace: &Dataspace,
elem_size: usize,
pipeline: Option<&FilterPipeline>,
offset_size: u8,
length_size: u8,
selection: &Selection,
) -> Result<Option<Vec<u8>>, FormatError> {
let dims = &dataspace.dimensions;
if dims.is_empty() || elem_size == 0 {
return Ok(None);
}
let Some((box_start, box_extent)) = bounding_box(selection, dims) else {
return Ok(None);
};
let total = dataspace.checked_num_elements()?;
let box_elements = box_extent
.iter()
.try_fold(1u64, |acc, &e| acc.checked_mul(e))
.ok_or_else(|| FormatError::Overflow("selection bounding box overflows".into()))?;
// A box covering most of the dataset gains nothing over the full path.
if box_elements.saturating_mul(2) > total {
return Ok(None);
}
let mut boxed = alloc_output(checked_byte_len(box_elements, elem_size)?)?;
match layout {
DataLayout::Contiguous {
address: Some(address),
..
} => {
let base = usize::try_from(*address)
.map_err(|_| FormatError::Overflow("data address exceeds usize".into()))?;
let data = file_data
.get(base..)
.and_then(|d| d.get(..checked_byte_len(total, elem_size).ok()?))
.ok_or(FormatError::UnexpectedEof {
expected: base,
available: file_data.len(),
})?;
let origin = vec![0u64; dims.len()];
copy_overlap(
data,
&origin,
dims,
&mut boxed,
&box_start,
&box_extent,
elem_size,
);
}
DataLayout::Chunked {
btree_address: Some(_),
..
} => {
let (chunks, chunk_dims) = list_chunks(
file_data,
layout,
dataspace,
elem_size,
offset_size,
length_size,
)?;
let rank = dims.len();
let chunk_shape: Vec<u64> = chunk_dims.iter().map(|&d| d as u64).collect();
let chunk_bytes = crate::chunked_read::checked_chunk_byte_len(&chunk_dims, elem_size)?;
for chunk in &chunks {
if chunk.offsets.len() < rank || chunk.address == u64::MAX {
continue;
}
let origin = &chunk.offsets[..rank];
let overlaps = (0..rank).all(|d| {
origin[d] < box_start[d] + box_extent[d]
&& origin[d].saturating_add(chunk_shape[d]) > box_start[d]
});
if !overlaps {
continue;
}
let at = usize::try_from(chunk.address)
.map_err(|_| FormatError::Overflow("chunk address exceeds usize".into()))?;
let raw = at
.checked_add(chunk.chunk_size as usize)
.and_then(|end| file_data.get(at..end))
.ok_or(FormatError::UnexpectedEof {
expected: at.saturating_add(chunk.chunk_size as usize),
available: file_data.len(),
})?;
// Mirrors the full-read path: a non-zero filter mask means the
// chunk was stored unfiltered.
let decoded;
let data: &[u8] = match pipeline {
Some(pl) if chunk.filter_mask == 0 => {
decoded = decompress_chunk(raw, pl, chunk_bytes, elem_size as u32)?;
&decoded
}
_ => raw,
};
copy_overlap(
data,
origin,
&chunk_shape,
&mut boxed,
&box_start,
&box_extent,
elem_size,
);
}
}
_ => return Ok(None),
}
extract_selection_from_buffer(
&boxed,
&box_extent,
elem_size,
&translate(selection, &box_start),
)
.map(Some)
}
+1 -1
View File
@@ -509,7 +509,7 @@ mod tests {
#[test]
fn selection_slice_1d() {
let sel = Selection::slice(&[5..15]);
let sel = Selection::slice(std::slice::from_ref(&(5..15)));
assert_eq!(sel.num_elements(&[100]), 10);
assert_eq!(sel.output_shape(&[100]), vec![10]);
}
+104 -64
View File
@@ -16,8 +16,12 @@
//! - SMLI list structure: simple list of shared message entries
//! - B-tree v2 type 7: indexed shared message entries
#[cfg(not(feature = "std"))]
use alloc::borrow::Cow;
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
#[cfg(feature = "std")]
use std::borrow::Cow;
use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records};
use crate::error::FormatError;
@@ -28,6 +32,14 @@ use crate::object_header::ObjectHeader;
/// Fractal heap ID length for SOHM entries (fixed at 8 bytes).
const FHEAP_ID_LEN: usize = 8;
/// Shared-message `type` values (version 3 encoding).
/// The message is in the file's shared-message (SOHM) fractal heap.
const SHARE_TYPE_SOHM: u8 = 1;
/// The message is in another object's header (a committed/named datatype).
const SHARE_TYPE_COMMITTED: u8 = 2;
/// The message is stored here but is sharable.
const SHARE_TYPE_HERE: u8 = 3;
/// A resolved shared message reference.
#[derive(Debug, Clone)]
pub struct SharedMessageRef {
@@ -35,9 +47,10 @@ pub struct SharedMessageRef {
pub ref_type: u8,
/// Version of the shared message encoding.
pub version: u8,
/// Address of the object header containing the shared message (type 1, 3).
/// Address of the object header holding the message (committed). Set for
/// every v1/v2 reference and for v3 types 2 and 3.
pub object_header_address: Option<u64>,
/// Fractal heap ID for type 2 (SOHM) references.
/// Fractal heap ID for a v3 SOHM (type 1) reference.
pub heap_id: Option<[u8; FHEAP_ID_LEN]>,
}
@@ -146,48 +159,39 @@ pub fn parse_shared_ref(data: &[u8], offset_size: u8) -> Result<SharedMessageRef
let version = data[0];
let ref_type = data[1];
// Layouts (HDF5 spec IV.A.2 "Shared Message", and libhdf5's decoder):
// v1: version, type, reserved(6), address — always "committed"
// v2: version, type, address — always "committed"
// v3: version, type, then a fractal-heap ID if type == SOHM, otherwise
// an address
// Verified against h5py/HDF5 2.0 output, which writes `02 02 <address>`
// for a dataset using a committed datatype under both default and
// `latest` libver bounds.
let address_at = |pos: usize| -> Result<SharedMessageRef, FormatError> {
ensure_len(data, pos, offset_size as usize)?;
Ok(SharedMessageRef {
ref_type,
version,
object_header_address: Some(read_offset(data, pos, offset_size)?),
heap_id: None,
})
};
match version {
1 | 2 => {
// v1/v2: reserved(6) + address(offset_size)
let pos = 2 + 6; // skip reserved bytes
ensure_len(data, pos, offset_size as usize)?;
let addr = read_offset(data, pos, offset_size)?;
1 => address_at(2 + 6),
2 => address_at(2),
3 if ref_type == SHARE_TYPE_SOHM => {
ensure_len(data, 2, FHEAP_ID_LEN)?;
let mut id = [0u8; FHEAP_ID_LEN];
id.copy_from_slice(&data[2..2 + FHEAP_ID_LEN]);
Ok(SharedMessageRef {
ref_type,
version,
object_header_address: Some(addr),
heap_id: None,
object_header_address: None,
heap_id: Some(id),
})
}
3 => {
match ref_type {
1 | 3 => {
// type 1/3: message in another object header
// v3 layout: version(1) + type(1) + address(offset_size)
ensure_len(data, 2, offset_size as usize)?;
let addr = read_offset(data, 2, offset_size)?;
Ok(SharedMessageRef {
ref_type,
version,
object_header_address: Some(addr),
heap_id: None,
})
}
2 => {
// type 2: SOHM table (fractal heap ID)
ensure_len(data, 2, FHEAP_ID_LEN)?;
let mut id = [0u8; FHEAP_ID_LEN];
id.copy_from_slice(&data[2..2 + FHEAP_ID_LEN]);
Ok(SharedMessageRef {
ref_type,
version,
object_header_address: None,
heap_id: Some(id),
})
}
_ => Err(FormatError::InvalidSharedMessageVersion(ref_type)),
}
}
3 if ref_type == SHARE_TYPE_COMMITTED || ref_type == SHARE_TYPE_HERE => address_at(2),
3 => Err(FormatError::InvalidSharedMessageVersion(ref_type)),
_ => Err(FormatError::InvalidSharedMessageVersion(version)),
}
}
@@ -422,6 +426,35 @@ pub fn resolve_sohm_message(
fh_header.read_managed_object(file_data, heap_id, offset_size)
}
/// The payload of an object-header message, following the indirection if the
/// message is *shared* (header flag bit 1).
///
/// A shared message's bytes are not the message itself but a reference to
/// where it lives — e.g. a dataset created with a committed (named) datatype
/// stores only a pointer to that datatype's object header. Every reader of a
/// message that may be shared (datatype, dataspace, fill value, filter
/// pipeline, attribute) must go through this; parsing the reference bytes as
/// the message yields garbage rather than an error.
pub fn message_data<'a>(
file_data: &[u8],
msg: &'a crate::object_header::HeaderMessage,
offset_size: u8,
length_size: u8,
) -> Result<Cow<'a, [u8]>, FormatError> {
if !is_shared(msg.flags) {
return Ok(Cow::Borrowed(&msg.data));
}
let shared_ref = parse_shared_ref(&msg.data, offset_size)?;
resolve_shared_message(
file_data,
&shared_ref,
msg.msg_type,
offset_size,
length_size,
)
.map(Cow::Owned)
}
/// Resolve a shared message to its actual message data.
///
/// For type 1/3 (shared in another object header), reads the target object header
@@ -453,14 +486,14 @@ pub fn resolve_shared_message_with_sohm(
length_size: u8,
sohm_table: Option<&SohmTable>,
) -> Result<Vec<u8>, FormatError> {
match shared_ref.ref_type {
1 | 3 => {
let addr = shared_ref
.object_header_address
.ok_or(FormatError::UnexpectedEof {
expected: 1,
available: 0,
})?;
// Dispatch on what the reference carries rather than on `ref_type`: v1/v2
// references are always an object-header address whatever their type
// byte says.
match (
shared_ref.object_header_address,
shared_ref.heap_id.as_ref(),
) {
(Some(addr), _) => {
let target_header =
ObjectHeader::parse(file_data, addr as usize, offset_size, length_size)?;
for msg in &target_header.messages {
@@ -487,11 +520,7 @@ pub fn resolve_shared_message_with_sohm(
available: 0,
})
}
2 => {
let heap_id = shared_ref
.heap_id
.as_ref()
.ok_or(FormatError::InvalidSharedMessageVersion(2))?;
(None, Some(heap_id)) => {
let table = sohm_table.ok_or(FormatError::InvalidSharedMessageVersion(2))?;
resolve_sohm_message(
file_data,
@@ -502,7 +531,7 @@ pub fn resolve_shared_message_with_sohm(
length_size,
)
}
_ => Err(FormatError::InvalidSharedMessageVersion(
(None, None) => Err(FormatError::InvalidSharedMessageVersion(
shared_ref.ref_type,
)),
}
@@ -522,15 +551,15 @@ mod tests {
}
#[test]
fn parse_v3_type1_ref() {
fn parse_v3_committed_ref() {
let mut data = Vec::new();
data.push(3); // version
data.push(1); // type 1 = shared in another OH
data.push(SHARE_TYPE_COMMITTED); // message lives in another object header
data.extend_from_slice(&0x1234u64.to_le_bytes()); // address
let shared = parse_shared_ref(&data, 8).unwrap();
assert_eq!(shared.version, 3);
assert_eq!(shared.ref_type, 1);
assert_eq!(shared.ref_type, SHARE_TYPE_COMMITTED);
assert_eq!(shared.object_header_address, Some(0x1234));
assert!(shared.heap_id.is_none());
}
@@ -539,7 +568,7 @@ mod tests {
fn parse_v3_type3_ref() {
let mut data = Vec::new();
data.push(3); // version
data.push(3); // type 3 = shared in another OH (v3 encoding)
data.push(SHARE_TYPE_HERE); // stored here but sharable: an address
data.extend_from_slice(&0xABCDu64.to_le_bytes());
let shared = parse_shared_ref(&data, 8).unwrap();
@@ -563,10 +592,10 @@ mod tests {
#[test]
fn parse_v2_ref() {
// v2 dropped v1's six reserved bytes: the address follows the type.
let mut data = Vec::new();
data.push(2); // version
data.push(0); // type
data.extend_from_slice(&[0u8; 6]); // reserved
data.push(SHARE_TYPE_COMMITTED);
data.extend_from_slice(&0x9000u32.to_le_bytes());
let shared = parse_shared_ref(&data, 4).unwrap();
@@ -575,15 +604,26 @@ mod tests {
}
#[test]
fn parse_v3_type2_sohm() {
fn parse_v2_ref_from_hdf5_2_0() {
// Datatype message of a dataset created with a committed datatype,
// as written by h5py 3.16 / HDF5 2.0 (libver='latest'): header flags
// 0x03 (shared), payload `02 02 <8-byte object header address>`.
let data = [0x02, 0x02, 0xb3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let shared = parse_shared_ref(&data, 8).unwrap();
assert_eq!(shared.object_header_address, Some(0xb3));
assert!(shared.heap_id.is_none());
}
#[test]
fn parse_v3_sohm_ref() {
let mut data = Vec::new();
data.push(3); // version
data.push(2); // type 2 = SOHM heap
data.push(SHARE_TYPE_SOHM); // message lives in the SOHM fractal heap
data.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44]);
let shared = parse_shared_ref(&data, 8).unwrap();
assert_eq!(shared.version, 3);
assert_eq!(shared.ref_type, 2);
assert_eq!(shared.ref_type, SHARE_TYPE_SOHM);
assert_eq!(shared.object_header_address, None);
assert_eq!(
shared.heap_id,
@@ -592,10 +632,10 @@ mod tests {
}
#[test]
fn parse_v3_type2_too_short() {
fn parse_v3_sohm_too_short() {
let mut data = Vec::new();
data.push(3); // version
data.push(2); // type 2 = SOHM heap
data.push(SHARE_TYPE_SOHM);
data.extend_from_slice(&[0xAA, 0xBB]); // only 2 bytes, need 8
let err = parse_shared_ref(&data, 8).unwrap_err();
@@ -620,7 +660,7 @@ mod tests {
fn parse_four_byte_offsets() {
let mut data = Vec::new();
data.push(3); // version
data.push(1); // type 1
data.push(SHARE_TYPE_COMMITTED);
data.extend_from_slice(&0x1000u32.to_le_bytes());
let shared = parse_shared_ref(&data, 4).unwrap();
+31 -3
View File
@@ -80,9 +80,12 @@ impl SymbolTableNode {
offset_size: u8,
) -> Result<SymbolTableNode, FormatError> {
// signature(4) + version(1) + reserved(1) + number_of_symbols(2) = 8
if offset + 8 > file_data.len() {
if offset
.checked_add(8)
.is_none_or(|end| end > file_data.len())
{
return Err(FormatError::UnexpectedEof {
expected: offset + 8,
expected: offset.saturating_add(8),
available: file_data.len(),
});
}
@@ -103,7 +106,12 @@ impl SymbolTableNode {
// Each entry: link_name_offset(os) + obj_hdr_addr(os) + cache_type(4) + reserved(4) + scratch(16)
let entry_size = os + os + 4 + 4 + 16;
let entries_start = offset + 8;
let needed = entries_start + num_symbols * entry_size;
let needed = entries_start.checked_add(num_symbols * entry_size).ok_or(
FormatError::UnexpectedEof {
expected: usize::MAX,
available: file_data.len(),
},
)?;
if needed > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: needed,
@@ -228,4 +236,24 @@ mod tests {
let err = SymbolTableNode::parse(&data, 0, 8).unwrap_err();
assert_eq!(err, FormatError::InvalidSymbolTableNodeVersion(2));
}
/// A near-`usize::MAX` SNOD offset must error cleanly, not overflow/panic.
#[test]
fn parse_snod_rejects_offset_overflow() {
let data = build_snod(&[], 8);
let result = SymbolTableNode::parse(&data, usize::MAX - 4, 8);
assert!(result.is_err());
}
/// A huge symbol count combined with a large entries_start must not
/// overflow the `needed` size computation.
#[test]
fn parse_snod_rejects_entries_size_overflow() {
let mut data = build_snod(&[], 8);
// num_symbols at offset 6..8 — set to max to blow up entries_start + num_symbols*entry_size
data[6] = 0xFF;
data[7] = 0xFF;
let result = SymbolTableNode::parse(&data, usize::MAX / 2, 8);
assert!(result.is_err());
}
}
+51 -1
View File
@@ -279,6 +279,43 @@ pub(crate) fn build_attr_message(name: &str, value: &AttrValue) -> AttributeMess
dataspace: scalar_ds(),
raw_data: v.to_le_bytes().to_vec(),
},
AttrValue::U64Array(arr) => {
let mut raw = Vec::with_capacity(arr.len() * 8);
for v in arr {
raw.extend_from_slice(&v.to_le_bytes());
}
AttributeMessage {
name: name.to_string(),
datatype: Datatype::FixedPoint {
size: 8,
byte_order: DatatypeByteOrder::LittleEndian,
signed: false,
bit_offset: 0,
bit_precision: 64,
},
dataspace: simple_1d(arr.len() as u64),
raw_data: raw,
}
}
AttrValue::Raw {
datatype,
shape,
data,
} => AttributeMessage {
name: name.to_string(),
datatype: datatype.clone(),
dataspace: if shape.is_empty() {
scalar_ds()
} else {
Dataspace {
space_type: DataspaceType::Simple,
rank: shape.len() as u8,
dimensions: shape.clone(),
max_dimensions: None,
}
},
raw_data: data.clone(),
},
AttrValue::String(s) => {
let bytes = s.as_bytes();
AttributeMessage {
@@ -334,7 +371,7 @@ pub(crate) fn simple_1d(n: u64) -> Dataspace {
// ---- Attribute values ----
/// Convenient attribute values for the write API.
/// Attribute values, for both the write API and what reading returns.
#[derive(Debug, Clone)]
pub enum AttrValue {
F64(f64),
@@ -342,8 +379,21 @@ pub enum AttrValue {
I64(i64),
I64Array(Vec<i64>),
U64(u64),
/// Unsigned integers, kept unsigned so values above `i64::MAX` survive.
U64Array(Vec<u64>),
String(String),
StringArray(Vec<String>),
/// An attribute whose datatype has no dedicated variant above (compound,
/// general enum, complex, reference, opaque, array, ...), carried verbatim
/// so it is never silently lost: the datatype, the dataspace dimensions
/// (empty for a scalar) and the element bytes exactly as stored. Decode
/// `data` with `clawhdf5_format::data_read` (e.g. `read_compound_fields`)
/// against `datatype`. Writing a `Raw` value stores it back unchanged.
Raw {
datatype: Datatype,
shape: Vec<u64>,
data: Vec<u8>,
},
}
// ---- Dataset builder ----
+44
View File
@@ -0,0 +1,44 @@
"""Generate std_ref_hdf5_2_0.h5: a dataset of H5T_STD_REF (the reference
datatype introduced in HDF5 1.12, datatype message version 4) holding two
object references to /target (a dataset) and /grp (a group).
h5py has no API for this type, so the file is written by calling the libhdf5
bundled in the h5py wheel directly through ctypes. Written with h5py 3.16.0 /
HDF5 2.0.0. Re-run only if the fixture ever needs regenerating:
python gen_std_ref.py std_ref_hdf5_2_0.h5
"""
import ctypes
import glob
import os
import sys
import h5py
import numpy as np
libdir = os.path.join(os.path.dirname(os.path.dirname(h5py.__file__)), "h5py.libs")
libs = [p for p in glob.glob(os.path.join(libdir, "libhdf5*.so*")) if "_hl" not in os.path.basename(p)]
lib = ctypes.CDLL(libs[0])
lib.H5open()
hid = ctypes.c_int64
std_ref = hid.in_dll(lib, "H5T_STD_REF_g").value
lib.H5Screate_simple.restype = hid
lib.H5Screate_simple.argtypes = [ctypes.c_int, ctypes.POINTER(ctypes.c_uint64), ctypes.POINTER(ctypes.c_uint64)]
lib.H5Dcreate2.restype = hid
lib.H5Dcreate2.argtypes = [hid, ctypes.c_char_p, hid, hid, hid, hid, hid]
lib.H5Rcreate_object.argtypes = [hid, ctypes.c_char_p, hid, ctypes.c_void_p]
lib.H5Dwrite.argtypes = [hid, hid, hid, hid, hid, ctypes.c_void_p]
lib.H5Dclose.argtypes = [hid]
with h5py.File(sys.argv[1], "w", libver="latest") as f:
f.create_dataset("target", data=np.arange(5, dtype="<i4"))
f.create_group("grp")
fid = f.id.id
sid = lib.H5Screate_simple(1, (ctypes.c_uint64 * 1)(2), None)
did = lib.H5Dcreate2(fid, b"refs", std_ref, sid, 0, 0, 0)
refs = ((ctypes.c_ubyte * 64) * 2)() # H5R_ref_t is a 64-byte buffer
assert lib.H5Rcreate_object(fid, b"/target", 0, ctypes.byref(refs[0])) == 0
assert lib.H5Rcreate_object(fid, b"/grp", 0, ctypes.byref(refs[1])) == 0
assert lib.H5Dwrite(did, std_ref, 0, 0, 0, ctypes.byref(refs)) == 0
lib.H5Dclose(did)
Binary file not shown.
@@ -343,7 +343,11 @@ fn attrs_h5_dataset_scale() {
let scale_attr = find_attribute(&attrs, "scale").expect("scale attr not found");
let vals = scale_attr.read_as_f64().unwrap();
assert_eq!(vals.len(), 1);
assert!((vals[0] - 3.14).abs() < 1e-10);
// 3.14 here is the literal value baked into the binary fixture (fixtures/attrs.h5),
// not an arbitrary sample value, so it cannot be swapped for another constant.
#[allow(clippy::approx_constant)]
let expected = 3.14;
assert!((vals[0] - expected).abs() < 1e-10);
}
#[test]
@@ -556,8 +560,8 @@ fn chunked_deflate_read_values() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
let values = read_as_f64(&raw, &datatype).unwrap();
assert_eq!(values.len(), 100);
for i in 0..100 {
assert_eq!(values[i], i as f64, "mismatch at index {i}");
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as f64, "mismatch at index {i}");
}
}
@@ -567,8 +571,8 @@ fn chunked_shuffle_deflate_read_values() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
let values = read_as_f64(&raw, &datatype).unwrap();
assert_eq!(values.len(), 100);
for i in 0..100 {
assert_eq!(values[i], i as f64, "mismatch at index {i}");
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as f64, "mismatch at index {i}");
}
}
@@ -578,8 +582,8 @@ fn chunked_fletcher32_read_values() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
let values = read_as_f64(&raw, &datatype).unwrap();
assert_eq!(values.len(), 100);
for i in 0..100 {
assert_eq!(values[i], i as f64, "mismatch at index {i}");
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as f64, "mismatch at index {i}");
}
}
@@ -589,11 +593,10 @@ fn chunked_2d_read_values() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "matrix");
let values = read_as_f32(&raw, &datatype).unwrap();
assert_eq!(values.len(), 60);
for i in 0..60 {
for (i, &v) in values.iter().enumerate() {
assert!(
(values[i] - i as f32).abs() < 1e-6,
"mismatch at index {i}: got {}",
values[i]
(v - i as f32).abs() < 1e-6,
"mismatch at index {i}: got {v}"
);
}
}
@@ -604,8 +607,8 @@ fn chunked_large_read_values() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "big");
let values = read_as_i32(&raw, &datatype).unwrap();
assert_eq!(values.len(), 1000);
for i in 0..1000 {
assert_eq!(values[i], i as i32, "mismatch at index {i}");
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as i32, "mismatch at index {i}");
}
}
@@ -615,8 +618,8 @@ fn chunked_nofilter_read_values() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "raw");
let values = read_as_f64(&raw, &datatype).unwrap();
assert_eq!(values.len(), 50);
for i in 0..50 {
assert_eq!(values[i], i as f64, "mismatch at index {i}");
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as f64, "mismatch at index {i}");
}
}
@@ -646,8 +649,8 @@ fn v4_implicit_read() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
let values = read_as_f64(&raw, &datatype).unwrap();
assert_eq!(values.len(), 100);
for i in 0..100 {
assert_eq!(values[i], i as f64, "mismatch at index {i}");
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as f64, "mismatch at index {i}");
}
}
@@ -657,8 +660,8 @@ fn v4_fixed_array_read() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
let values = read_as_f64(&raw, &datatype).unwrap();
assert_eq!(values.len(), 100);
for i in 0..100 {
assert_eq!(values[i], i as f64, "mismatch at index {i}");
for (i, &v) in values.iter().enumerate() {
assert_eq!(v, i as f64, "mismatch at index {i}");
}
}
@@ -871,11 +874,10 @@ fn v4_2d_fixed_array_read() {
let (raw, datatype, _) = read_chunked_dataset(file_data, "matrix");
let values = read_as_f32(&raw, &datatype).unwrap();
assert_eq!(values.len(), 60);
for i in 0..60 {
for (i, &v) in values.iter().enumerate() {
assert!(
(values[i] - i as f32).abs() < 1e-6,
"mismatch at index {i}: got {}",
values[i]
(v - i as f32).abs() < 1e-6,
"mismatch at index {i}: got {v}"
);
}
}
@@ -1272,7 +1274,7 @@ fn write_roundtrip_scalar_f64_attr() {
let mut fw = FileWriter::new();
fw.create_dataset("data")
.with_f64_data(&[1.0])
.set_attr("scale", AttrValue::F64(3.14));
.set_attr("scale", AttrValue::F64(3.25));
let bytes = fw.finish().unwrap();
let sig = find_signature(&bytes).unwrap();
@@ -1283,7 +1285,7 @@ fn write_roundtrip_scalar_f64_attr() {
let scale = find_attribute(&attrs, "scale").expect("scale attr not found");
let vals = scale.read_as_f64().unwrap();
assert_eq!(vals.len(), 1);
assert!((vals[0] - 3.14).abs() < 1e-10);
assert!((vals[0] - 3.25).abs() < 1e-10);
}
#[test]
+113 -5
View File
@@ -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,22 +182,27 @@ print('ok')
"#,
path.display()
);
let output = std::process::Command::new("python3")
let output = std::process::Command::new(python())
.args(["-c", &script])
.output();
let output = match output {
Ok(o) if o.status.success() => o,
_ => {
// CI sets CLAWHDF5_REQUIRE_INTEROP=1 so this can't silently skip.
assert!(
!std::env::var("CLAWHDF5_REQUIRE_INTEROP").is_ok_and(|v| v == "1"),
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
);
eprintln!("skipping h5py_object_reference_roundtrip: python3+h5py not available");
return;
}
};
let stdout = String::from_utf8(output.stdout).unwrap();
if !stdout.trim().contains("ok") {
eprintln!("skipping h5py_object_reference_roundtrip: h5py script failed");
return;
}
assert!(
stdout.trim().contains("ok"),
"h5py reference-file generator did not report ok: {stdout}"
);
// Read the file and parse object references
let file_data = std::fs::read(&path).unwrap();
@@ -311,3 +325,97 @@ print('ok')
// Clean up
let _ = std::fs::remove_file(&path);
}
// ---------------------------------------------------------------------------
// H5T_STD_REF (HDF5 1.12+ references, datatype message version 4)
// ---------------------------------------------------------------------------
/// `fixtures/std_ref_hdf5_2_0.h5` (see `gen_std_ref.py`) holds a dataset of
/// `H5T_STD_REF` with two object references, written by HDF5 2.0 itself. The
/// datatype used to be rejected with `InvalidReferenceType(2)`.
#[test]
fn std_ref_object_references_from_hdf5_2_0() {
use clawhdf5_format::data_layout::DataLayout;
use clawhdf5_format::dataspace::Dataspace;
use clawhdf5_format::group_v2::resolve_path_any;
use clawhdf5_format::message_type::MessageType;
use clawhdf5_format::object_header::ObjectHeader;
use clawhdf5_format::signature::find_signature;
use clawhdf5_format::superblock::Superblock;
let bytes: &[u8] = include_bytes!("fixtures/std_ref_hdf5_2_0.h5");
let sb = Superblock::parse(bytes, find_signature(bytes).unwrap()).unwrap();
let (os, ls) = (sb.offset_size, sb.length_size);
let refs_addr = resolve_path_any(bytes, &sb, "refs").unwrap();
let header = ObjectHeader::parse(bytes, refs_addr as usize, os, ls).unwrap();
let message = |t: MessageType| {
&header
.messages
.iter()
.find(|m| m.msg_type == t)
.unwrap()
.data
};
let (datatype, _) = Datatype::parse(message(MessageType::Datatype)).unwrap();
assert_eq!(
datatype,
Datatype::Reference {
size: 18,
ref_type: ReferenceType::Object2
}
);
let dataspace = Dataspace::parse(message(MessageType::Dataspace), ls).unwrap();
let layout = DataLayout::parse(message(MessageType::DataLayout), os, ls).unwrap();
let raw =
clawhdf5_format::data_read::read_raw_data(bytes, &layout, &dataspace, &datatype).unwrap();
assert_eq!(raw.len(), 2 * 18);
// The references point at the objects they were created from.
let refs = read_object_references(&raw, &datatype, os).unwrap();
let addresses: Vec<u64> = refs.iter().map(|r| r.address).collect();
assert_eq!(
addresses,
[
resolve_path_any(bytes, &sb, "target").unwrap(),
resolve_path_any(bytes, &sb, "grp").unwrap(),
]
);
// And what they point at is a real object header.
for address in addresses {
ObjectHeader::parse(bytes, address as usize, os, ls).unwrap();
}
}
#[test]
fn std_ref_decoding_rejects_malformed_elements() {
let dt = Datatype::Reference {
size: 18,
ref_type: ReferenceType::Object2,
};
let mut good = vec![0u8; 18];
good[..4].copy_from_slice(&[2, 0, 8, 0xb3]);
assert_eq!(
read_object_references(&good, &dt, 8).unwrap()[0].address,
0xb3
);
// Null reference.
assert_eq!(
read_object_references(&[0u8; 18], &dt, 8).unwrap()[0].address,
u64::MAX
);
for (what, patch) in [
("wrong reference type", (0usize, 3u8)),
("external flag", (1, 1)),
("token longer than the element", (2, 200)),
("zero-length token", (2, 0)),
] {
let mut bad = good.clone();
bad[patch.0] = patch.1;
assert!(read_object_references(&bad, &dt, 8).is_err(), "{what}");
}
// Not a whole number of elements.
assert!(read_object_references(&good[..17], &dt, 8).is_err());
}
@@ -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));
}
@@ -235,17 +244,31 @@ fn h5py_reads_our_array_dataset() {
#[test]
#[ignore = "requires Python h5py module"]
fn read_h5py_generated_compound() {
let path = std::env::temp_dir().join("clawhdf5_h5py_compound.h5");
check_h5py_generated_compound("latest", ", libver='latest'");
}
/// Same file written with h5py's default format bounds. HDF5 2.0 raised the
/// default low bound to 1.8, so "default" files exercise different on-disk
/// structures than both `libver='latest'` and pre-2.0 defaults.
#[test]
#[ignore = "requires Python h5py module"]
fn read_h5py_generated_compound_default_libver() {
check_h5py_generated_compound("default", "");
}
fn check_h5py_generated_compound(tag: &str, libver_kw: &str) {
let path = std::env::temp_dir().join(format!("clawhdf5_h5py_compound_{tag}.h5"));
let gen_script = format!(
r#"
import h5py, numpy as np
dt = np.dtype([('x', 'f8'), ('y', 'f8'), ('id', 'i4')])
data = np.array([(1.0, 2.0, 10), (3.0, 4.0, 20)], dtype=dt)
f = h5py.File('{}', 'w', libver='latest')
f = h5py.File('{}', 'w'{})
f.create_dataset('particles', data=data)
f.close()
"#,
path.display()
path.display(),
libver_kw
);
h5py_read(&path, &gen_script);
@@ -292,20 +315,102 @@ f.close()
assert_eq!(x_vals, vec![1.0, 3.0]);
}
#[test]
#[ignore = "requires Python h5py module"]
fn read_h5py_generated_native_complex() {
// HDF5 2.0 native complex (datatype class 11, version 5), written through
// h5py's low-level API. Skips when the linked HDF5 predates 2.0.
let path = std::env::temp_dir().join("clawhdf5_h5py_native_complex.h5");
let gen_script = format!(
r#"
import h5py, numpy as np
from h5py import h5t, h5s, h5d, h5f, h5p
if not getattr(h5py.get_config(), 'has_native_complex', False):
print('SKIP')
else:
fapl = h5p.create(h5p.FILE_ACCESS)
fapl.set_libver_bounds(h5f.LIBVER_LATEST, h5f.LIBVER_LATEST)
fid = h5f.create(b'{}', h5f.ACC_TRUNC, fapl=fapl)
t = h5t.COMPLEX_IEEE_F64LE
d = h5d.create(fid, b'z', t, h5s.create_simple((2,)))
d.write(h5s.ALL, h5s.ALL, np.array([1+2j, 3+4j], dtype=np.complex128), mtype=t)
fid.close()
"#,
path.display()
);
if h5py_read(&path, &gen_script) == "SKIP" {
eprintln!("HDF5 < 2.0: no native complex support, skipping");
return;
}
let bytes = std::fs::read(&path).unwrap();
let sig = clawhdf5_format::signature::find_signature(&bytes).unwrap();
let sb = clawhdf5_format::superblock::Superblock::parse(&bytes, sig).unwrap();
let addr = clawhdf5_format::group_v2::resolve_path_any(&bytes, &sb, "z").unwrap();
let hdr = clawhdf5_format::object_header::ObjectHeader::parse(
&bytes,
addr as usize,
sb.offset_size,
sb.length_size,
)
.unwrap();
let msg = |t: clawhdf5_format::message_type::MessageType| {
&hdr.messages.iter().find(|m| m.msg_type == t).unwrap().data
};
let (dt, _) = clawhdf5_format::datatype::Datatype::parse(msg(
clawhdf5_format::message_type::MessageType::Datatype,
))
.unwrap();
let ds = clawhdf5_format::dataspace::Dataspace::parse(
msg(clawhdf5_format::message_type::MessageType::Dataspace),
sb.length_size,
)
.unwrap();
let dl = clawhdf5_format::data_layout::DataLayout::parse(
msg(clawhdf5_format::message_type::MessageType::DataLayout),
sb.offset_size,
sb.length_size,
)
.unwrap();
let raw = clawhdf5_format::data_read::read_raw_data(&bytes, &dl, &ds, &dt).unwrap();
let fields = clawhdf5_format::data_read::read_compound_fields(&raw, &dt).unwrap();
assert_eq!(fields.len(), 2);
let re =
clawhdf5_format::data_read::read_as_f64(&fields[0].raw_data, &fields[0].datatype).unwrap();
let im =
clawhdf5_format::data_read::read_as_f64(&fields[1].raw_data, &fields[1].datatype).unwrap();
assert_eq!((fields[0].name.as_str(), re), ("r", vec![1.0, 3.0]));
assert_eq!((fields[1].name.as_str(), im), ("i", vec![2.0, 4.0]));
}
#[test]
#[ignore = "requires Python h5py module"]
fn read_h5py_generated_enum() {
let path = std::env::temp_dir().join("clawhdf5_h5py_enum.h5");
check_h5py_generated_enum("latest", ", libver='latest'");
}
/// Same file written with h5py's default format bounds. HDF5 2.0 raised the
/// default low bound to 1.8, so "default" files exercise different on-disk
/// structures than both `libver='latest'` and pre-2.0 defaults.
#[test]
#[ignore = "requires Python h5py module"]
fn read_h5py_generated_enum_default_libver() {
check_h5py_generated_enum("default", "");
}
fn check_h5py_generated_enum(tag: &str, libver_kw: &str) {
let path = std::env::temp_dir().join(format!("clawhdf5_h5py_enum_{tag}.h5"));
let gen_script = format!(
r#"
import h5py, numpy as np
dt = h5py.enum_dtype({{"RED": 0, "GREEN": 1, "BLUE": 2}}, basetype=np.int32)
data = np.array([1, 0, 2, 1], dtype=np.int32)
f = h5py.File('{}', 'w', libver='latest')
f = h5py.File('{}', 'w'{})
f.create_dataset('colors', data=data, dtype=dt)
f.close()
"#,
path.display()
path.display(),
libver_kw
);
h5py_read(&path, &gen_script);
+2 -2
View File
@@ -1,10 +1,10 @@
[package]
name = "clawhdf5-gpu"
version = "2.1.0"
version = "2.6.0"
edition = "2024"
description = "GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders"
license = "MIT"
repository = "https://github.com/redclawsystems/clawhdf5"
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
readme = "README.md"
keywords = ["hdf5", "gpu", "wgpu", "compute"]
categories = ["science", "graphics"]
+6 -1
View File
@@ -6,6 +6,9 @@ use crate::shaders;
use bytemuck::Pod;
use wgpu::util::DeviceExt;
/// Upper bound on a single GPU→CPU readback wait.
const READBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// GPU-accelerated vector search engine.
///
/// Upload vectors once, then run many searches against them.
@@ -1033,10 +1036,12 @@ impl GpuAccelerator {
slice.map_async(wgpu::MapMode::Read, move |result| {
let _ = tx.send(result);
});
// Bounded wait: a wedged driver must surface as an error, not hang
// the caller forever.
self.device
.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
timeout: Some(READBACK_TIMEOUT),
})
.map_err(|e| GpuError::BufferMap(format!("device poll failed: {e}")))?;
rx.recv()
+36 -2
View File
@@ -6,9 +6,41 @@
mod tests {
use clawhdf5_gpu::{GpuAccelerator, GpuError};
fn skip_if_no_gpu() -> Option<GpuAccelerator> {
/// Serialises GPU access across tests. The harness runs tests on many
/// threads; letting each create its own wgpu instance + device (with
/// adapter-maximum limits) at the same time can wedge the driver and hang
/// the whole suite, so every test holds this lock while it owns a device.
static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn gpu_lock() -> std::sync::MutexGuard<'static, ()> {
// A panicking test poisons the lock; the guarded state is `()`.
GPU_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// A `GpuAccelerator` plus the lock that keeps other tests off the GPU.
/// Field order matters: the device is dropped before the lock is released.
struct LockedGpu {
gpu: GpuAccelerator,
_guard: std::sync::MutexGuard<'static, ()>,
}
impl std::ops::Deref for LockedGpu {
type Target = GpuAccelerator;
fn deref(&self) -> &GpuAccelerator {
&self.gpu
}
}
impl std::ops::DerefMut for LockedGpu {
fn deref_mut(&mut self) -> &mut GpuAccelerator {
&mut self.gpu
}
}
fn skip_if_no_gpu() -> Option<LockedGpu> {
let guard = gpu_lock();
match GpuAccelerator::new() {
Ok(gpu) => Some(gpu),
Ok(gpu) => Some(LockedGpu { gpu, _guard: guard }),
Err(_) => {
eprintln!("SKIPPED: no GPU available");
None
@@ -69,6 +101,7 @@ mod tests {
#[test]
fn test_gpu_availability_detection() {
// Should not panic regardless of GPU presence
let _guard = gpu_lock();
let available = GpuAccelerator::is_available();
eprintln!("GPU available: {available}");
}
@@ -425,6 +458,7 @@ mod tests {
#[test]
fn test_graceful_no_gpu_fallback() {
// This test just demonstrates the pattern — it always passes
let _guard = gpu_lock();
match GpuAccelerator::new() {
Ok(gpu) => {
eprintln!("GPU found: {}", gpu.device_info());
+3 -3
View File
@@ -1,16 +1,16 @@
[package]
name = "clawhdf5-io"
version = "2.1.0"
version = "2.6.0"
edition = "2024"
description = "I/O abstraction layer for rustyhdf5"
license = "MIT"
repository = "https://github.com/redclawsystems/clawhdf5"
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
readme = "README.md"
keywords = ["hdf5", "io", "science", "data"]
categories = ["filesystem", "science"]
[dependencies]
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
memmap2 = { version = "0.9", optional = true }
libc = { version = "0.2", optional = true }
tokio = { version = "1", features = ["fs", "io-util"], optional = true }
+24 -8
View File
@@ -59,11 +59,16 @@ pub trait AsyncHDF5Read: Send + Sync {
/// Async file-backed reader using tokio for non-blocking I/O.
///
/// Opens a file and reads it asynchronously. The file is read into memory
/// on first access, making subsequent operations fast.
/// Opens a file and reads it asynchronously. The underlying file handle is
/// opened once (lazily, on first access) and cached for the lifetime of this
/// reader, so repeated granular `read_at` calls reuse the open descriptor
/// and cached length instead of paying an open+stat syscall pair every time.
/// The handle is guarded by a mutex, which also correctly serializes the
/// seek-then-read pairs of concurrent callers sharing the one file position.
#[derive(Debug)]
pub struct AsyncFileReader {
path: std::path::PathBuf,
handle: tokio::sync::Mutex<Option<(tokio::fs::File, u64)>>,
}
impl AsyncFileReader {
@@ -73,6 +78,7 @@ impl AsyncFileReader {
pub fn new<P: AsRef<Path>>(path: P) -> Self {
Self {
path: path.as_ref().to_path_buf(),
handle: tokio::sync::Mutex::new(None),
}
}
@@ -89,23 +95,33 @@ impl AsyncFileReader {
impl AsyncHDF5Read for AsyncFileReader {
async fn read_at(&self, offset: u64, len: usize) -> io::Result<Vec<u8>> {
let mut file = tokio::fs::File::open(&self.path).await?;
let metadata = file.metadata().await?;
let file_len = metadata.len();
let mut guard = self.handle.lock().await;
if guard.is_none() {
let file = tokio::fs::File::open(&self.path).await?;
let file_len = file.metadata().await?.len();
*guard = Some((file, file_len));
}
let (file, file_len) = guard.as_mut().expect("just populated above");
let file_len = *file_len;
if offset >= file_len {
return Ok(Vec::new());
}
let available = (file_len - offset) as usize;
let to_read = len.min(available);
tokio::io::AsyncSeekExt::seek(&mut file, io::SeekFrom::Start(offset)).await?;
tokio::io::AsyncSeekExt::seek(file, io::SeekFrom::Start(offset)).await?;
let mut buf = vec![0u8; to_read];
file.read_exact(&mut buf).await?;
Ok(buf)
}
async fn len(&self) -> io::Result<u64> {
let metadata = tokio::fs::metadata(&self.path).await?;
Ok(metadata.len())
let mut guard = self.handle.lock().await;
if guard.is_none() {
let file = tokio::fs::File::open(&self.path).await?;
let file_len = file.metadata().await?.len();
*guard = Some((file, file_len));
}
Ok(guard.as_ref().expect("just populated above").1)
}
}
+5 -5
View File
@@ -1,10 +1,10 @@
[package]
name = "clawhdf5-migrate"
version = "2.1.0"
version = "2.6.0"
edition = "2024"
description = "CLI to migrate SQLite agent memory databases to HDF5 format"
license = "MIT"
repository = "https://github.com/redclawsystems/clawhdf5"
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
readme = "README.md"
keywords = ["sqlite", "hdf5", "migration", "agent", "memory"]
categories = ["command-line-utilities", "database"]
@@ -14,9 +14,9 @@ name = "clawhdf5-migrate"
path = "src/main.rs"
[dependencies]
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.1.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0" }
clawhdf5 = { path = "../clawhdf5", version = "2.1.0" }
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.6.0" }
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
clawhdf5 = { path = "../clawhdf5", version = "2.6.0" }
rusqlite = { version = "0.31", features = ["bundled"] }
clap = { version = "4", features = ["derive"] }
half = { workspace = true }
@@ -49,6 +49,10 @@ pub fn read_hdf5(path: &str) -> Result<SqliteData, BoxErr> {
entities,
relations,
embedding_dim,
// Not a SQLite read — the caller (incremental migration) carries
// forward the current run's actual `source_path` from the fresh
// SQLite read instead of using this placeholder.
source_path: String::new(),
})
}

Some files were not shown because too many files have changed in this diff Show More