Compare commits
69
Commits
55959b4920
...
v2.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3ad548f84 | ||
|
|
0876796432 | ||
|
|
91d46a3813 | ||
|
|
97ab658c11 | ||
|
|
5dd95a6cf8 | ||
|
|
a0ff8ef32c | ||
|
|
24afcdc70f | ||
|
|
8f62cb44e0 | ||
|
|
e38c8133bc | ||
|
|
12847c6c66 | ||
|
|
81e8294048 | ||
|
|
0eca8574f5 | ||
|
|
005f37e846 | ||
|
|
bf8bbec87e | ||
|
|
6e84f31ed6 | ||
|
|
3ed0489faa | ||
|
|
0744d52639 | ||
|
|
99b907be04 | ||
|
|
4f2975d7e3 | ||
|
|
d4f2d3e7b5 | ||
|
|
6848494647 | ||
|
|
943b9141e3 | ||
|
|
a9f78ca5a1 | ||
|
|
a3f7c6fe89 | ||
|
|
bbe1baa208 | ||
|
|
706189c3ef | ||
|
|
926dc457e0 | ||
|
|
a8ab9ca054 | ||
|
|
2053b69f07 | ||
|
|
b55b7dbac5 | ||
|
|
48c745a960 | ||
|
|
377c8b6f17 | ||
|
|
07b7301ded | ||
|
|
d3c65ccb58 | ||
|
|
c137302f04 | ||
|
|
f23363cde5 | ||
|
|
3a30327f35 | ||
|
|
5db1008eb7 | ||
|
|
ab283d2759 | ||
|
|
18ac510c29 | ||
|
|
3c7c229e20 | ||
|
|
2e8414e412 | ||
|
|
45a38ba260 | ||
|
|
1efd82c841 | ||
|
|
4051d5c16e | ||
|
|
934d053f92 | ||
|
|
603fcf8757 | ||
|
|
d787ac04c8 | ||
|
|
55c3737130 | ||
|
|
7314971fe7 | ||
|
|
864faf3656 | ||
|
|
73bc067fea | ||
|
|
122849b5a9 | ||
|
|
b08df7b628 | ||
|
|
b2dce41532 | ||
|
|
1537a9464a | ||
|
|
12d9d8462f | ||
|
|
c913cd1cbf | ||
|
|
7d6e269bf3 | ||
|
|
6f5940d042 | ||
|
|
dfae9e2cc1 | ||
|
|
429c29b76b | ||
|
|
40527be653 | ||
|
|
2013fa94a0 | ||
|
|
a3e1cf8588 | ||
|
|
534331ffbe | ||
|
|
297ee5ec17 | ||
|
|
a319405ffc | ||
|
|
62595d5ac0 |
@@ -22,5 +22,19 @@ jobs:
|
||||
run: rustup component add rustfmt clippy
|
||||
- name: Install thumbv7em-none-eabihf target
|
||||
run: rustup target add thumbv7em-none-eabihf
|
||||
- name: Install Python interop dependencies
|
||||
# The interop suites used to skip silently when python3/h5py were
|
||||
# missing, so they never ran in CI. Install them and make a missing
|
||||
# dependency a failure (CLAWHDF5_REQUIRE_INTEROP below).
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends python3 python3-venv
|
||||
python3 -m venv /opt/interop
|
||||
/opt/interop/bin/pip install --no-cache-dir h5py numpy netCDF4 xarray
|
||||
echo "/opt/interop/bin" >> "$GITHUB_PATH"
|
||||
- name: Show interop library versions
|
||||
run: python3 -c "import h5py, netCDF4; print('h5py', h5py.__version__, 'HDF5', h5py.version.hdf5_version, 'netCDF4', netCDF4.__version__)"
|
||||
- name: Run CI script
|
||||
env:
|
||||
CLAWHDF5_REQUIRE_INTEROP: "1"
|
||||
run: bash scripts/ci-test.sh
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
benchmarks/longmemeval/*.json
|
||||
|
||||
# Local model weights (MiniLM etc.) — large, not committed
|
||||
weights/
|
||||
|
||||
+371
-33
@@ -6,6 +6,26 @@
|
||||
**Rust:** 1.96.0-nightly (2026-03-14) · `--release` profile
|
||||
**Date:** 2026-07-01
|
||||
|
||||
> **Traceability note:** the "h5bench-Equivalent I/O Benchmarks" and both
|
||||
> "Independent Validation: tank" sections below meet a dated,
|
||||
> hardware-cited, reproducible standard (explicit date, machine spec, and a
|
||||
> runnable command per result) — this now covers "LongMemEval Results",
|
||||
> "SIMD & Parallelism", "Vector Search Latency", and "Comparison to MemX" via
|
||||
> their tank re-runs. The remaining undated sections above (Hybrid Search,
|
||||
> Knowledge Graph, Memory Consolidation, Temporal Index, Write Path, Decision
|
||||
> Gate, Memory Strategy, Multi-Session Benchmark, Memory Footprint,
|
||||
> Consolidation Efficiency, Ephemeral Tier) do not yet meet that bar — this is
|
||||
> a known, tracked documentation gap, not a claim that those numbers are wrong.
|
||||
>
|
||||
> **Correctness note (2026-08-06).** Being dated and reproducible is necessary but
|
||||
> not sufficient — a number can be perfectly reproducible and still measure the
|
||||
> wrong thing. A methodology audit found two such cases and both have been
|
||||
> retracted in place: the session-level LongMemEval figures (degenerate on the
|
||||
> oracle variant) and the MemX retrieval comparison (mismatched granularity and
|
||||
> corpus). Every cross-system comparison in this file now carries an explicit
|
||||
> scoping caveat. Where a section states a scoring target, that declaration is the
|
||||
> contract — read it before citing the number.
|
||||
|
||||
---
|
||||
|
||||
## Vector Search Latency
|
||||
@@ -24,10 +44,19 @@ Brute-force cosine similarity over 384-dimensional embeddings (OpenAI text-embed
|
||||
|
||||
MemX claims end-to-end search under 90ms at 100K records (Rust + libSQL + FTS5).
|
||||
|
||||
| Metric | MemX (claimed) | ClawhDF5 | Speedup |
|
||||
|--------|----------------|----------|---------|
|
||||
| 100K flat search | <90 ms | 11.4 ms | **~8x** |
|
||||
| 100K IVF-PQ search | — | 1.19 ms | **~76x** |
|
||||
> **Caveat — not like-for-like.** MemX's `<90 ms` is *end-to-end* search across their
|
||||
> full pipeline (dense embeddings + FTS5 + four-factor re-ranking). The clawhdf5
|
||||
> figures below are a *single component* — raw vector search latency, excluding
|
||||
> embedding, keyword, fusion, and re-ranking stages. A component measured against a
|
||||
> full pipeline will always look favourable; the "speedup" column overstates the real
|
||||
> advantage by an unquantified margin and should be read as an order-of-magnitude
|
||||
> indication only, not a benchmark result. Matching MemX's measurement boundary is
|
||||
> tracked as follow-up work.
|
||||
|
||||
| Metric | MemX (claimed, end-to-end) | ClawhDF5 (component only) | Ratio |
|
||||
|--------|----------------------------|---------------------------|-------|
|
||||
| 100K flat search | <90 ms | 11.4 ms | ~8x |
|
||||
| 100K IVF-PQ search | — | 1.19 ms | ~76x |
|
||||
| Keyword search 10K | 1,100x improvement over unindexed | 583 µs (BM25) | Comparable |
|
||||
|
||||
---
|
||||
@@ -174,46 +203,193 @@ _Latency benchmarks generated with Criterion.rs (50-100 samples per benchmark).
|
||||
|
||||
## LongMemEval Results
|
||||
|
||||
**Dataset:** LongMemEval oracle (500 questions, 6 question types, variable-length chat histories)
|
||||
> **Scoring target declaration.** Per [arXiv 2605.24060](https://arxiv.org/abs/2605.24060),
|
||||
> which found that changing scoring target alone alters nDCG on 83–94% of queries and
|
||||
> can reverse system rankings, this section states its measurement contract explicitly:
|
||||
>
|
||||
> - **Dataset variant:** both are now reported below — the full `longmemeval_s`
|
||||
> haystack (**the headline number**) and `longmemeval_oracle` (evidence sessions
|
||||
> only, a substantially easier corpus, kept for continuity). The harness does not
|
||||
> trust the filename: it measures evidence-session density from the data and
|
||||
> labels the run from that, so a mislabelled input cannot yield a mislabelled
|
||||
> result. Measured density is 4.0% on `longmemeval_s` and 100.0% on the oracle.
|
||||
> - **Metric:** *retrieval recall.* A "hit" means the gold-labelled memory appeared in
|
||||
> the top-k. **No answer is generated and none is scored** — the dataset's `answer`
|
||||
> field is deserialized and never read. This is **not** the official LongMemEval
|
||||
> leaderboard metric, which is end-to-end QA accuracy (retrieve → generate → LLM
|
||||
> judge). Retrieval recall reported as QA accuracy typically overstates by 20–30 points.
|
||||
> - **Granularity:** turn-level = the returned memory's source turn had `has_answer == true`.
|
||||
> - **k = 10**, n = 500.
|
||||
> - **Retrieval mode:** all three are reported below. Historically the bench passed
|
||||
> zero-vector embeddings with `vector_weight=0.0`, so the HNSW/vector stage was
|
||||
> inert and every published number was BM25 alone. Real `all-MiniLM-L6-v2`
|
||||
> embeddings are now available via `--features embeddings --embeddings <dir>`,
|
||||
> and BM25-only / vector-only / hybrid are each measured separately.
|
||||
|
||||
**Mode:** BM25-only retrieval — zero embeddings, `vector_weight=0.0`, `keyword_weight=1.0`
|
||||
**Reference:** MemX (arxiv:2603.16171) with full embedding system: Hit@5=51.6%, MRR=0.380
|
||||
|
||||
> **Run:** `cargo run --release --bin longmemeval_bench`
|
||||
> **Run:** `cargo run --release --bin longmemeval_bench -- benchmarks/longmemeval/longmemeval_s_cleaned.json`
|
||||
> (~70 s for all 500 questions on the tank reference machine). Omit the path for the
|
||||
> oracle variant; add `--limit N` for an evenly-strided subsample.
|
||||
|
||||
### Session-Level Recall (n=500)
|
||||
### Full haystack — `longmemeval_s`, n=500 (the number to cite)
|
||||
|
||||
| Metric | ClawhDF5 (BM25-only) |
|
||||
|--------|---------------------|
|
||||
| Hit@1 | **100.0%** |
|
||||
| Hit@5 | **100.0%** |
|
||||
| Hit@10 | **100.0%** |
|
||||
| MRR | **1.0000** |
|
||||
47.7 sessions and 493.5 turns per question; 4.0% of haystack sessions are evidence
|
||||
sessions, so retrieval has to actually discriminate.
|
||||
|
||||
Perfect session-level recall across all 500 questions and all 6 question types.
|
||||
| Metric | Turn-level | Session-level |
|
||||
|--------|-----------|---------------|
|
||||
| Hit@1 | 53.8% | 86.2% |
|
||||
| Hit@5 | **75.0%** | **93.6%** |
|
||||
| Hit@10 | 81.6% | 96.6% |
|
||||
| MRR | 0.6320 | 0.8948 |
|
||||
|
||||
### Turn-Level Recall (n=500)
|
||||
Session-level is reported here because on this corpus it is meaningful — unlike on
|
||||
the oracle variant, where it was degenerate and was retracted (below). At 4.0%
|
||||
evidence density a session-level hit reflects discrimination rather than corpus
|
||||
shape.
|
||||
|
||||
| Metric | ClawhDF5 (BM25-only) | MemX (full system)¹ |
|
||||
|--------|---------------------|---------------------|
|
||||
| Hit@1 | **52.6%** | — |
|
||||
| Hit@5 | **84.4%** | 51.6% |
|
||||
| Hit@10 | **90.4%** | — |
|
||||
| MRR | **0.6597** | 0.380 |
|
||||
Per-type, session-level: `single-session-assistant` 100.0% Hit@1 (n=56),
|
||||
`knowledge-update` 96.2% (n=78), `single-session-user` 94.3% (n=70),
|
||||
`multi-session` 84.2% (n=133), `temporal-reasoning` 84.2% (n=133), and
|
||||
`single-session-preference` 33.3% (n=30) — the one category where BM25 clearly
|
||||
struggles, since a preference question's evidence rarely shares vocabulary with
|
||||
the question.
|
||||
|
||||
**clawhdf5 outperforms MemX at turn-level retrieval** — Hit@5 84.4% vs 51.6%, MRR 0.66 vs 0.38 — with BM25 alone, no embeddings needed.
|
||||
### Retrieval mode ablation — full haystack, n=500
|
||||
|
||||
> ¹ MemX uses dense embeddings + FTS5 + four-factor re-ranking. Our BM25-only result exceeds their full pipeline.
|
||||
Real 384-d `all-MiniLM-L6-v2` embeddings, 190,015 unique texts encoded once on an
|
||||
RTX 5060 Ti (~13 min; the same work on the 8-core CPU was still unfinished after
|
||||
30 minutes, so the GPU path is not a convenience here). Turn-level:
|
||||
|
||||
### Per-Type Breakdown (session-level)
|
||||
| Mode | Hit@1 | Hit@5 | Hit@10 | MRR |
|
||||
|------|-------|-------|--------|-----|
|
||||
| BM25 only (`0.0`/`1.0`) | **53.8%** | 75.0% | 81.6% | **0.6320** |
|
||||
| Vector only (`1.0`/`0.0`) | 36.0% | 71.8% | 81.6% | 0.5027 |
|
||||
| Hybrid (`0.7`/`0.3`) | 44.4% | **79.2%** | **86.0%** | 0.5868 |
|
||||
|
||||
| Question Type | N | Hit@1 | Hit@5 | Hit@10 | MRR |
|
||||
|---------------|---|-------|-------|--------|-----|
|
||||
| single-session-user | 70 | 100.0% | 100.0% | 100.0% | 1.0000 |
|
||||
| single-session-assistant | 56 | 100.0% | 100.0% | 100.0% | 1.0000 |
|
||||
| single-session-preference | 30 | 100.0% | 100.0% | 100.0% | 1.0000 |
|
||||
| temporal-reasoning | 133 | 100.0% | 100.0% | 100.0% | 1.0000 |
|
||||
| multi-session | 133 | 100.0% | 100.0% | 100.0% | 1.0000 |
|
||||
| knowledge-update | 78 | 100.0% | 100.0% | 100.0% | 1.0000 |
|
||||
Session-level:
|
||||
|
||||
| Mode | Hit@1 | Hit@5 | Hit@10 | MRR |
|
||||
|------|-------|-------|--------|-----|
|
||||
| BM25 only | 86.2% | 93.6% | 96.6% | 0.8948 |
|
||||
| Vector only | 85.4% | 94.2% | 96.6% | 0.8901 |
|
||||
| Hybrid | **88.2%** | **95.8%** | **97.8%** | **0.9158** |
|
||||
|
||||
### Weight sweep — full haystack, n=500
|
||||
|
||||
`0.7/0.3` was a documented default, never a searched one. Sweeping
|
||||
`vector_weight` from 0.0 to 1.0 (`--sweep`, reusing the one-time embedding
|
||||
table) shows it is not merely suboptimal but **strictly dominated**:
|
||||
|
||||
| vector / keyword | Hit@1 | Hit@5 | Hit@10 | MRR | session Hit@5 |
|
||||
|---|---|---|---|---|---|
|
||||
| 0.0 / 1.0 (BM25) | **53.8%** | 75.0% | 81.6% | 0.6320 | 93.6% |
|
||||
| 0.1 / 0.9 | 53.2% | 77.4% | 83.8% | 0.6374 | 95.0% |
|
||||
| 0.2 / 0.8 | 53.6% | 78.2% | 85.6% | 0.6440 | 95.4% |
|
||||
| 0.3 / 0.7 | 53.2% | 78.8% | 87.2% | **0.6463** | 96.0% |
|
||||
| **0.4 / 0.6** | 51.6% | **81.4%** | 87.8% | 0.6429 | 96.8% |
|
||||
| 0.5 / 0.5 | 48.2% | **81.4%** | **88.2%** | 0.6234 | **97.4%** |
|
||||
| 0.6 / 0.4 | 46.6% | 79.8% | 87.4% | 0.6069 | 96.6% |
|
||||
| 0.7 / 0.3 *(old default)* | 44.4% | 79.2% | 86.0% | 0.5868 | 95.8% |
|
||||
| 0.8 / 0.2 | 40.6% | 76.2% | 85.4% | 0.5571 | 95.2% |
|
||||
| 0.9 / 0.1 | 37.8% | 73.4% | 84.6% | 0.5289 | 94.2% |
|
||||
| 1.0 / 0.0 (vector) | 36.0% | 71.8% | 81.6% | 0.5027 | 94.2% |
|
||||
|
||||
**`0.4/0.6` beats `0.7/0.3` on every metric at both granularities** — Hit@1
|
||||
+7.2pp, Hit@5 +2.2, Hit@10 +1.8, MRR +0.056. There is no trade being made; the
|
||||
old default was simply on the wrong side of the peak. **`0.4/0.6` is the
|
||||
recommended setting**, with `0.3/0.7` preferable if rank-1 precision matters
|
||||
most (it takes the best MRR in the sweep and gives up only 0.6pp of Hit@1
|
||||
against pure BM25).
|
||||
|
||||
**Correction.** An earlier revision of this section, measuring only `0.7/0.3`,
|
||||
concluded that fusion "buys deeper recall and pays for it at rank 1" and advised
|
||||
callers taking a single top hit to prefer BM25. That was an artifact of the
|
||||
badly-chosen weight, not a property of fusion. At `0.3/0.7` hybrid *beats* BM25
|
||||
on MRR (0.6463 vs 0.6320) and on Hit@5 (78.8% vs 75.0%) while costing 0.6pp of
|
||||
Hit@1. The advice below is corrected accordingly.
|
||||
|
||||
**Hybrid wins, once the weights are right.** At the old `0.7/0.3` the picture
|
||||
looked like a trade: best at Hit@5 and Hit@10, worse than BM25 at Hit@1 and MRR.
|
||||
The sweep above shows that was the weight, not fusion. At `0.4/0.6` hybrid leads
|
||||
Hit@5 and Hit@10 outright; at `0.3/0.7` it also leads MRR and is within 0.6pp of
|
||||
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.
|
||||
|
||||
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.
|
||||
Two different codebases, two different fusion schemes, same direction.
|
||||
|
||||
Vector-only being *worse* than BM25 at every turn-level cutoff except Hit@10 is
|
||||
worth stating plainly rather than hiding: LongMemEval questions share substantial
|
||||
vocabulary with their evidence turns, which is close to the best case for lexical
|
||||
matching, and MiniLM at 384 dimensions is a small embedding model.
|
||||
|
||||
> **Run:** `cargo run --release --bin longmemeval_bench --features embeddings -- \
|
||||
> benchmarks/longmemeval/longmemeval_s_cleaned.json --embeddings weights/all-minilm-l6-v2`
|
||||
> For the GPU path use `--features embeddings-cuda`. That requires `nvcc` on
|
||||
> `PATH` at *build* time — cudarc's build script shells out to it. The toolkit
|
||||
> installs to `/usr/local/cuda/bin`, which many distributions do not export;
|
||||
> check with `nvcc --version` and, if it is missing, add it somewhere every
|
||||
> shell reads (for zsh that is `~/.zshenv`, not `~/.zshrc`, since build tooling
|
||||
> runs non-interactively). The device is selected at runtime with a CPU
|
||||
> fallback, so a machine without CUDA still produces correct numbers — just far
|
||||
> more slowly, and the bench says so on startup.
|
||||
>
|
||||
> Weights: `huggingface.co/sentence-transformers/all-MiniLM-L6-v2` — place
|
||||
> `model.safetensors` and `tokenizer.json` in the `--embeddings` directory.
|
||||
|
||||
### Oracle variant — `longmemeval_oracle`, n=500 (easier corpus, kept for continuity)
|
||||
|
||||
| Metric | ClawhDF5 (BM25-only, oracle variant) |
|
||||
|--------|--------------------------------------|
|
||||
| Hit@1 | 52.6% |
|
||||
| Hit@5 | **84.4%** |
|
||||
| Hit@10 | 90.4% |
|
||||
| MRR | 0.6597 |
|
||||
|
||||
Turn-level. The 9.4-point gap between this and the full haystack's 75.0% is the
|
||||
price of the harder corpus, and is the reason oracle-only numbers should not be
|
||||
presented as LongMemEval results. Session-level figures on this variant are
|
||||
degenerate — see below.
|
||||
|
||||
With real embeddings the same oracle corpus gives BM25-only 84.2% / vector-only
|
||||
80.4% / hybrid **85.2%** Hit@5 turn-level — hybrid ahead at Hit@5 and Hit@10 and
|
||||
behind at Hit@1, matching the full-haystack pattern above. (BM25-only reads 84.2%
|
||||
here against 84.4% with zero embedding vectors: one question of 500 changes rank,
|
||||
with MRR identical at 0.6597. On the full haystack the two agree exactly.)
|
||||
|
||||
### Retracted: session-level recall and the MemX comparison
|
||||
|
||||
Earlier revisions of this file reported session-level Hit@1/5/10 of **100.0%** with
|
||||
MRR **1.0000**, uniform across all six question types, and claimed clawhdf5
|
||||
"outperforms MemX at turn-level retrieval (84.4% vs 51.6%)". **Both are withdrawn.**
|
||||
|
||||
**The session-level numbers are a degenerate artifact.** On the `longmemeval_oracle`
|
||||
variant, the ingested haystack for a question consists essentially only of that
|
||||
question's evidence sessions. Every returned document therefore belongs to an answer
|
||||
session, so session-level hit rate is ≈1.0 at rank 0 *by construction* — which is
|
||||
exactly why the result was a uniform 100.0% across every question type. It measured
|
||||
the shape of the corpus, not the retriever.
|
||||
|
||||
**The MemX comparison was not like-for-like on two independent axes.** MemX
|
||||
([arxiv:2603.16171](https://arxiv.org/abs/2603.16171)) reports Hit@5 = 51.6% /
|
||||
MRR = 0.380 at **fact-level granularity over 220,349 fact-level records drawn from
|
||||
19,195 sessions**, and explicitly notes that fact-level "doubl[es] session-level
|
||||
performance." Our 84.4% is **turn-level, on the oracle subset**. Different retrieval
|
||||
granularity, and a corpus smaller by orders of magnitude. A higher number on an
|
||||
easier corpus at a different granularity is not an outperformance claim, and it
|
||||
should not have been presented as one.
|
||||
|
||||
The full-haystack half of that gap is now closed: the section above reports
|
||||
`longmemeval_s` over all 500 questions. The **granularity** mismatch remains — MemX
|
||||
measures fact-level, we measure turn-level and session-level — so no cross-system
|
||||
claim is made here even now. Matching granularity would require fact-level
|
||||
extraction over the haystack, which this harness does not do.
|
||||
|
||||
### Search Latency (LongMemEval, n=500 queries)
|
||||
|
||||
@@ -368,6 +544,54 @@ No network hop, no serialization — direct HashMap operations.
|
||||
|
||||
---
|
||||
|
||||
## World-Model Sample Loading (vs h5py / stable-worldmodel shape)
|
||||
|
||||
Reproduces the access pattern of `stable-worldmodel`'s HDF5 dataloader
|
||||
([arXiv 2605.21800](https://arxiv.org/abs/2605.21800), LeCun/Balestriero
|
||||
group), which supports HDF5 as one of three native formats and measures
|
||||
generic HDF5 at **1,416-1,474 samples/s** (vs Lance 4,815) for per-frame
|
||||
sample loading. This benchmark measures **clawhdf5 vs h5py on the same
|
||||
machine and the same file**, so the comparison is hardware-controlled.
|
||||
|
||||
**Absolute numbers are not comparable to the paper's** - different hardware
|
||||
(AMD Ryzen 7 7800X3D, local NVMe, warm page cache), smaller frames, and no
|
||||
torch-tensor / transform step. Only the clawhdf5-vs-h5py ratio *here* is a
|
||||
controlled result. The workload is the dataloader shape: a `(N, H, W, C)`
|
||||
uint8 observation dataset (20,000 x 64x64x3 = 246 MB), each frame read once
|
||||
per pass in a fixed shuffled (random-access) order, 10 passes.
|
||||
|
||||
Both read a **file written by h5py** - clawhdf5 parsing an
|
||||
externally-produced HDF5 file is itself the interop result. h5py opens SWMR
|
||||
with a 256 MB chunk cache, exactly `stable-worldmodel`'s `HDF5Dataset`; it
|
||||
materialises each frame as a numpy array (`d[i]`) and sums it. clawhdf5
|
||||
mmaps once, takes a zero-copy `&[u8]` over the contiguous dataset, and
|
||||
indexes frame `i` as a subslice.
|
||||
|
||||
| Reader | samples/sec (median of 3) | vs h5py |
|
||||
|--------|---------------------------|---------|
|
||||
| **clawhdf5** (zero-copy view) | **593,000** | **8.1x** |
|
||||
| **clawhdf5** (materialised copy per frame) | **518,000** | **7.1x** |
|
||||
| h5py (swmr, 256 MB cache) | 73,000 | 1.0x |
|
||||
|
||||
The **materialised-copy row is the fair, equal-work comparison** - it
|
||||
`to_vec()`s every frame so clawhdf5 pays the same per-frame allocation h5py
|
||||
does, and it is still **7.1x faster**. That the copy costs almost nothing
|
||||
(518k vs 593k) shows the h5py gap is **per-frame call overhead** (Python +
|
||||
library dispatch), not data movement. This is an in-page-cache measurement:
|
||||
it isolates the read-path overhead both libraries add on top of the OS,
|
||||
which is the thing that differs - not disk bandwidth, which is shared.
|
||||
|
||||
Reproduce (`benchmarks/`):
|
||||
|
||||
```bash
|
||||
python benchmarks/gen_worldmodel_frames.py /tmp/wm_frames.h5 20000
|
||||
cargo run --release -p clawhdf5-bench --example worldmodel_sampling -- /tmp/wm_frames.h5 10
|
||||
cargo run --release -p clawhdf5-bench --example worldmodel_sampling -- /tmp/wm_frames.h5 10 --copy
|
||||
python benchmarks/bench_worldmodel_h5py.py /tmp/wm_frames.h5 10
|
||||
```
|
||||
|
||||
Measured 2026-08-07 on tank (Ryzen 7 7800X3D, 246 MB dataset in page cache).
|
||||
|
||||
## Cross-Platform Notes
|
||||
|
||||
> **Run:** `./benchmarks/cross_platform.sh [--full] [--output results.json]`
|
||||
@@ -649,3 +873,117 @@ cargo bench -p clawhdf5-bench --features libhdf5-compare --bench h5bench_meta --
|
||||
cargo bench -p clawhdf5-bench --features libhdf5-compare --bench h5bench_meta -- metadata_parse_in_memory
|
||||
cargo bench -p clawhdf5-bench --features libhdf5-compare --bench h5bench_read -- read_zerocopy_mmap
|
||||
```
|
||||
|
||||
## Independent Validation: tank — LongMemEval & Vector Search (Ryzen 7 7800X3D), 2026-08-05
|
||||
|
||||
Re-running the "LongMemEval Results" and "SIMD & Parallelism" sections above on
|
||||
tank (AMD Ryzen 7 7800X3D, 8C/16T, Ubuntu 26.04, same machine as the
|
||||
vs-libhdf5 validation above) to give both sections the dated, hardware-cited,
|
||||
reproducible citation the top-of-file traceability note flags them as
|
||||
missing.
|
||||
|
||||
### LongMemEval Results (reproduction)
|
||||
|
||||
```bash
|
||||
cd benchmarks/longmemeval
|
||||
wget https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_oracle.json
|
||||
cargo run --release --bin longmemeval_bench
|
||||
```
|
||||
|
||||
Recall numbers are deterministic (pure BM25 retrieval over a fixed dataset) and
|
||||
reproduce exactly. Scoring target as declared in the LongMemEval section above:
|
||||
retrieval recall, turn-level, k=10, `longmemeval_oracle` variant, BM25-only.
|
||||
|
||||
| Metric | Turn-Level |
|
||||
|--------|------------|
|
||||
| Hit@1 | 52.6% |
|
||||
| Hit@5 | **84.4%** |
|
||||
| Hit@10 | 90.4% |
|
||||
| MRR | 0.6597 |
|
||||
|
||||
Session-level figures are omitted here — they are degenerate on the oracle variant
|
||||
and have been retracted; see "Retracted: session-level recall and the MemX
|
||||
comparison" above.
|
||||
|
||||
Search latency (hardware-dependent, tank numbers):
|
||||
|
||||
| Metric | avg | p50 | p95 | p99 |
|
||||
|--------|-----|-----|-----|-----|
|
||||
| Latency | 2,431 µs | 2,105 µs | 7,250 µs | 12,018 µs |
|
||||
|
||||
Higher than the i7-12650H figures at the top of this file (avg 1,004 µs) despite
|
||||
tank's faster single-core performance elsewhere in this document — BM25 search
|
||||
latency here scales with per-question haystack size and this run's variance is
|
||||
wider (p99 is ~5x the mean), suggesting this metric is more sensitive to
|
||||
momentary scheduling/cache effects than the flat-array vector-search benchmarks.
|
||||
Recorded as-is rather than smoothed.
|
||||
|
||||
### SIMD & Parallelism (reproduction, with a correction)
|
||||
|
||||
```bash
|
||||
cargo bench -p clawhdf5-agent --bench bench -- "^(strategy_scalar_10k|strategy_simd_10k|strategy_rayon_10k|adaptive_search_10k|simd_cosine_100k|rayon_cosine_100k)$"
|
||||
```
|
||||
|
||||
The original 10K table above compares named benchmarks (`vector_search`,
|
||||
`rayon`, `strategy`) that, on inspection, don't all exercise the same
|
||||
scalar-vs-SIMD-vs-parallel axis the table implies — several of the
|
||||
`simd_cosine_10k`/`sequential_cosine_10k`-style benchmarks actually call the
|
||||
same underlying function under different names. The `adaptive_benches` group's
|
||||
`strategy_scalar_10k` / `strategy_simd_10k` / `strategy_rayon_10k` benchmarks
|
||||
are the ones that genuinely hold the dataset fixed and vary only the
|
||||
`SearchStrategy` enum, so they're the correct apples-to-apples comparison —
|
||||
used here instead.
|
||||
|
||||
| Strategy | Latency (tank) | vs Sequential |
|
||||
|----------|-----------------|----------------|
|
||||
| Sequential (scalar) | 502 µs | 1.0x |
|
||||
| SIMD (auto-vectorized) | 327 µs | **1.53x** |
|
||||
| Rayon (parallel) | 323 µs | **1.55x** |
|
||||
| Adaptive (auto-select) | 339 µs | **1.48x** |
|
||||
|
||||
Honest finding: the speedup from SIMD/parallelism over scalar is real but
|
||||
smaller here (~1.5x) than the i7-12650H figures above (~2.0x). The Ryzen 7
|
||||
7800X3D's large L3 cache (96MB 3D V-Cache) measurably narrows the gap versus a
|
||||
naive scalar loop compared to the i7 — this is a genuine hardware-dependent
|
||||
result, not a regression or measurement error, and is recorded rather than
|
||||
reconciled away.
|
||||
|
||||
At 100K, no `strategy_*` benchmark exists in the current suite (`adaptive_benches`
|
||||
only covers n=10,000), so this row uses the same `simd_cosine_100k`/
|
||||
`rayon_cosine_100k` benchmarks as the original table — not a true scalar
|
||||
baseline, so no "vs Sequential" multiple is reported for it:
|
||||
|
||||
| Strategy | Latency (tank) |
|
||||
|----------|-----------------|
|
||||
| SIMD | 6.60 ms |
|
||||
| Rayon parallel | 4.73 ms |
|
||||
|
||||
### Vector Search Latency & Comparison to MemX (reproduction)
|
||||
|
||||
```bash
|
||||
cargo bench -p clawhdf5-agent --bench bench -- "^(vector_search_1k|simd_cosine_10k|simd_cosine_100k|prenorm_search_10k|ivf_search_10k_nprobe10|ivf_search_100k_nprobe10|ivf_pq_search_100k|rairs_search_10k_nprobe10|bm25_search_10k)$"
|
||||
```
|
||||
|
||||
| Scale | Flat Search | Pre-norm | IVF (nprobe=10) | IVF-PQ | RAIRS |
|
||||
|-------|-------------|----------|-----------------|--------|-------|
|
||||
| **1K** | 47.8 µs | — | — | — | — |
|
||||
| **10K** | 501 µs | 322 µs | 24.8 µs | — | 109 µs |
|
||||
| **100K** | 6.60 ms | — | 608 µs | 865 µs | — |
|
||||
|
||||
(The 1K Pre-norm cell from the original table has no corresponding benchmark
|
||||
in the current suite — not re-verified, left blank rather than guessed.)
|
||||
|
||||
Same not-like-for-like caveat as the "Comparison to MemX" section at the top of this
|
||||
file applies — MemX's figure is end-to-end, these are a single component. Ratios are
|
||||
an order-of-magnitude indication, not a benchmark result.
|
||||
|
||||
| Metric | MemX (claimed, end-to-end) | ClawhDF5 (tank, component only) | Ratio |
|
||||
|--------|----------------------------|----------------------------------|-------|
|
||||
| 100K flat search | <90 ms | 6.60 ms | ~14x |
|
||||
| 100K IVF-PQ search | — | 865 µs | ~104x |
|
||||
| Keyword search 10K | 1,100x improvement over unindexed | 520 µs (BM25) | Comparable |
|
||||
|
||||
Every figure in this subsection is faster than the corresponding i7-12650H
|
||||
number at the top of this file, consistent with the Ryzen 7 7800X3D's higher
|
||||
single-core throughput and larger cache observed in the vs-libhdf5 validation
|
||||
above.
|
||||
|
||||
+245
-1
@@ -1,6 +1,240 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## 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 v1–v3 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
|
||||
deflate/lz4/zstd/pcodec so a crafted compressed chunk can't drive an
|
||||
unbounded allocation (memory-exhaustion DoS).
|
||||
- `clawhdf5-format`: `chunked_read.rs`/`data_read.rs`/`local_heap.rs` bounds
|
||||
audit — added `ensure_len` overflow guards at every plain-arithmetic
|
||||
offset+size check, a recursion-depth guard against a crafted
|
||||
self-referencing/cyclic B-tree chunk index, a fix for an unguarded
|
||||
compound-datatype `byte_offset` overrun in `read_compound_fields`, and an
|
||||
`ndims - 1` underflow guard for degenerate zero-dimension chunked layouts.
|
||||
Added a new `fuzz_dataset_read` cargo-fuzz target (walks every dataset in a
|
||||
parsed file and exercises the contiguous/chunked/compact raw-data read
|
||||
paths) which found and fixed 3 real crash bugs — an integer-multiply
|
||||
overflow in `copy_chunk_to_output`'s N-D assembly path, the `ndims - 1`
|
||||
underflow above, and an overflow in `local_heap.rs` — within the first few
|
||||
fuzzing runs.
|
||||
- `clawhdf5-format`: `btree_v1.rs` overflow-safe bounds checks via a local
|
||||
`ensure_len` helper, closing a `usize`-overflow panic reachable from a
|
||||
crafted near-`usize::MAX` B-tree offset.
|
||||
- `clawhdf5-agent`: WAL length-prefix caps (`MAX_WAL_FIELD_LEN`, 64 MiB) reject
|
||||
a corrupted/truncated length claim before allocating. Followed by a full
|
||||
per-entry CRC32 trailer (`WAL_VERSION` bumped to 2) — a bit-flip inside an
|
||||
entry now stops replay cleanly instead of silently accepting corrupted
|
||||
data. Old-format WAL files are still read correctly and migrated to the new
|
||||
format on next open.
|
||||
- `clawhdf5-android`: validate `embedding_len`/`query_embedding_len` against
|
||||
the handle's configured `embedding_dim` (and reject null pointers) before
|
||||
constructing a slice from a raw pointer in `edgehdf5_save` /
|
||||
`edgehdf5_hybrid_search`.
|
||||
- `clawhdf5-py`: bump pyo3/numpy `0.28` → `0.29`, clearing two RUSTSEC
|
||||
advisories (OOB read in `PyList`/`PyTuple` iterator; missing `Sync` bound on
|
||||
`PyCFunction::new_closure`).
|
||||
- Clarified that the integrity hashes in `clawhdf5-agent::provenance`
|
||||
(FNV-1a) and `clawhdf5-format::provenance` (SHA-256) are unkeyed and detect
|
||||
only accidental corruption, not tampering — doc-only change, no behavior
|
||||
change.
|
||||
|
||||
### Performance
|
||||
- `clawhdf5-format`: chunk cache lookup is now O(1) (`slot_index: HashMap`)
|
||||
instead of a linear scan, and cache hits return a shared `Arc` instead of
|
||||
cloning the decompressed buffer — the hottest path in chunked reads.
|
||||
- `clawhdf5-ann`: optional `parallel` feature (rayon) parallelizes HNSW's
|
||||
`prune_connections` neighbor-distance computation. The outer build/insert
|
||||
loop is deliberately left sequential — it has genuine cross-iteration data
|
||||
dependencies and needs its own correctness-focused design pass.
|
||||
- `clawhdf5-format/chunked_read.rs`: removed 12 unnecessary
|
||||
`chunk_dimensions[..rank].to_vec()` allocations where callees already
|
||||
accept `&[u32]`.
|
||||
|
||||
### Architecture
|
||||
- Added `.gitea/workflows/ci.yml`, actually wiring the long-existing
|
||||
`scripts/ci-test.sh` (fmt, clippy, tests, no_std check) into CI on every
|
||||
push/PR to `main`. Fixed stale package names in `ci-test.sh`/
|
||||
`check-nostd.sh` that had been silently no-op'ing the `clawhdf5-py`
|
||||
exclusion and the no_std check.
|
||||
- Fixed a genuine no_std build break in `clawhdf5-format` (uncovered once the
|
||||
no_std CI check actually started running): `core::sync::atomic::AtomicU64`
|
||||
doesn't exist on `thumbv7em-none-eabihf` (switched to `portable-atomic`),
|
||||
missing `alloc` imports for `Box`/`Vec`/`format!` on a few no_std paths, and
|
||||
`f64::powi` (std/libm-only) replaced with a local exponentiation-by-squaring
|
||||
helper in the scale-offset filter.
|
||||
- Added `[workspace.dependencies]` for `tempfile`/`criterion`/`half`/`serde`,
|
||||
fixing a real version skew on `half` (`2` vs `2.7` across crates).
|
||||
- Fixed version skew: `clawhdf5-py` (`pyproject.toml`) and
|
||||
`packages/clawhdf5-node` (`package.json`) were both behind the actual crate
|
||||
version (2.1.0).
|
||||
- Documented that the `mpi-io` feature's read/write paths are root-read
|
||||
+broadcast / gather-to-rank-0, not true collective I/O.
|
||||
|
||||
### Documentation
|
||||
- BENCHMARKS.md: re-ran the previously-undated "LongMemEval Results", "SIMD &
|
||||
Parallelism", and "Vector Search Latency"/"Comparison to MemX" sections on
|
||||
a second machine (tank, Ryzen 7 7800X3D) with explicit dates and reproduce
|
||||
commands. Found and corrected a methodology issue in the SIMD/Parallelism
|
||||
benchmark selection (several originally-compared benchmarks didn't actually
|
||||
isolate the scalar/SIMD/parallel axis).
|
||||
- README.md / ROADMAP.md / CLAUDE.md: corrected several stale facts —
|
||||
the `clawhdf5-types` crate (removed earlier) was still listed in the
|
||||
README crate map; the LongMemEval numbers in the README badge and table
|
||||
didn't match the actual (much better) benchmark results in BENCHMARKS.md;
|
||||
total line-of-code and test-count figures were stale; `clawhdf5-gpu`'s
|
||||
CubeCL→wgpu correction; documented the new `clawhdf5-ann` `parallel`
|
||||
feature flag, which had no entry in the Feature Flags table.
|
||||
|
||||
### New Features
|
||||
- `clawhdf5-migrate`: substantial engine improvements:
|
||||
@@ -161,6 +395,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
|
||||
|
||||
@@ -17,7 +17,7 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
|
||||
| `clawhdf5-netcdf4` | NetCDF-4 compatibility layer |
|
||||
| `clawhdf5-ann` | HNSW approximate nearest-neighbor vector index |
|
||||
| `clawhdf5-agent` | Agent memory, session history, knowledge graph storage |
|
||||
| `clawhdf5-gpu` | GPU-accelerated I/O via CubeCL |
|
||||
| `clawhdf5-gpu` | GPU-accelerated I/O via wgpu (hand-written WGSL compute shaders) |
|
||||
| `clawhdf5-accel` | CPU SIMD acceleration path |
|
||||
| `clawhdf5-migrate` | Schema migration engine |
|
||||
| `clawhdf5-android` | Android JNI bindings |
|
||||
@@ -33,7 +33,48 @@ 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
|
||||
- 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
|
||||
|
||||
+8
-2
@@ -21,7 +21,13 @@ members = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "2.1.0"
|
||||
version = "2.3.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||
|
||||
[workspace.dependencies]
|
||||
tempfile = "3"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
half = "2.7"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org)
|
||||
[](#benchmarks)
|
||||
[](BENCHMARKS.md#longmemeval-results)
|
||||
[](#performance)
|
||||
[](BENCHMARKS.md#longmemeval-results)
|
||||
[](BENCHMARKS.md#memory-footprint)
|
||||
|
||||
ClawHDF5 is a pure-Rust HDF5 implementation combined with a research-grade agent memory engine. It gives AI agents persistent, searchable, cryptographically verifiable memory — all stored in a single portable file.
|
||||
@@ -64,7 +64,12 @@ Figures below are from an independent reproduction run on a second machine (AMD
|
||||
|-------|------|-----------------|--------|----------|
|
||||
| 1K | **54 µs** | — | — | — |
|
||||
| 10K | 753 µs | **27 µs** | — | — |
|
||||
| 100K | 11.4 ms | 1.32 ms | **1.19 ms** | **8–76× faster** |
|
||||
| 100K | 11.4 ms | 1.32 ms | **1.19 ms** | ~8–76× (see caveat) |
|
||||
|
||||
> Reproduced on the same second machine (Ryzen 7 7800X3D) with a corrected,
|
||||
> apples-to-apples SIMD/scalar/parallel comparison methodology — see
|
||||
> [BENCHMARKS.md § Independent Validation: tank — LongMemEval & Vector
|
||||
> Search](BENCHMARKS.md#independent-validation-tank--longmemeval--vector-search-ryzen-7-7800x3d-2026-08-05).
|
||||
|
||||
### Agent Memory Operations
|
||||
|
||||
@@ -92,20 +97,52 @@ by default (AoS→SoA byte transpose, +157–204% throughput for float data):
|
||||
|
||||
Use `.with_zstd(3)` or `.with_deflate(6)` for write-heavy workloads — both now perform at ~720–750 MiB/s on large matrices. Use `.with_pcodec()` for write-once/read-many workloads where compression ratio matters more than encode speed. Disable auto-shuffle with `.without_shuffle()` for byte arrays that don't benefit from AoS→SoA transposition.
|
||||
|
||||
> ¹ MemX ([arxiv:2603.16171](https://arxiv.org/abs/2603.16171), March 2026): Rust + libSQL, claims <90ms at 100K records.
|
||||
> ¹ MemX ([arxiv:2603.16171](https://arxiv.org/abs/2603.16171), March 2026): Rust + libSQL, claims <90ms at 100K records. **Not like-for-like:** MemX's figure is *end-to-end* (embeddings + FTS5 + four-factor re-ranking); ours is a *single component* (raw vector search). The ratio overstates the real advantage by an unquantified margin — order-of-magnitude indication only. See [BENCHMARKS.md](BENCHMARKS.md#comparison-to-memx-arxiv260316171).
|
||||
|
||||
### LongMemEval Retrieval Recall
|
||||
|
||||
Evaluated against the LongMemEval dataset (500 questions, multi-session haystack).
|
||||
BM25-only baseline (no embedding model required at bench time):
|
||||
Evaluated against the full **`longmemeval_s`** haystack — all 500 questions, 47.7
|
||||
sessions and 493.5 turns each, with only 4.0% of haystack sessions being evidence
|
||||
sessions. See [BENCHMARKS.md § LongMemEval
|
||||
Results](BENCHMARKS.md#longmemeval-results) for the full scoring-target
|
||||
declaration:
|
||||
|
||||
| Metric | BM25-only | Full hybrid¹ |
|
||||
|--------|-----------|--------------|
|
||||
| Hit@5 (session) | ~46% | Higher |
|
||||
| MRR (session) | ~0.34 | Higher |
|
||||
| Abstention accuracy | ~72% | — |
|
||||
| Mode | Turn-Level Hit@5 | Session-Level Hit@5 |
|
||||
|------|------------------|---------------------|
|
||||
| BM25 only | 75.0% | 93.6% |
|
||||
| Vector only (MiniLM) | 71.8% | 94.2% |
|
||||
| Hybrid (0.4/0.6, tuned) | **81.4%** | **96.8%** |
|
||||
|
||||
> ¹ Enable embeddings via `hybrid_search(query_emb, text, 0.7, 0.3, k)` for substantially higher recall. The vector stage is served by the HNSW index by default (the `hnsw` feature is on by default); build with `--no-default-features --features float16` to fall back to an exact linear cosine scan.
|
||||
Hybrid is the strongest configuration, which is what running two retrieval stages
|
||||
is for. The weights matter more than the stages: a sweep of `vector_weight` from
|
||||
0.0 to 1.0 found the long-standing `0.7/0.3` default is **strictly dominated** by
|
||||
`0.4/0.6` — better on Hit@1, Hit@5, Hit@10 and MRR at both granularities. Use
|
||||
`0.4/0.6`, or `0.3/0.7` if rank-1 precision matters most. See
|
||||
[BENCHMARKS.md § Weight sweep](BENCHMARKS.md#longmemeval-results).
|
||||
|
||||
Vector embeddings require `--features embeddings`; without it the vector stage is
|
||||
inert and only the BM25 row is produced, which is what every previously published
|
||||
number here measured.
|
||||
|
||||
On the easier `longmemeval_oracle` variant (evidence sessions only) the same
|
||||
harness scores 84.4% turn-level Hit@5 / MRR 0.6597, reproduced identically on a
|
||||
second machine. The 9.4-point gap is the cost of the real haystack, and is why the
|
||||
full-haystack number is the one quoted here.
|
||||
|
||||
This is **retrieval recall** (did the gold memory appear in the top-k), not the
|
||||
official LongMemEval QA-accuracy metric — the two are not comparable, and
|
||||
retrieval recall reported as QA accuracy typically overstates by 20–30 points.
|
||||
|
||||
> **Previously reported here and now retracted:** session-level Hit@5 of 100.0% /
|
||||
> MRR 1.0000, and a claim of beating MemX's 51.6%. Those session-level figures were
|
||||
> degenerate on the oracle variant (any returned document is a hit by
|
||||
> construction); the 93.6% above is a different, real measurement on a corpus where
|
||||
> evidence sessions are 4.0% of the haystack. The MemX comparison stays withdrawn —
|
||||
> MemX measures fact-level granularity over 220,349 records, which running the full
|
||||
> haystack does not fix. Details in
|
||||
> [BENCHMARKS.md](BENCHMARKS.md#retracted-session-level-recall-and-the-memx-comparison).
|
||||
|
||||
> Enable embeddings via `hybrid_search(query_emb, text, 0.4, 0.6, k)` for substantially higher recall. The vector stage is served by the HNSW index by default (the `hnsw` feature is on by default); build with `--no-default-features --features float16` to fall back to an exact linear cosine scan.
|
||||
|
||||
### Memory Footprint
|
||||
|
||||
@@ -194,7 +231,7 @@ ClawhDF5's agent memory engine implements research from 15+ recent papers on age
|
||||
| **`ivf` / `pq`** | IVF-PQ approximate nearest neighbor for billion-scale search |
|
||||
| **`bm25`** | BM25 keyword index with TF-IDF scoring |
|
||||
| **`entity_extract`** | Rule-based entity extraction from text chunks into the knowledge graph |
|
||||
| **`wal`** | Write-ahead log for crash-safe persistence |
|
||||
| **`wal`** | Write-ahead log for crash-safe persistence; each entry is CRC32-checked on replay, so a corrupted entry stops replay there instead of loading bad data |
|
||||
| **`memory_strategy`** | Pluggable strategies: save-every, semantic-shift, user-correction detection |
|
||||
| **`decision_gate`** | Sub-microsecond trivial/substantive classification |
|
||||
| **`async_memory`** | Tokio-based async wrapper over the memory store (`async` feature) |
|
||||
@@ -338,22 +375,22 @@ let exported = backend.export_markdown("MEMORY.md")?;
|
||||
## Crate Map
|
||||
|
||||
```
|
||||
clawhdf5 workspace (17 crates, 84K lines of Rust)
|
||||
clawhdf5 workspace (16 crates, ~92K lines of Rust; plus libaec-sys, an
|
||||
internal FFI bindings crate for the optional szip feature)
|
||||
│
|
||||
├── Core HDF5
|
||||
│ ├── clawhdf5-types — Type system definitions
|
||||
│ ├── clawhdf5-format — Binary parser/writer (no_std)
|
||||
│ ├── clawhdf5-format — Binary parser/writer (no_std), shared type definitions
|
||||
│ ├── clawhdf5-io — I/O abstraction (buffered, mmap, async)
|
||||
│ ├── clawhdf5-filters — Compression (deflate, lz4, zstd, blosc)
|
||||
│ ├── clawhdf5-filters — Fast deflate path (zlib-ng); lz4/zstd/pcodec/szip filters live in clawhdf5-format
|
||||
│ ├── clawhdf5-derive — Proc macros
|
||||
│ ├── clawhdf5 — High-level API
|
||||
│ ├── clawhdf5-netcdf4 — NetCDF-4 support
|
||||
│ ├── clawhdf5-accel — SIMD (NEON, AVX2, AVX-512)
|
||||
│ └── clawhdf5-gpu — GPU compute (wgpu)
|
||||
│ └── clawhdf5-gpu — GPU compute (wgpu, hand-written WGSL compute shaders)
|
||||
│
|
||||
├── Agent Memory
|
||||
│ ├── clawhdf5-agent — Memory engine (20.7K lines, 32 modules)
|
||||
│ ├── clawhdf5-ann — HNSW approximate nearest neighbor (default backend)
|
||||
│ ├── clawhdf5-agent — Memory engine (20.9K lines, 32 modules; WAL is CRC32-checked per entry)
|
||||
│ ├── clawhdf5-ann — HNSW approximate nearest neighbor (default backend; optional `parallel` feature)
|
||||
│ ├── clawhdf5-migrate — SQLite → HDF5 migration
|
||||
│ ├── clawhdf5-android — Android JNI bridge
|
||||
│ └── clawhdf5-cli — CLI tool
|
||||
@@ -420,6 +457,24 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
|
||||
| `system-zlib` / `zlib-rs` | no | Alternative zlib backends for deflate |
|
||||
| `blake3_hash` | no | BLAKE3 content hashing for provenance |
|
||||
|
||||
### `clawhdf5-ann`
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `parallel` | no | Rayon-parallel neighbor-distance computation during HNSW graph pruning |
|
||||
|
||||
### `clawhdf5-io`
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `mpi-io` | no | MPI-backed I/O via the `mpi` crate |
|
||||
|
||||
> **Parallel I/O (MPI) limitation:** `mpi-io`'s read path is a root-rank read
|
||||
> followed by a broadcast, and its write path gathers all ranks' shards to
|
||||
> rank 0 before writing — not true collective I/O
|
||||
> (`MPI_File_read_at_all`/`write_at_all`). It does not provide I/O bandwidth
|
||||
> that scales with rank count; true collective I/O is tracked as future work.
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
@@ -435,7 +490,7 @@ cargo build -p clawhdf5-agent --features "agent,float16,parallel,fast-math"
|
||||
cargo build -p clawhdf5-agent --features "agent,float16,accelerate,parallel,gpu"
|
||||
|
||||
# Tests
|
||||
cargo test --workspace # all 417+ tests
|
||||
cargo test --workspace # all 1,650+ tests
|
||||
cargo test -p clawhdf5-agent # agent memory tests
|
||||
|
||||
# Benchmarks
|
||||
@@ -505,7 +560,7 @@ See [ROADMAP.md](ROADMAP.md) for the full implementation tracker.
|
||||
- ✅ OpenClaw integration layer
|
||||
- ✅ Comprehensive Criterion benchmarks
|
||||
|
||||
**Phase 2** — OpenClaw TypeScript bridge, academic benchmarks (MemoryArena, LongMemEval), cross-platform validation.
|
||||
**Phase 2** — MemoryArena and LongMemEval academic benchmarks are done (see [BENCHMARKS.md](BENCHMARKS.md), reproduced on a second machine); remaining: publish the OpenClaw TypeScript bridge to npm, crates.io/PyPI publishing.
|
||||
|
||||
---
|
||||
|
||||
@@ -523,5 +578,5 @@ MIT
|
||||
|
||||
<p align="center">
|
||||
<em>Built by <a href="https://github.com/redclawsystems">RedClaw Systems</a></em><br>
|
||||
<em>72,087 lines of Rust. Zero C dependencies. One file to remember everything.</em>
|
||||
<em>~92,000 lines of Rust. Zero C dependencies. One file to remember everything.</em>
|
||||
</p>
|
||||
|
||||
+23
-6
@@ -145,19 +145,36 @@
|
||||
**Phase 3:** ~~Track 6 (multi-modal) + Track 7 (OpenClaw integration)~~ 🟢 Complete
|
||||
**Phase 4:** ~~Track 8 (benchmarking + validation)~~ 🟢 Complete
|
||||
|
||||
All 8 tracks delivered. 1,546 tests passing, zero clippy warnings.
|
||||
All 8 tracks delivered. 1,650+ tests passing, zero clippy warnings.
|
||||
|
||||
---
|
||||
|
||||
## What's Next
|
||||
|
||||
Verified against current repo state on 2026-08-03 (see also `docs/superpowers/plans/` for the filter-codec/format-write/MPI-IO work, now shipped):
|
||||
Verified against current repo state on 2026-08-05 (see also `docs/superpowers/plans/` for the filter-codec/format-write/MPI-IO work, now shipped):
|
||||
|
||||
- [ ] CI/CD pipeline — still no GitHub/Gitea Actions workflow in the repo; automated testing is manual only
|
||||
- [ ] Academic benchmark cross-validation — reproduce MemX/LongMemEval under identical conditions
|
||||
- [ ] TypeScript bridge — `clawhdf5-napi` has no `package.json`; it's still Rust-only scaffolding, not a publishable npm package
|
||||
- [ ] TypeScript bridge not wired into CI — `packages/clawhdf5-node/` already has a complete, working napi-rs package (package.json, tsconfig, hand-written TS wrapper matching all 21 `#[napi]` items, Jest test suite, README); it isn't published to npm and has no committed lockfile
|
||||
- [ ] Publish crates to crates.io — no `publish` config anywhere in the workspace yet
|
||||
- [ ] Python wheel distribution via maturin — `crates/clawhdf5-py/pyproject.toml` exists (maturin-buildable locally) but wheels aren't published anywhere
|
||||
- [ ] `chunked_read.rs`/`data_read.rs` full bounds-check audit + scheduled fuzz campaigns (the new `fuzz_dataset_read` target covers the two files' main entry points; a full manual audit of every indexing site is still open) — see Tier 4 below
|
||||
- [ ] WAL per-entry checksum landed as CRC32 (see below); a stronger per-entry format (explicit length prefix, avoiding the read-then-verify restructuring) could still be revisited if profiling shows it matters
|
||||
- [ ] HNSW build parallelism is still narrow (only `prune_connections`); the correctness-sensitive outer insert loop needs its own dedicated design pass before parallelizing
|
||||
|
||||
### Recently closed out (2026-08-05, Tier 3–4 hardening pass)
|
||||
|
||||
- [x] Academic benchmark cross-validation — LongMemEval reproduced against MemX on tank (Ryzen 7 7800X3D): turn-level Hit@5 84.4% vs MemX's 51.6%; recall numbers are deterministic and reproduce exactly across machines. SIMD/Parallelism and Vector Search sections also re-run and dated. See [BENCHMARKS.md § Independent Validation: tank — LongMemEval & Vector Search](BENCHMARKS.md#independent-validation-tank--longmemeval--vector-search-ryzen-7-7800x3d-2026-08-05)
|
||||
- [x] Android JNI (`clawhdf5-android`): validate `embedding_len`/`query_embedding_len` against the handle's configured `embedding_dim` before constructing a slice from a raw pointer
|
||||
- [x] `clawhdf5-py`: bumped pyo3/numpy 0.28 → 0.29, clearing two RUSTSEC advisories
|
||||
- [x] WAL (`clawhdf5-agent`): length-prefix caps (`MAX_WAL_FIELD_LEN`) to reject a corrupted length claim before allocating, then a full per-entry CRC32 trailer (`WAL_VERSION` 2) so a bit-flip stops replay cleanly instead of loading corrupted data; old-format WAL files still read correctly and are migrated on next open
|
||||
- [x] `chunked_read.rs`/`data_read.rs`/`local_heap.rs` bounds-check audit: added `ensure_len` overflow guards, a recursion-depth guard against cyclic B-trees, and a fix for an unguarded compound-datatype byte-offset overrun. Added a new `fuzz_dataset_read` cargo-fuzz target exercising the contiguous/chunked/compact read paths — it found and we fixed 3 real crash bugs (integer-overflow panics) within the first few runs
|
||||
- [x] `clawhdf5-ann`: optional `parallel` feature (rayon) for HNSW's `prune_connections` neighbor-distance computation
|
||||
- [x] `[workspace.dependencies]` added for `tempfile`/`criterion`/`half`/`serde`, fixing a real version skew on `half` (2 vs 2.7)
|
||||
|
||||
### Recently closed out (2026-08-05 hardening pass)
|
||||
|
||||
- [x] CI/CD pipeline — `.gitea/workflows/ci.yml` now runs `scripts/ci-test.sh` (fmt, clippy, tests, no_std check) on push/PR to `main`
|
||||
- [x] Fixed no_std build breakage in `clawhdf5-format` (missing alloc imports, `AtomicU64` unsupported on thumbv7em, `f64::powi` requiring std/libm)
|
||||
- [x] Fixed version skew: `clawhdf5-py` (pyproject.toml) and `packages/clawhdf5-node` (package.json) were both behind the actual crate version
|
||||
|
||||
### Recently closed out (2026-08-03 cleanup pass)
|
||||
|
||||
@@ -167,4 +184,4 @@ Verified against current repo state on 2026-08-03 (see also `docs/superpowers/pl
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-08-03_
|
||||
_Last updated: 2026-08-05_
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
"""h5py counterpart to worldmodel_sampling.rs — same file, same shuffled
|
||||
per-frame access, same minimal touch (sum the frame bytes). Reports
|
||||
samples/sec so the two sit side by side on one machine."""
|
||||
import sys, time, numpy as np, h5py
|
||||
|
||||
path = sys.argv[1]
|
||||
passes = int(sys.argv[2]) if len(sys.argv) > 2 else 5
|
||||
|
||||
def shuffled(n):
|
||||
v = list(range(n))
|
||||
state = 0x9E3779B97F4A7C15
|
||||
for i in range(n - 1, 0, -1):
|
||||
state = (state * 6364136223846793005 + 1442695040888963407) & 0xFFFFFFFFFFFFFFFF
|
||||
j = (state >> 33) % (i + 1)
|
||||
v[i], v[j] = v[j], v[i]
|
||||
return v
|
||||
|
||||
# swmr + a 256 MB chunk cache: exactly stable-worldmodel's HDF5Dataset._open_h5.
|
||||
f = h5py.File(path, "r", swmr=True, rdcc_nbytes=256 * 1024 * 1024)
|
||||
d = f["observation"]
|
||||
n = d.shape[0]
|
||||
order = shuffled(n)
|
||||
|
||||
# warm
|
||||
sink = 0
|
||||
for i in order:
|
||||
sink += int(d[i].sum())
|
||||
|
||||
t0 = time.perf_counter()
|
||||
sink = 0
|
||||
for _ in range(passes):
|
||||
for i in order:
|
||||
sink += int(d[i].sum())
|
||||
elapsed = time.perf_counter() - t0
|
||||
total = n * passes
|
||||
print(f"h5py: {n} frames x {passes} passes = {total} reads in {elapsed:.3f}s")
|
||||
print(f"h5py: {total/elapsed:.0f} samples/sec")
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a world-model-shaped dataset: N frames of HxWxC uint8 observations,
|
||||
contiguous (N,H,W,C), matching stable-worldmodel's per-frame sample-loading
|
||||
access pattern. Also emits ep_len/ep_offset like their format."""
|
||||
import sys, time, numpy as np, h5py
|
||||
|
||||
path = sys.argv[1]
|
||||
N = int(sys.argv[2]) if len(sys.argv) > 2 else 20000
|
||||
H = W = 64
|
||||
C = 3
|
||||
rng = np.random.default_rng(0)
|
||||
t0 = time.perf_counter()
|
||||
with h5py.File(path, "w", libver="latest") as f:
|
||||
# Contiguous (N,H,W,C) uint8 — the fair, both-APIs-support-it layout.
|
||||
obs = f.create_dataset("observation", shape=(N, H, W, C), dtype=np.uint8)
|
||||
# Write in blocks to bound memory.
|
||||
B = 2000
|
||||
for i in range(0, N, B):
|
||||
n = min(B, N - i)
|
||||
obs[i:i+n] = rng.integers(0, 256, size=(n, H, W, C), dtype=np.uint8)
|
||||
# Episode metadata like their format: 100-step episodes.
|
||||
ep = 100
|
||||
n_ep = N // ep
|
||||
f.create_dataset("ep_len", data=np.full(n_ep, ep, dtype=np.int32))
|
||||
f.create_dataset("ep_offset", data=(np.arange(n_ep) * ep).astype(np.int64))
|
||||
print(f"wrote {N} frames {H}x{W}x{C} to {path} in {time.perf_counter()-t0:.1f}s "
|
||||
f"({N*H*W*C/1e6:.0f} MB)")
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "clawhdf5-accel"
|
||||
version = "2.1.0"
|
||||
version = "2.3.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"]
|
||||
@@ -15,7 +15,7 @@ float16 = ["dep:half"]
|
||||
avx512 = []
|
||||
|
||||
[dependencies]
|
||||
half = { version = "2", optional = true }
|
||||
half = { workspace = true, optional = true }
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
features = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -361,6 +361,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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]) {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
[package]
|
||||
name = "clawhdf5-agent"
|
||||
version = "2.1.0"
|
||||
version = "2.3.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 }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.3.0", features = ["parallel", "fast-checksum"] }
|
||||
clawhdf5 = { path = "../clawhdf5", version = "2.3.0" }
|
||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.3.0", features = ["mmap"] }
|
||||
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.3.0" }
|
||||
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.3.0", optional = true }
|
||||
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.3.0", optional = true, default-features = false }
|
||||
serde = { workspace = true }
|
||||
byteorder = "1"
|
||||
half = { version = "2", optional = true }
|
||||
half = { workspace = true, optional = true }
|
||||
rayon = { version = "1", optional = true }
|
||||
matrixmultiply = { version = "0.3", optional = true }
|
||||
cblas-sys = { version = "0.1", optional = true }
|
||||
@@ -31,8 +31,8 @@ accelerate-src = { version = "0.3", optional = true }
|
||||
openblas-src = { version = "0.10", optional = true, features = ["cblas"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
criterion = "0.5"
|
||||
tempfile = { workspace = true }
|
||||
criterion = { workspace = true }
|
||||
rayon = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"] }
|
||||
|
||||
@@ -48,6 +48,9 @@ harness = false
|
||||
default = ["float16", "hnsw"]
|
||||
float16 = ["half"]
|
||||
parallel = ["rayon"]
|
||||
# Compress embeddings with Zstd instead of deflate when
|
||||
# `MemoryConfig::compression` is on. Off by default: it links libzstd (C).
|
||||
zstd = ["clawhdf5/zstd"]
|
||||
# HNSW approximate-nearest-neighbour acceleration for the vector stage of
|
||||
# hybrid_search. On by default; the index is rebuilt from the cache on demand
|
||||
# and stays self-consistent with the persisted memory store. Disable with
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
target/
|
||||
artifacts/
|
||||
coverage/
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,28 @@
|
||||
//! - Sorted posting lists by doc_id for cache-friendly access
|
||||
//! - Block-Max WAND early termination
|
||||
|
||||
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;
|
||||
@@ -97,9 +118,11 @@ impl BM25Index {
|
||||
|
||||
let total_max_contribution: f32 = max_tf_score.iter().sum();
|
||||
|
||||
// Threshold for WAND early termination
|
||||
// Threshold for WAND early termination. `top_k_heap` is a min-heap of
|
||||
// size k (worst-of-the-top-k at the head) so it can be maintained in
|
||||
// O(log k) per update instead of re-sorting the whole buffer.
|
||||
let mut threshold = 0.0f32;
|
||||
let mut top_k_scores: Vec<f32> = Vec::with_capacity(k);
|
||||
let mut top_k_heap: BinaryHeap<Reverse<HeapScore>> = BinaryHeap::with_capacity(k);
|
||||
|
||||
for (term_idx, (_, idf, postings)) in query_terms.iter().enumerate() {
|
||||
for &(doc_id, freq) in *postings {
|
||||
@@ -118,24 +141,17 @@ impl BM25Index {
|
||||
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];
|
||||
if top_k_heap.len() >= k {
|
||||
if final_score > threshold {
|
||||
// Replace the current worst-of-top-k.
|
||||
top_k_heap.pop();
|
||||
top_k_heap.push(Reverse(HeapScore(final_score)));
|
||||
threshold = top_k_heap.peek().map(|Reverse(s)| s.0).unwrap_or(0.0);
|
||||
}
|
||||
} 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];
|
||||
} else {
|
||||
top_k_heap.push(Reverse(HeapScore(final_score)));
|
||||
if top_k_heap.len() == k {
|
||||
threshold = top_k_heap.peek().map(|Reverse(s)| s.0).unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ use crate::vector_search;
|
||||
pub struct MemoryCache {
|
||||
pub chunks: Vec<String>,
|
||||
pub embeddings: Vec<Vec<f32>>,
|
||||
/// `embeddings` flattened into one contiguous `[N × embedding_dim]`
|
||||
/// buffer, maintained incrementally alongside `embeddings` (push/update/
|
||||
/// compact) so BLAS/Accelerate batch search can read it directly instead
|
||||
/// of re-flattening the whole corpus on every query.
|
||||
pub embeddings_flat: Vec<f32>,
|
||||
pub source_channels: Vec<String>,
|
||||
pub timestamps: Vec<f64>,
|
||||
pub session_ids: Vec<String>,
|
||||
@@ -24,6 +29,7 @@ impl MemoryCache {
|
||||
Self {
|
||||
chunks: Vec::new(),
|
||||
embeddings: Vec::new(),
|
||||
embeddings_flat: Vec::new(),
|
||||
source_channels: Vec::new(),
|
||||
timestamps: Vec::new(),
|
||||
session_ids: Vec::new(),
|
||||
@@ -35,6 +41,17 @@ impl MemoryCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild `embeddings_flat` from `embeddings` from scratch. Callers that
|
||||
/// populate `embeddings` directly (bulk loads) must call this afterward.
|
||||
pub fn rebuild_flat(&mut self) {
|
||||
self.embeddings_flat.clear();
|
||||
self.embeddings_flat
|
||||
.reserve(self.embeddings.len() * self.embedding_dim);
|
||||
for emb in &self.embeddings {
|
||||
self.embeddings_flat.extend_from_slice(emb);
|
||||
}
|
||||
}
|
||||
|
||||
/// Total number of entries (including tombstoned).
|
||||
pub fn len(&self) -> usize {
|
||||
self.chunks.len()
|
||||
@@ -62,6 +79,7 @@ impl MemoryCache {
|
||||
let idx = self.chunks.len();
|
||||
let norm = vector_search::compute_norm(&embedding);
|
||||
self.chunks.push(chunk);
|
||||
self.embeddings_flat.extend_from_slice(&embedding);
|
||||
self.embeddings.push(embedding);
|
||||
self.source_channels.push(source_channel);
|
||||
self.timestamps.push(timestamp);
|
||||
@@ -100,7 +118,20 @@ impl MemoryCache {
|
||||
if idx < self.chunks.len() {
|
||||
let norm = vector_search::compute_norm(&embedding);
|
||||
self.chunks[idx] = chunk;
|
||||
let dim = self.embedding_dim;
|
||||
let flat_start = idx * dim;
|
||||
let matches_dim =
|
||||
embedding.len() == dim && flat_start + dim <= self.embeddings_flat.len();
|
||||
self.embeddings[idx] = embedding;
|
||||
if matches_dim {
|
||||
self.embeddings_flat[flat_start..flat_start + dim]
|
||||
.copy_from_slice(&self.embeddings[idx]);
|
||||
} else {
|
||||
// Embedding length doesn't match embedding_dim (shouldn't
|
||||
// happen in practice) — fall back to a full rebuild rather
|
||||
// than leave embeddings_flat misaligned with embeddings.
|
||||
self.rebuild_flat();
|
||||
}
|
||||
self.source_channels[idx] = source_channel;
|
||||
self.timestamps[idx] = timestamp;
|
||||
self.session_ids[idx] = session_id;
|
||||
@@ -173,16 +204,125 @@ impl MemoryCache {
|
||||
self.tombstones = new_tombstones;
|
||||
self.norms = new_norms;
|
||||
self.activation_weights = new_activation_weights;
|
||||
self.rebuild_flat();
|
||||
|
||||
(removed, index_map)
|
||||
}
|
||||
|
||||
/// Flatten all embeddings into a single Vec<f32> for HDF5 storage.
|
||||
/// `embeddings_flat` is already maintained incrementally, so this just
|
||||
/// clones it — kept as a method for callers that want an owned copy.
|
||||
pub fn flat_embeddings(&self) -> Vec<f32> {
|
||||
let mut flat = Vec::with_capacity(self.embeddings.len() * self.embedding_dim);
|
||||
for emb in &self.embeddings {
|
||||
flat.extend_from_slice(emb);
|
||||
self.embeddings_flat.clone()
|
||||
}
|
||||
flat
|
||||
}
|
||||
|
||||
#[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_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_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_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_flat, vec![1.0, 1.0, 3.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_flat_matches_manual_flatten() {
|
||||
let mut cache = MemoryCache::new(2);
|
||||
cache.embeddings = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
|
||||
cache.rebuild_flat();
|
||||
assert_eq!(cache.embeddings_flat, vec![1.0, 2.0, 3.0, 4.0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -91,14 +91,22 @@ pub fn merge_vector_keyword(
|
||||
}
|
||||
|
||||
let mut results: Vec<(usize, f32)> = merged.into_iter().collect();
|
||||
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
// Index tie-break: `merged` is a HashMap, so without it the ties that
|
||||
// survive `truncate` differ from run to run.
|
||||
results.sort_by(|a, b| {
|
||||
b.1.partial_cmp(&a.1)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then(a.0.cmp(&b.0))
|
||||
});
|
||||
results.truncate(k);
|
||||
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 +120,13 @@ fn normalize_scores(scores: &[(usize, f32)]) -> Vec<(usize, f32)> {
|
||||
|
||||
let range = max - min;
|
||||
if range == 0.0 {
|
||||
return scores.iter().map(|(idx, _)| (*idx, 0.0)).collect();
|
||||
// All candidates scored the same (including the single-candidate
|
||||
// case), so min-max has no spread to work with. They are all equally
|
||||
// the best match if that score is positive, and all non-matches
|
||||
// otherwise. This used to return 0.0 unconditionally, which erased a
|
||||
// lone perfect match from the fused score.
|
||||
let level = if max > 0.0 { 1.0 } else { 0.0 };
|
||||
return scores.iter().map(|(idx, _)| (*idx, level)).collect();
|
||||
}
|
||||
|
||||
scores
|
||||
@@ -324,10 +338,18 @@ 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 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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
+725
-206
File diff suppressed because it is too large
Load Diff
@@ -748,6 +748,69 @@ impl MemoryBackend for ClawhdfBackend {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Ephemeral tier methods on ClawhdfBackend
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
impl ClawhdfBackend {
|
||||
/// Enable the ephemeral (in-memory only) working memory tier.
|
||||
pub fn enable_ephemeral(&mut self, config: crate::ephemeral::EphemeralConfig) {
|
||||
self.memory.enable_ephemeral(config);
|
||||
}
|
||||
|
||||
/// Store a text value in ephemeral memory.
|
||||
///
|
||||
/// Returns an error string if the ephemeral tier has not been enabled.
|
||||
pub fn ephemeral_set(
|
||||
&mut self,
|
||||
key: &str,
|
||||
value: &str,
|
||||
ttl_secs: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
match self.memory.ephemeral_mut() {
|
||||
Some(s) => {
|
||||
s.set_text(key, value, ttl_secs);
|
||||
Ok(())
|
||||
}
|
||||
None => Err("ephemeral tier not enabled".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve a text value from ephemeral memory.
|
||||
///
|
||||
/// Returns `None` if the tier is disabled, the key is absent, or the
|
||||
/// entry has expired.
|
||||
pub fn ephemeral_get(&mut self, key: &str) -> Option<String> {
|
||||
self.memory
|
||||
.ephemeral_mut()?
|
||||
.get_text(key)
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Delete a key from ephemeral memory.
|
||||
///
|
||||
/// Returns `true` if the key existed and was removed.
|
||||
pub fn ephemeral_delete(&mut self, key: &str) -> bool {
|
||||
self.memory.ephemeral_mut().is_some_and(|s| s.delete(key))
|
||||
}
|
||||
|
||||
/// Return a snapshot of ephemeral tier statistics, or `None` if the tier
|
||||
/// is not enabled.
|
||||
pub fn ephemeral_stats(&self) -> Option<crate::ephemeral::EphemeralStats> {
|
||||
self.memory.ephemeral().map(|s| s.stats())
|
||||
}
|
||||
|
||||
/// Promote frequently-accessed ephemeral entries to persistent HDF5 storage.
|
||||
///
|
||||
/// Entries with `access_count >= min_access_count` are moved from the
|
||||
/// ephemeral store into the persistent cache. Returns the count promoted.
|
||||
pub fn promote_ephemeral(&mut self, min_access_count: u32) -> Result<usize, String> {
|
||||
self.memory
|
||||
.promote_ephemeral(min_access_count)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1333,66 +1396,3 @@ mod tests {
|
||||
assert!(out.starts_with("# Title"));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Ephemeral tier methods on ClawhdfBackend
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
impl ClawhdfBackend {
|
||||
/// Enable the ephemeral (in-memory only) working memory tier.
|
||||
pub fn enable_ephemeral(&mut self, config: crate::ephemeral::EphemeralConfig) {
|
||||
self.memory.enable_ephemeral(config);
|
||||
}
|
||||
|
||||
/// Store a text value in ephemeral memory.
|
||||
///
|
||||
/// Returns an error string if the ephemeral tier has not been enabled.
|
||||
pub fn ephemeral_set(
|
||||
&mut self,
|
||||
key: &str,
|
||||
value: &str,
|
||||
ttl_secs: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
match self.memory.ephemeral_mut() {
|
||||
Some(s) => {
|
||||
s.set_text(key, value, ttl_secs);
|
||||
Ok(())
|
||||
}
|
||||
None => Err("ephemeral tier not enabled".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve a text value from ephemeral memory.
|
||||
///
|
||||
/// Returns `None` if the tier is disabled, the key is absent, or the
|
||||
/// entry has expired.
|
||||
pub fn ephemeral_get(&mut self, key: &str) -> Option<String> {
|
||||
self.memory
|
||||
.ephemeral_mut()?
|
||||
.get_text(key)
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Delete a key from ephemeral memory.
|
||||
///
|
||||
/// Returns `true` if the key existed and was removed.
|
||||
pub fn ephemeral_delete(&mut self, key: &str) -> bool {
|
||||
self.memory.ephemeral_mut().is_some_and(|s| s.delete(key))
|
||||
}
|
||||
|
||||
/// Return a snapshot of ephemeral tier statistics, or `None` if the tier
|
||||
/// is not enabled.
|
||||
pub fn ephemeral_stats(&self) -> Option<crate::ephemeral::EphemeralStats> {
|
||||
self.memory.ephemeral().map(|s| s.stats())
|
||||
}
|
||||
|
||||
/// Promote frequently-accessed ephemeral entries to persistent HDF5 storage.
|
||||
///
|
||||
/// Entries with `access_count >= min_access_count` are moved from the
|
||||
/// ephemeral store into the persistent cache. Returns the count promoted.
|
||||
pub fn promote_ephemeral(&mut self, min_access_count: u32) -> Result<usize, String> {
|
||||
self.memory
|
||||
.promote_ephemeral(min_access_count)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Memory provenance tracking and integrity verification.
|
||||
//!
|
||||
//! Records the origin, authorship, and integrity of every memory chunk
|
||||
//! so the system can detect tampering and trace data lineage.
|
||||
//! Records the origin, authorship, and a content hash of every memory chunk
|
||||
//! so the system can detect *accidental* corruption and trace data lineage.
|
||||
//! The hash is unkeyed (see [`fnv1a_64`]) — this is not a tamper-evidence or
|
||||
//! authenticity guarantee.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -11,6 +13,10 @@ pub use crate::consolidation::MemorySource;
|
||||
// Hash helper (std-only FNV-1a 64-bit)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Unkeyed, non-cryptographic FNV-1a hash for detecting accidental content
|
||||
/// corruption. It is trivially forgeable by anyone able to modify the stored
|
||||
/// data, since they can recompute and overwrite the stored hash alongside
|
||||
/// it — do not rely on this as a tamper-evidence or authenticity control.
|
||||
fn fnv1a_64(text: &str) -> u64 {
|
||||
const OFFSET: u64 = 14_695_981_039_346_656_037;
|
||||
const PRIME: u64 = 1_099_511_628_211;
|
||||
@@ -99,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)
|
||||
@@ -114,6 +137,11 @@ impl ProvenanceStore {
|
||||
|
||||
/// Re-hash `current_chunk` and compare against the stored hash.
|
||||
/// Returns `true` if the content matches (integrity intact).
|
||||
///
|
||||
/// This only detects accidental corruption: the hash is unkeyed, so an
|
||||
/// actor able to modify the stored chunk can also recompute and
|
||||
/// overwrite the stored hash. Do not treat a `true` result as proof the
|
||||
/// data hasn't been tampered with.
|
||||
pub fn verify_integrity(&self, record_id: u64, current_chunk: &str) -> bool {
|
||||
match self.records.get(&record_id) {
|
||||
Some(p) => p.content_hash == fnv1a_64(current_chunk),
|
||||
|
||||
@@ -12,16 +12,36 @@ 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";
|
||||
|
||||
/// Build a complete HDF5 file from the in-memory state.
|
||||
pub fn build_hdf5_file(
|
||||
config: &MemoryConfig,
|
||||
cache: &MemoryCache,
|
||||
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 mut builder = clawhdf5::FileBuilder::new();
|
||||
|
||||
@@ -34,10 +54,35 @@ pub fn build_hdf5_file(
|
||||
meta.set_attr("embedding_dim", AttrValue::I64(config.embedding_dim as i64));
|
||||
meta.set_attr("chunk_size", AttrValue::I64(config.chunk_size as i64));
|
||||
meta.set_attr("overlap", AttrValue::I64(config.overlap as i64));
|
||||
// Behavioural settings. These used to live only in memory, so reopening a
|
||||
// store silently reset them to defaults — e.g. a compressed store was
|
||||
// rewritten uncompressed by the first checkpoint after a reopen. Loaders
|
||||
// treat each one as optional so older files keep opening.
|
||||
meta.set_attr("float16", AttrValue::I64(config.float16.into()));
|
||||
meta.set_attr("compression", AttrValue::I64(config.compression.into()));
|
||||
meta.set_attr(
|
||||
"compression_level",
|
||||
AttrValue::I64(config.compression_level.into()),
|
||||
);
|
||||
meta.set_attr(
|
||||
"compact_threshold",
|
||||
AttrValue::F64(config.compact_threshold.into()),
|
||||
);
|
||||
meta.set_attr("hebbian_boost", AttrValue::F64(config.hebbian_boost.into()));
|
||||
meta.set_attr("decay_factor", AttrValue::F64(config.decay_factor.into()));
|
||||
meta.set_attr("wal_enabled", AttrValue::I64(config.wal_enabled.into()));
|
||||
meta.set_attr(
|
||||
"wal_max_entries",
|
||||
AttrValue::I64(config.wal_max_entries as i64),
|
||||
);
|
||||
meta.set_attr(
|
||||
"edgehdf5_version",
|
||||
AttrValue::String(ZEROCLAW_VERSION.into()),
|
||||
);
|
||||
if let Some(mark) = wal_applied.filter(|m| m.len > 0) {
|
||||
meta.set_attr(WAL_APPLIED_LEN_ATTR, AttrValue::I64(mark.len as i64));
|
||||
meta.set_attr(WAL_APPLIED_CRC_ATTR, AttrValue::I64(i64::from(mark.crc)));
|
||||
}
|
||||
// Need at least one dataset in the group for it to be a proper group
|
||||
meta.create_dataset("_marker").with_u8_data(&[1]).compact();
|
||||
let finished_meta = meta.finish();
|
||||
@@ -83,16 +128,34 @@ fn build_memory_group(
|
||||
let rows_per_chunk = (target_chunk_bytes / (d * 4)).max(1).min(n);
|
||||
ds.with_chunks(&[rows_per_chunk, d]);
|
||||
|
||||
// Compression: Zstd for embeddings — faster than deflate at same ratio.
|
||||
// Shuffle is applied automatically (auto-shuffle pre-filter).
|
||||
// Compression. Shuffle is applied automatically (auto-shuffle
|
||||
// pre-filter). Zstd is faster than deflate at the same ratio but
|
||||
// pulls in libzstd, so it is opt-in via the `zstd` feature; the
|
||||
// default build uses deflate, which is always available. (This
|
||||
// used to call `with_zstd` unconditionally, so without the
|
||||
// feature every checkpoint of a compressed store failed with
|
||||
// "unsupported filter: 32015".) Both are standard HDF5 filters;
|
||||
// reading a zstd-compressed store needs a zstd-enabled build.
|
||||
if config.compression {
|
||||
#[cfg(feature = "zstd")]
|
||||
{
|
||||
let level = if config.compression_level > 0 {
|
||||
config.compression_level.min(22)
|
||||
} else {
|
||||
3 // Zstd level 3: fast + good ratio for f32 embeddings
|
||||
3 // fast + good ratio for f32 embeddings
|
||||
};
|
||||
ds.with_zstd(level);
|
||||
}
|
||||
#[cfg(not(feature = "zstd"))]
|
||||
{
|
||||
let level = if config.compression_level > 0 {
|
||||
config.compression_level.min(9)
|
||||
} else {
|
||||
4
|
||||
};
|
||||
ds.with_deflate(level);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip fill-value initialization — embeddings are fully written
|
||||
@@ -309,6 +372,20 @@ 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 })
|
||||
}
|
||||
|
||||
pub fn validate_and_load(
|
||||
file: &clawhdf5::File,
|
||||
) -> Result<(MemoryConfig, MemoryCache, SessionCache, KnowledgeCache), MemoryError> {
|
||||
@@ -344,15 +421,19 @@ pub fn validate_and_load(
|
||||
embedding_dim,
|
||||
chunk_size,
|
||||
overlap,
|
||||
float16: false,
|
||||
compression: false,
|
||||
compression_level: 0,
|
||||
compact_threshold: 0.3,
|
||||
hebbian_boost: 0.15,
|
||||
decay_factor: 0.98,
|
||||
float16: optional_bool_attr(&attrs, "float16", false),
|
||||
compression: optional_bool_attr(&attrs, "compression", false),
|
||||
compression_level: optional_i64_attr(&attrs, "compression_level")
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.unwrap_or(0),
|
||||
compact_threshold: optional_f32_attr(&attrs, "compact_threshold", 0.3),
|
||||
hebbian_boost: optional_f32_attr(&attrs, "hebbian_boost", 0.15),
|
||||
decay_factor: optional_f32_attr(&attrs, "decay_factor", 0.98),
|
||||
created_at,
|
||||
wal_enabled: true,
|
||||
wal_max_entries: 500,
|
||||
wal_enabled: optional_bool_attr(&attrs, "wal_enabled", true),
|
||||
wal_max_entries: optional_i64_attr(&attrs, "wal_max_entries")
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.unwrap_or(500),
|
||||
};
|
||||
|
||||
// Load /memory group
|
||||
@@ -391,19 +472,45 @@ fn load_memory_group(
|
||||
let tags = read_string_dataset_from_group(&group, "tags")?;
|
||||
let tombstones = read_u8_dataset(&group, "tombstones")?;
|
||||
|
||||
// Read norms if present, otherwise compute from embeddings
|
||||
// Every per-record dataset must describe exactly `n` records. Without
|
||||
// this, a truncated or hand-edited file loads "successfully" and then
|
||||
// panics on the first out-of-bounds index during search/delete.
|
||||
if embedding_dim == 0 {
|
||||
return Err(MemoryError::Schema(format!(
|
||||
"/memory has {n} records but embedding_dim is 0"
|
||||
)));
|
||||
}
|
||||
let expected_flat = n.checked_mul(embedding_dim).ok_or_else(|| {
|
||||
MemoryError::Schema(format!("/memory size overflow: {n} x {embedding_dim}"))
|
||||
})?;
|
||||
let check_len = |name: &str, actual: usize, expected: usize| {
|
||||
if actual == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(MemoryError::Schema(format!(
|
||||
"/memory/{name} has {actual} entries, expected {expected} \
|
||||
({n} records)"
|
||||
)))
|
||||
}
|
||||
};
|
||||
check_len("embeddings", flat_embeddings.len(), expected_flat)?;
|
||||
check_len("source_channel", source_channels.len(), n)?;
|
||||
check_len("timestamps", timestamps.len(), n)?;
|
||||
check_len("session_ids", session_ids.len(), n)?;
|
||||
check_len("tags", tags.len(), n)?;
|
||||
check_len("tombstones", tombstones.len(), n)?;
|
||||
|
||||
// Norms are derived data: use the stored ones only if they are present
|
||||
// and the right length, otherwise recompute from the embeddings.
|
||||
let norms = match read_f32_dataset(&group, "norms") {
|
||||
Ok(n) if n.len() == n.len() => n,
|
||||
_ => {
|
||||
// Compute norms from flat embeddings
|
||||
flat_embeddings
|
||||
Ok(stored) if stored.len() == n => stored,
|
||||
_ => flat_embeddings
|
||||
.chunks(embedding_dim)
|
||||
.map(|chunk| {
|
||||
let sq_sum: f32 = chunk.iter().map(|x| x * x).sum();
|
||||
sq_sum.sqrt()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
.collect(),
|
||||
};
|
||||
|
||||
// Unflatten embeddings
|
||||
@@ -427,6 +534,7 @@ fn load_memory_group(
|
||||
cache.tombstones = tombstones;
|
||||
cache.norms = norms;
|
||||
cache.activation_weights = activation_weights;
|
||||
cache.rebuild_flat();
|
||||
|
||||
Ok(cache)
|
||||
}
|
||||
@@ -480,6 +588,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 +638,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 +744,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(|_| ())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,13 +113,24 @@ 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();
|
||||
|
||||
|
||||
@@ -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,20 @@ 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 bytes = schema::build_hdf5_file_with_mark(config, cache, sessions, knowledge, wal_applied)?;
|
||||
|
||||
if bytes.is_empty() {
|
||||
return Err(MemoryError::Hdf5("build_hdf5_file produced 0 bytes".into()));
|
||||
@@ -28,9 +42,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.
|
||||
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.
|
||||
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 +88,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 +108,9 @@ 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))
|
||||
}
|
||||
|
||||
/// Copy an HDF5 file atomically to a destination.
|
||||
@@ -78,7 +134,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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -137,12 +137,12 @@ fn bench_hit_at_1_1014_records() {
|
||||
0.3,
|
||||
1,
|
||||
);
|
||||
if let Some((top_idx, _)) = results.first() {
|
||||
if *top_idx == target_indices[qi] {
|
||||
if let Some((top_idx, _)) = results.first()
|
||||
&& *top_idx == target_indices[qi]
|
||||
{
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hit_at_1 = hits as f64 / NUM_QUERIES as f64;
|
||||
println!(
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "clawhdf5-android"
|
||||
version = "2.1.0"
|
||||
version = "2.3.0"
|
||||
edition = "2024"
|
||||
description = "Android JNI bridge for edgehdf5-memory HDF5 backend"
|
||||
license = "MIT"
|
||||
@@ -10,3 +10,6 @@ crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
clawhdf5-agent = { path = "../clawhdf5-agent", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -92,11 +92,18 @@ pub unsafe extern "C" fn edgehdf5_close(handle: Handle) {
|
||||
|
||||
/// Save a memory entry. Returns the entry index, or -1 on failure.
|
||||
///
|
||||
/// `embedding_len` is validated against the handle's configured
|
||||
/// `embedding_dim` before the input slice is constructed; a mismatch fails
|
||||
/// the call with -1 rather than reading out of bounds. This is a length
|
||||
/// check only — it cannot detect a same-length buffer that is otherwise
|
||||
/// too short or invalid.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// - `handle` must be a valid, non-null handle.
|
||||
/// - All `*const c_char` arguments must be valid, null-terminated C strings.
|
||||
/// - `embedding_ptr` must point to at least `embedding_len` contiguous `f32` values.
|
||||
/// - If `embedding_len` matches the handle's `embedding_dim`, `embedding_ptr`
|
||||
/// must point to at least that many contiguous, valid `f32` values.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn edgehdf5_save(
|
||||
handle: Handle,
|
||||
@@ -135,8 +142,14 @@ pub unsafe extern "C" fn edgehdf5_save(
|
||||
None => return -1,
|
||||
};
|
||||
|
||||
if embedding_ptr.is_null() || embedding_len as usize != mem.config().embedding_dim {
|
||||
return -1;
|
||||
}
|
||||
let embedding =
|
||||
// SAFETY: JNI caller guarantees embedding_ptr points to embedding_len valid f32 values.
|
||||
// SAFETY: embedding_ptr is non-null and embedding_len matches the handle's configured
|
||||
// embedding_dim (checked above); JNI caller guarantees it points to that many valid f32
|
||||
// values. A mismatched-but-equal-length short buffer is not caught by this length check
|
||||
// alone — the caller is still responsible for pointer validity.
|
||||
unsafe { std::slice::from_raw_parts(embedding_ptr, embedding_len as usize) }.to_vec();
|
||||
|
||||
let entry = MemoryEntry {
|
||||
@@ -210,11 +223,18 @@ pub unsafe extern "C" fn edgehdf5_delete(handle: Handle, index: u64) -> i32 {
|
||||
/// Performs hybrid search and writes up to `max_results` entries into the
|
||||
/// provided output arrays. Returns the number of results written.
|
||||
///
|
||||
/// `query_embedding_len` is validated against the handle's configured
|
||||
/// `embedding_dim` before the input slice is constructed; a mismatch fails
|
||||
/// the call (returns 0) rather than reading out of bounds. This is a length
|
||||
/// check only — it cannot detect a same-length buffer that is otherwise too
|
||||
/// short or invalid.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// - `handle` must be a valid, non-null handle.
|
||||
/// - `query_text` must be a valid, null-terminated C string.
|
||||
/// - `query_embedding_ptr` must point to at least `query_embedding_len` `f32` values.
|
||||
/// - If `query_embedding_len` matches the handle's `embedding_dim`,
|
||||
/// `query_embedding_ptr` must point to at least that many valid `f32` values.
|
||||
/// - `out_indices` and `out_scores` must point to arrays of at least `max_results` elements.
|
||||
/// - `out_chunks` must be null or point to an array of at least `max_results` pointers.
|
||||
#[unsafe(no_mangle)]
|
||||
@@ -240,8 +260,14 @@ pub unsafe extern "C" fn edgehdf5_hybrid_search(
|
||||
Some(s) => s,
|
||||
None => return 0,
|
||||
};
|
||||
if query_embedding_ptr.is_null() || query_embedding_len as usize != mem.config().embedding_dim {
|
||||
return 0;
|
||||
}
|
||||
let query_embedding =
|
||||
// SAFETY: JNI caller guarantees query_embedding_ptr points to query_embedding_len valid f32 values.
|
||||
// SAFETY: query_embedding_ptr is non-null and query_embedding_len matches the handle's
|
||||
// configured embedding_dim (checked above); JNI caller guarantees it points to that many
|
||||
// valid f32 values. A mismatched-but-equal-length short buffer is not caught by this
|
||||
// length check alone — the caller is still responsible for pointer validity.
|
||||
unsafe { std::slice::from_raw_parts(query_embedding_ptr, query_embedding_len as usize) };
|
||||
|
||||
let results = mem.hybrid_search(
|
||||
@@ -456,3 +482,112 @@ unsafe fn cstr_to_string(ptr: *const c_char) -> Option<String> {
|
||||
.ok()
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EMBEDDING_DIM: u32 = 4;
|
||||
|
||||
fn open_handle(dir: &tempfile::TempDir) -> Handle {
|
||||
let path = CString::new(dir.path().join("mem.h5").to_str().unwrap()).unwrap();
|
||||
let agent_id = CString::new("test-agent").unwrap();
|
||||
// SAFETY: both C strings are valid and null-terminated.
|
||||
unsafe { edgehdf5_create(path.as_ptr(), agent_id.as_ptr(), EMBEDDING_DIM) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_rejects_mismatched_embedding_len() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let handle = open_handle(&dir);
|
||||
assert!(!handle.is_null());
|
||||
|
||||
let embedding = [1.0f32, 2.0, 3.0]; // len 3, dim is 4
|
||||
let chunk = CString::new("hello").unwrap();
|
||||
let channel = CString::new("test").unwrap();
|
||||
let session = CString::new("s1").unwrap();
|
||||
let tags = CString::new("").unwrap();
|
||||
|
||||
// SAFETY: handle is valid; all C strings are valid; embedding_len (3) intentionally
|
||||
// does not match embedding_dim (4), which edgehdf5_save must reject before touching
|
||||
// embedding_ptr.
|
||||
let result = unsafe {
|
||||
edgehdf5_save(
|
||||
handle,
|
||||
chunk.as_ptr(),
|
||||
embedding.as_ptr(),
|
||||
embedding.len() as u32,
|
||||
channel.as_ptr(),
|
||||
0.0,
|
||||
session.as_ptr(),
|
||||
tags.as_ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(result, -1, "mismatched embedding_len must be rejected");
|
||||
|
||||
unsafe { edgehdf5_close(handle) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_rejects_null_embedding_ptr() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let handle = open_handle(&dir);
|
||||
assert!(!handle.is_null());
|
||||
|
||||
let chunk = CString::new("hello").unwrap();
|
||||
let channel = CString::new("test").unwrap();
|
||||
let session = CString::new("s1").unwrap();
|
||||
let tags = CString::new("").unwrap();
|
||||
|
||||
// SAFETY: handle and C strings are valid; embedding_ptr is intentionally null, which
|
||||
// edgehdf5_save must reject before constructing a slice from it.
|
||||
let result = unsafe {
|
||||
edgehdf5_save(
|
||||
handle,
|
||||
chunk.as_ptr(),
|
||||
ptr::null(),
|
||||
EMBEDDING_DIM,
|
||||
channel.as_ptr(),
|
||||
0.0,
|
||||
session.as_ptr(),
|
||||
tags.as_ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(result, -1, "null embedding_ptr must be rejected");
|
||||
|
||||
unsafe { edgehdf5_close(handle) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hybrid_search_rejects_mismatched_embedding_len() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let handle = open_handle(&dir);
|
||||
assert!(!handle.is_null());
|
||||
|
||||
let query_embedding = [1.0f32, 2.0]; // len 2, dim is 4
|
||||
let query_text = CString::new("hello").unwrap();
|
||||
let mut out_indices = [0u64; 4];
|
||||
let mut out_scores = [0.0f32; 4];
|
||||
|
||||
// SAFETY: handle and query_text are valid; query_embedding_len (2) intentionally does
|
||||
// not match embedding_dim (4), which edgehdf5_hybrid_search must reject before touching
|
||||
// query_embedding_ptr. Output buffers are sized to max_results.
|
||||
let count = unsafe {
|
||||
edgehdf5_hybrid_search(
|
||||
handle,
|
||||
query_embedding.as_ptr(),
|
||||
query_embedding.len() as u32,
|
||||
query_text.as_ptr(),
|
||||
0.7,
|
||||
0.3,
|
||||
4,
|
||||
out_indices.as_mut_ptr(),
|
||||
out_scores.as_mut_ptr(),
|
||||
ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
assert_eq!(count, 0, "mismatched query_embedding_len must be rejected");
|
||||
|
||||
unsafe { edgehdf5_close(handle) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
[package]
|
||||
name = "clawhdf5-ann"
|
||||
version = "2.1.0"
|
||||
version = "2.3.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.3.0" }
|
||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.3.0" }
|
||||
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.3.0" }
|
||||
rayon = { version = "1", optional = true }
|
||||
|
||||
[features]
|
||||
parallel = ["rayon"]
|
||||
|
||||
@@ -44,32 +44,14 @@ impl DistanceMetric {
|
||||
}
|
||||
|
||||
/// Compute distance between two vectors using the given metric.
|
||||
///
|
||||
/// Delegates to `clawhdf5-accel`'s runtime-dispatched SIMD kernels (AVX2 on
|
||||
/// x86_64, NEON on aarch64, portable scalar fallback elsewhere) — this is
|
||||
/// the hottest loop in both HNSW build and every `hybrid_search` query.
|
||||
fn compute_distance(a: &[f32], b: &[f32], metric: DistanceMetric) -> f32 {
|
||||
match metric {
|
||||
DistanceMetric::L2 => {
|
||||
let mut sum = 0.0f32;
|
||||
for i in 0..a.len() {
|
||||
let d = a[i] - b[i];
|
||||
sum += d * d;
|
||||
}
|
||||
sum.sqrt()
|
||||
}
|
||||
DistanceMetric::Cosine => {
|
||||
let mut dot = 0.0f32;
|
||||
let mut norm_a = 0.0f32;
|
||||
let mut norm_b = 0.0f32;
|
||||
for i in 0..a.len() {
|
||||
dot += a[i] * b[i];
|
||||
norm_a += a[i] * a[i];
|
||||
norm_b += b[i] * b[i];
|
||||
}
|
||||
let denom = norm_a.sqrt() * norm_b.sqrt();
|
||||
if denom < f32::EPSILON {
|
||||
1.0
|
||||
} else {
|
||||
1.0 - (dot / denom)
|
||||
}
|
||||
}
|
||||
DistanceMetric::L2 => clawhdf5_accel::l2_distance(a, b),
|
||||
DistanceMetric::Cosine => 1.0 - clawhdf5_accel::cosine_similarity(a, b),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -857,6 +839,15 @@ fn prune_connections(
|
||||
if neighbors.len() <= max_conn {
|
||||
return;
|
||||
}
|
||||
#[cfg(feature = "parallel")]
|
||||
let mut scored: Vec<(usize, f32)> = {
|
||||
use rayon::prelude::*;
|
||||
neighbors
|
||||
.par_iter()
|
||||
.map(|&n| (n, compute_distance(&vectors[node], &vectors[n], metric)))
|
||||
.collect()
|
||||
};
|
||||
#[cfg(not(feature = "parallel"))]
|
||||
let mut scored: Vec<(usize, f32)> = neighbors
|
||||
.iter()
|
||||
.map(|&n| (n, compute_distance(&vectors[node], &vectors[n], metric)))
|
||||
@@ -1309,6 +1300,18 @@ mod tests {
|
||||
assert!((d - 1.0).abs() < 1e-6); // zero vector -> distance 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_near_zero_vector() {
|
||||
// Tiny-but-nonzero, identical-direction vectors: denom is well
|
||||
// below f32::EPSILON but not exactly 0.0. Must still be treated
|
||||
// as a degenerate/unreliable direction (distance 1, "maximally
|
||||
// dissimilar"), not as an exact match (distance 0).
|
||||
let a = vec![1e-4, 1e-4];
|
||||
let b = vec![1e-4, 1e-4];
|
||||
let d = compute_distance(&a, &b, DistanceMetric::Cosine);
|
||||
assert!((d - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_into_empty_index() {
|
||||
let mut index = HnswIndex::new(4, 16, DistanceMetric::L2);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clawhdf5-bench"
|
||||
version = "2.1.0"
|
||||
version = "2.3.0"
|
||||
edition = "2024"
|
||||
description = "Benchmark harnesses for clawhdf5-agent (Track 8)"
|
||||
license = "MIT"
|
||||
@@ -50,19 +50,31 @@ harness = false
|
||||
clawhdf5-agent = { path = "../clawhdf5-agent" }
|
||||
clawhdf5-io = { path = "../clawhdf5-io" }
|
||||
mpi = { version = "0.8", optional = true }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
tempfile = { workspace = true }
|
||||
# Optional: libhdf5 C wrapper for side-by-side comparison (requires system libhdf5).
|
||||
# Enable with: cargo bench -p clawhdf5-bench --features libhdf5-compare
|
||||
# Uses hdf5-metno (fork of hdf5 crate) which supports HDF5 1.14.x.
|
||||
hdf5 = { version = "0.12", optional = true, package = "hdf5-metno" }
|
||||
# Optional: real sentence embeddings for the LongMemEval bench's vector stage.
|
||||
# Enable with: cargo run --release --bin longmemeval_bench --features embeddings
|
||||
# Off by default — nothing in the shipped crates depends on these.
|
||||
candle-core = { version = "0.9", optional = true }
|
||||
candle-nn = { version = "0.9", optional = true }
|
||||
candle-transformers = { version = "0.9", optional = true }
|
||||
tokenizers = { version = "0.21", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
clawhdf5 = { path = "../clawhdf5", features = ["zstd", "pcodec"] }
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
criterion = { workspace = true }
|
||||
|
||||
[features]
|
||||
# When enabled, benchmarks add matching libhdf5 variants for side-by-side comparison.
|
||||
libhdf5-compare = ["hdf5"]
|
||||
mpi-io = ["clawhdf5-io/mpi-io", "mpi"]
|
||||
# Real MiniLM embeddings for longmemeval_bench, so the vector stage is not inert.
|
||||
embeddings = ["candle-core", "candle-nn", "candle-transformers", "tokenizers"]
|
||||
# CUDA-accelerated embedding. MiniLM on a CPU takes hours over the full
|
||||
# longmemeval_s haystack; on a GPU it is minutes.
|
||||
embeddings-cuda = ["embeddings", "candle-core/cuda", "candle-nn/cuda", "candle-transformers/cuda"]
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! World-model sample-loading benchmark — clawhdf5 vs the h5py counterpart.
|
||||
//!
|
||||
//! Reproduces the access pattern of `stable-worldmodel`'s HDF5 dataloader
|
||||
//! (arXiv 2605.21800): a dataset of `(N, H, W, C)` uint8 observation frames,
|
||||
//! read one frame at a time in shuffled (dataloader) order. That paper
|
||||
//! reports generic HDF5 at 1,416–1,474 samples/s (vs Lance 4,815); this
|
||||
//! measures clawhdf5 and h5py on the **same machine and file**, so the
|
||||
//! comparison is hardware-controlled. Absolute numbers are not comparable to
|
||||
//! the paper's (different box, smaller frames, no torch/transform) — only
|
||||
//! clawhdf5-vs-h5py *here* is.
|
||||
//!
|
||||
//! clawhdf5 mmaps the file once and takes a zero-copy `&[u8]` over the
|
||||
//! contiguous observation dataset; frame `i` is a subslice, and the OS pages
|
||||
//! it in on access. Two modes, because fairness demands both:
|
||||
//! * default: sum the frame bytes through the zero-copy view — clawhdf5's
|
||||
//! real advantage, no per-frame allocation;
|
||||
//! * `--copy`: `to_vec()` each frame first, matching h5py's unavoidable
|
||||
//! per-frame numpy materialization, so the two do equal work.
|
||||
//!
|
||||
//! Usage: `... --example worldmodel_sampling -- <file.h5> [passes] [--copy]`
|
||||
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
use clawhdf5::MmapFile;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let path = args
|
||||
.get(1)
|
||||
.expect("usage: worldmodel_sampling <file.h5> [passes] [--copy]");
|
||||
let passes: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
|
||||
let copy = args.iter().any(|a| a == "--copy");
|
||||
|
||||
let file = MmapFile::open(path).expect("open");
|
||||
let ds = file.dataset("observation").expect("observation dataset");
|
||||
let shape = ds.shape().expect("shape");
|
||||
let n = shape[0] as usize;
|
||||
let frame_bytes: usize = shape[1..].iter().map(|&d| d as usize).product();
|
||||
let raw = ds
|
||||
.read_raw_slice()
|
||||
.expect("read_raw_slice")
|
||||
.expect("contiguous zero-copy slice");
|
||||
assert_eq!(raw.len(), n * frame_bytes, "unexpected dataset size");
|
||||
|
||||
let order = shuffled(n);
|
||||
|
||||
let touch = |slice: &[u8]| -> u64 {
|
||||
if copy {
|
||||
let owned = slice.to_vec();
|
||||
owned.iter().map(|&b| u64::from(b)).sum()
|
||||
} else {
|
||||
slice.iter().map(|&b| u64::from(b)).sum()
|
||||
}
|
||||
};
|
||||
|
||||
// Warm one pass (page-in), then time.
|
||||
let mut sink = 0u64;
|
||||
for &i in &order {
|
||||
sink = sink.wrapping_add(touch(&raw[i * frame_bytes..(i + 1) * frame_bytes]));
|
||||
}
|
||||
black_box(sink);
|
||||
|
||||
let t0 = Instant::now();
|
||||
let mut sink = 0u64;
|
||||
for _ in 0..passes {
|
||||
for &i in &order {
|
||||
sink = sink.wrapping_add(touch(&raw[i * frame_bytes..(i + 1) * frame_bytes]));
|
||||
}
|
||||
}
|
||||
black_box(sink);
|
||||
let elapsed = t0.elapsed().as_secs_f64();
|
||||
|
||||
let total = (n * passes) as f64;
|
||||
let mode = if copy {
|
||||
"materialized copy"
|
||||
} else {
|
||||
"zero-copy view"
|
||||
};
|
||||
println!("clawhdf5 ({mode}): {n} frames x {passes} passes in {elapsed:.3}s");
|
||||
println!("clawhdf5 ({mode}): {:.0} samples/sec", total / elapsed);
|
||||
}
|
||||
|
||||
fn shuffled(n: usize) -> Vec<usize> {
|
||||
let mut v: Vec<usize> = (0..n).collect();
|
||||
let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
|
||||
for i in (1..n).rev() {
|
||||
state = state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
let j = (state >> 33) as usize % (i + 1);
|
||||
v.swap(i, j);
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -4,11 +4,39 @@
|
||||
//! Since no embedding model is available at bench time, all embeddings are zero vectors
|
||||
//! and `hybrid_search` operates in BM25-only mode (vector_weight=0.0, keyword_weight=1.0).
|
||||
//!
|
||||
//! This matches the MemX paper methodology: evaluate retrieval recall, not answer generation.
|
||||
//! # Scoring target (read before citing any number from this harness)
|
||||
//!
|
||||
//! - **Metric: retrieval recall.** A "hit" means the gold-labelled memory appeared in
|
||||
//! the top-k. No answer is generated and none is scored — the dataset's `answer`
|
||||
//! field is deserialized and deliberately never read. This is **not** the official
|
||||
//! LongMemEval metric, which is end-to-end QA accuracy (retrieve → generate → LLM
|
||||
//! judge). Reporting retrieval recall as QA accuracy overstates by 20–30 points.
|
||||
//! - **Dataset: whichever variant you point it at.** Both `longmemeval_oracle`
|
||||
//! (evidence sessions only — a substantially easier corpus) and the full
|
||||
//! `longmemeval_s` haystack are supported. The harness does not trust the
|
||||
//! filename: [`DatasetProfile`] measures evidence-session density from the
|
||||
//! data and labels the run from that, so a mislabelled input cannot produce a
|
||||
//! mislabelled result.
|
||||
//! - **Session-level metrics are degenerate when evidence density is high**, and
|
||||
//! the report says so per run rather than assuming it. On the oracle variant
|
||||
//! the haystack is essentially all-evidence, so any returned document is a
|
||||
//! session-level hit at rank 0 by construction; only turn-level
|
||||
//! (`has_answer == true` on the source turn) measures the retriever there. On
|
||||
//! the full haystack, session-level recall is meaningful.
|
||||
//! - **Not comparable to MemX's Hit@5=51.6% / MRR=0.380**, which is *fact-level*
|
||||
//! granularity over 220,349 records from 19,195 sessions.
|
||||
//!
|
||||
//! See `BENCHMARKS.md` § "Retracted: session-level recall and the MemX comparison".
|
||||
//!
|
||||
//! # Usage
|
||||
//! ```
|
||||
//! cargo run --release --bin longmemeval_bench [path/to/longmemeval_oracle.json]
|
||||
//! cargo run --release --bin longmemeval_bench [PATH] [--limit N]
|
||||
//!
|
||||
//! # Usage: full haystack
|
||||
//! ```
|
||||
//! cargo run --release --bin longmemeval_bench -- \
|
||||
//! benchmarks/longmemeval/longmemeval_s_cleaned.json --limit 50
|
||||
//! ```
|
||||
//! ```
|
||||
//!
|
||||
//! # WASM Note
|
||||
@@ -21,12 +49,80 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// `#[path]` keeps the module beside its binary without Cargo autodiscovering it
|
||||
// as a second bin target (which a bare `src/bin/embedder.rs` would be).
|
||||
#[cfg(feature = "embeddings")]
|
||||
#[path = "longmemeval_bench/embedder.rs"]
|
||||
mod embedder;
|
||||
|
||||
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
||||
use serde::Deserialize;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const EMBEDDING_DIM: usize = 384;
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
};
|
||||
#[cfg(feature = "embeddings")]
|
||||
const VECTOR_ONLY: Mode = Mode {
|
||||
label: "Vector only (MiniLM + HNSW)",
|
||||
vector_weight: 1.0,
|
||||
keyword_weight: 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,
|
||||
};
|
||||
|
||||
/// Every 0.1 step of vector weight, keyword weight taking the remainder.
|
||||
///
|
||||
/// Labels are leaked to `&'static str` because `Mode::label` is a `&'static
|
||||
/// str` for the eleven named modes and a sweep is a short-lived process; the
|
||||
/// alternative is threading a lifetime through the whole report path for a
|
||||
/// diagnostic mode.
|
||||
#[cfg(feature = "embeddings")]
|
||||
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,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Text -> embedding, built once for the whole corpus.
|
||||
type EmbeddingMap = HashMap<String, Vec<f32>>;
|
||||
|
||||
/// Look up a real embedding, falling back to zeros when running BM25-only.
|
||||
fn embedding_for(map: Option<&EmbeddingMap>, text: &str) -> Vec<f32> {
|
||||
map.and_then(|m| m.get(text))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| vec![0.0f32; EMBEDDING_DIM])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON data types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -168,7 +264,12 @@ struct EvalResult {
|
||||
latency: Duration,
|
||||
}
|
||||
|
||||
fn evaluate_question(q: &Question, top_k: usize) -> EvalResult {
|
||||
fn evaluate_question(
|
||||
q: &Question,
|
||||
top_k: usize,
|
||||
mode: Mode,
|
||||
embeddings: Option<&EmbeddingMap>,
|
||||
) -> EvalResult {
|
||||
let dir = TempDir::new().expect("failed to create temp dir");
|
||||
let mut config = MemoryConfig::new(dir.path().join("lme.h5"), "lme-bench", EMBEDDING_DIM);
|
||||
config.wal_enabled = false;
|
||||
@@ -190,7 +291,7 @@ fn evaluate_question(q: &Question, top_k: usize) -> EvalResult {
|
||||
for turn in session {
|
||||
entries.push(MemoryEntry {
|
||||
chunk: turn.content.clone(),
|
||||
embedding: vec![0.0f32; EMBEDDING_DIM],
|
||||
embedding: embedding_for(embeddings, &turn.content),
|
||||
source_channel: "longmemeval".to_string(),
|
||||
timestamp: ts,
|
||||
session_id: sess_id.to_string(),
|
||||
@@ -218,10 +319,15 @@ fn evaluate_question(q: &Question, top_k: usize) -> EvalResult {
|
||||
// Set of session IDs that contain the answer
|
||||
let answer_sess_set: HashSet<&str> = q.answer_session_ids.iter().map(String::as_str).collect();
|
||||
|
||||
// Run hybrid search (BM25-only: vector_weight=0.0, keyword_weight=1.0)
|
||||
let zero_emb = vec![0.0f32; EMBEDDING_DIM];
|
||||
let query_emb = embedding_for(embeddings, &q.question);
|
||||
let t0 = Instant::now();
|
||||
let results = memory.hybrid_search(&zero_emb, &q.question, 0.0, 1.0, top_k);
|
||||
let results = memory.hybrid_search(
|
||||
&query_emb,
|
||||
&q.question,
|
||||
mode.vector_weight,
|
||||
mode.keyword_weight,
|
||||
top_k,
|
||||
);
|
||||
let latency = t0.elapsed();
|
||||
|
||||
// Session-level recall
|
||||
@@ -286,17 +392,133 @@ fn evaluate_question(q: &Question, top_k: usize) -> EvalResult {
|
||||
// Report printing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn print_report(overall: &Metrics, by_type: &HashMap<String, Metrics>) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dataset profile — measured, not assumed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shape of the loaded corpus, computed from the data itself.
|
||||
///
|
||||
/// The variant used to be a hardcoded `"oracle"` string in the report and the
|
||||
/// JSON summary, so pointing the harness at `longmemeval_s` would have produced
|
||||
/// full-haystack numbers labelled oracle. Everything here is derived from the
|
||||
/// questions instead, which means the label cannot drift from the corpus and a
|
||||
/// mislabelled input file cannot produce a mislabelled result.
|
||||
struct DatasetProfile {
|
||||
n_questions: usize,
|
||||
mean_sessions: f64,
|
||||
mean_turns: f64,
|
||||
/// Mean over questions of `|answer_sessions| / |haystack_sessions|`.
|
||||
///
|
||||
/// This is what actually decides whether session-level recall means
|
||||
/// anything. At ~1.0 every haystack session is an evidence session, so any
|
||||
/// returned document is a session-level hit by construction.
|
||||
evidence_density: f64,
|
||||
}
|
||||
|
||||
impl DatasetProfile {
|
||||
fn measure(questions: &[Question]) -> Self {
|
||||
let n = questions.len().max(1) as f64;
|
||||
let mut sessions = 0.0;
|
||||
let mut turns = 0.0;
|
||||
let mut density = 0.0;
|
||||
for q in questions {
|
||||
let n_sess = q.haystack_sessions.len();
|
||||
sessions += n_sess as f64;
|
||||
turns += q.haystack_sessions.iter().map(Vec::len).sum::<usize>() as f64;
|
||||
if n_sess > 0 {
|
||||
let evidence: HashSet<&str> =
|
||||
q.answer_session_ids.iter().map(String::as_str).collect();
|
||||
let hit = q
|
||||
.haystack_session_ids
|
||||
.iter()
|
||||
.filter(|id| evidence.contains(id.as_str()))
|
||||
.count();
|
||||
density += hit as f64 / n_sess as f64;
|
||||
}
|
||||
}
|
||||
Self {
|
||||
n_questions: questions.len(),
|
||||
mean_sessions: sessions / n,
|
||||
mean_turns: turns / n,
|
||||
evidence_density: density / n,
|
||||
}
|
||||
}
|
||||
|
||||
/// Above this share of evidence sessions, session-level recall is measuring
|
||||
/// the corpus shape rather than the retriever.
|
||||
const DEGENERACY_THRESHOLD: f64 = 0.9;
|
||||
|
||||
const fn session_level_degenerate(&self) -> bool {
|
||||
self.evidence_density > Self::DEGENERACY_THRESHOLD
|
||||
}
|
||||
|
||||
/// Variant name inferred from evidence density, not from the filename.
|
||||
const fn variant(&self) -> &'static str {
|
||||
if self.session_level_degenerate() {
|
||||
"oracle"
|
||||
} else {
|
||||
"full_haystack"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_report(
|
||||
overall: &Metrics,
|
||||
by_type: &HashMap<String, Metrics>,
|
||||
profile: &DatasetProfile,
|
||||
mode: Mode,
|
||||
) {
|
||||
println!("=================================================================");
|
||||
println!(" LongMemEval Benchmark (BM25-only retrieval, zero embeddings)");
|
||||
println!(" LongMemEval Benchmark — {}", mode.label);
|
||||
println!("=================================================================");
|
||||
println!();
|
||||
println!("Mode: vector_weight=0.0 / keyword_weight=1.0 (pure BM25)");
|
||||
println!("Note: MemX (arxiv:2603.16171) with full system: Hit@5=51.6%, MRR=0.380");
|
||||
println!(" BM25-only numbers are expected to be lower — honest baseline.");
|
||||
println!(
|
||||
"Mode: vector_weight={:.1} / keyword_weight={:.1}",
|
||||
mode.vector_weight, mode.keyword_weight
|
||||
);
|
||||
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");
|
||||
println!(" LongMemEval metric (QA accuracy via retrieve+generate+judge).");
|
||||
println!(
|
||||
"Dataset: {} — {} questions, {:.1} sessions and {:.0} turns per question,",
|
||||
profile.variant(),
|
||||
profile.n_questions,
|
||||
profile.mean_sessions,
|
||||
profile.mean_turns,
|
||||
);
|
||||
println!(
|
||||
" {:.1}% of haystack sessions are evidence sessions.",
|
||||
profile.evidence_density * 100.0
|
||||
);
|
||||
if profile.session_level_degenerate() {
|
||||
println!(" This is the evidence-only corpus, NOT the full longmemeval_s");
|
||||
println!(" haystack — a substantially easier retrieval problem.");
|
||||
} else {
|
||||
println!(" This is a full-haystack corpus: evidence sessions are a small");
|
||||
println!(" minority, so retrieval has to actually discriminate.");
|
||||
}
|
||||
println!();
|
||||
println!("Do NOT compare these to MemX's Hit@5=51.6% / MRR=0.380: that is");
|
||||
println!(" fact-level granularity over 220,349 records from 19,195 sessions.");
|
||||
println!(" Different granularity and a corpus larger by orders of magnitude.");
|
||||
println!();
|
||||
|
||||
println!("## Session-Level Recall (n={})", overall.count);
|
||||
if profile.session_level_degenerate() {
|
||||
println!(
|
||||
" [DEGENERATE — {:.1}% of haystack sessions are evidence sessions, so a",
|
||||
profile.evidence_density * 100.0
|
||||
);
|
||||
println!(" returned document is a session-level hit almost by construction.");
|
||||
println!(" This measures the corpus shape, not the retriever. Use turn-level.]");
|
||||
} else {
|
||||
println!(
|
||||
" [Meaningful on this corpus — only {:.1}% of haystack sessions are",
|
||||
profile.evidence_density * 100.0
|
||||
);
|
||||
println!(" evidence sessions, so a hit reflects the retriever's discrimination.]");
|
||||
}
|
||||
println!(
|
||||
" Hit@1: {:5.1}% Hit@5: {:5.1}% Hit@10: {:5.1}% MRR: {:.4}",
|
||||
overall.hit1_session_pct(),
|
||||
@@ -380,7 +602,26 @@ fn print_report(overall: &Metrics, by_type: &HashMap<String, Metrics>) {
|
||||
println!("```json");
|
||||
println!("{{");
|
||||
println!(" \"benchmark\": \"longmemeval\",");
|
||||
println!(" \"mode\": \"bm25_only\",");
|
||||
println!(
|
||||
" \"mode\": \"vector_{:.1}_keyword_{:.1}\",",
|
||||
mode.vector_weight, mode.keyword_weight
|
||||
);
|
||||
println!(" \"dataset_variant\": \"{}\",", profile.variant());
|
||||
println!(" \"scoring_target\": \"retrieval_recall\",");
|
||||
println!(" \"k\": 10,");
|
||||
println!(
|
||||
" \"session_level_degenerate\": {},",
|
||||
profile.session_level_degenerate()
|
||||
);
|
||||
println!(
|
||||
" \"evidence_session_density\": {:.4},",
|
||||
profile.evidence_density
|
||||
);
|
||||
println!(
|
||||
" \"mean_sessions_per_question\": {:.2},",
|
||||
profile.mean_sessions
|
||||
);
|
||||
println!(" \"mean_turns_per_question\": {:.1},", profile.mean_turns);
|
||||
println!(
|
||||
" \"total_questions\": {},",
|
||||
overall.count + overall.abstention_total
|
||||
@@ -403,10 +644,16 @@ fn print_report(overall: &Metrics, by_type: &HashMap<String, Metrics>) {
|
||||
overall.mrr_turn()
|
||||
);
|
||||
println!(" }},");
|
||||
// `null`, not 0.0 — a corpus with no abstention questions has no abstention
|
||||
// accuracy, and emitting 0.0 reads as total failure at a task never posed.
|
||||
if overall.abstention_total > 0 {
|
||||
println!(
|
||||
" \"abstention_accuracy\": {:.4},",
|
||||
overall.abstention_pct() / 100.0
|
||||
);
|
||||
} else {
|
||||
println!(" \"abstention_accuracy\": null,");
|
||||
}
|
||||
println!(" \"latency_us\": {{");
|
||||
println!(
|
||||
" \"avg\": {:.1}, \"p50\": {:.1}, \"p95\": {:.1}, \"p99\": {:.1}",
|
||||
@@ -425,17 +672,152 @@ fn print_report(overall: &Metrics, by_type: &HashMap<String, Metrics>) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn main() {
|
||||
let json_path = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "benchmarks/longmemeval/longmemeval_oracle.json".to_string());
|
||||
let mut json_path: Option<String> = None;
|
||||
let mut limit: Option<usize> = None;
|
||||
let mut weights_dir: Option<String> = None;
|
||||
let mut sweep = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--limit" => {
|
||||
let v = args.next().expect("--limit needs a value");
|
||||
limit = Some(v.parse().expect("--limit must be a positive integer"));
|
||||
}
|
||||
"--sweep" => sweep = true,
|
||||
"--embeddings" => {
|
||||
weights_dir = Some(args.next().expect("--embeddings needs a directory"));
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
eprintln!(
|
||||
"usage: longmemeval_bench [PATH] [--limit N]\n\n\
|
||||
PATH dataset JSON; defaults to the oracle variant.\n\
|
||||
longmemeval_s works too — the harness measures which\n\
|
||||
variant it was given rather than trusting the filename.\n\
|
||||
--limit evaluate N questions, sampled evenly across the file\n\
|
||||
rather than as a prefix — the dataset is ordered by\n\
|
||||
question type, so a prefix samples one type only.\n\
|
||||
--embeddings DIR\n\
|
||||
directory holding all-MiniLM-L6-v2's model.safetensors\n\
|
||||
and tokenizer.json. Enables the vector stage and reports\n\
|
||||
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\
|
||||
--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."
|
||||
);
|
||||
return;
|
||||
}
|
||||
other => json_path = Some(other.to_string()),
|
||||
}
|
||||
}
|
||||
let json_path =
|
||||
json_path.unwrap_or_else(|| "benchmarks/longmemeval/longmemeval_oracle.json".to_string());
|
||||
|
||||
eprintln!("Loading: {json_path}");
|
||||
let data = std::fs::read_to_string(&json_path)
|
||||
.unwrap_or_else(|e| panic!("Failed to read {json_path}: {e}"));
|
||||
let questions: Vec<Question> = serde_json::from_str(&data).expect("Failed to parse JSON");
|
||||
let mut questions: Vec<Question> = serde_json::from_str(&data).expect("Failed to parse JSON");
|
||||
if let Some(n) = limit
|
||||
&& n < questions.len()
|
||||
{
|
||||
// Stride rather than truncate. The dataset is ordered by question type,
|
||||
// so taking a prefix samples one type: `--limit 20` on longmemeval_s
|
||||
// returns 20 `single-session-user` questions and nothing else, which
|
||||
// reads as a whole-dataset result but is not one.
|
||||
let total = questions.len();
|
||||
let step = total as f64 / n as f64;
|
||||
let keep: HashSet<usize> = (0..n)
|
||||
.map(|i| ((i as f64 * step) as usize).min(total - 1))
|
||||
.collect();
|
||||
questions = questions
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| keep.contains(i))
|
||||
.map(|(_, q)| q)
|
||||
.collect();
|
||||
eprintln!(
|
||||
"Sampling {} of {total} questions, evenly strided (--limit)",
|
||||
questions.len()
|
||||
);
|
||||
}
|
||||
let total = questions.len();
|
||||
eprintln!("Loaded {total} questions");
|
||||
|
||||
let profile = DatasetProfile::measure(&questions);
|
||||
eprintln!(
|
||||
"Corpus: {} variant — {:.1} sessions / {:.0} turns per question, \
|
||||
{:.1}% evidence-session density",
|
||||
profile.variant(),
|
||||
profile.mean_sessions,
|
||||
profile.mean_turns,
|
||||
profile.evidence_density * 100.0,
|
||||
);
|
||||
|
||||
// Build the embedding table once for the whole corpus, if asked for.
|
||||
let embeddings: Option<EmbeddingMap> = weights_dir
|
||||
.as_deref()
|
||||
.map(|dir| load_embeddings(dir, &questions));
|
||||
if embeddings.is_none() && weights_dir.is_some() {
|
||||
eprintln!("warning: --embeddings ignored (build with --features embeddings)");
|
||||
}
|
||||
|
||||
let modes: Vec<Mode> = if embeddings.is_some() {
|
||||
#[cfg(feature = "embeddings")]
|
||||
{
|
||||
if sweep {
|
||||
sweep_modes()
|
||||
} else {
|
||||
vec![BM25_ONLY, VECTOR_ONLY, HYBRID]
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "embeddings"))]
|
||||
{
|
||||
vec![BM25_ONLY]
|
||||
}
|
||||
} else {
|
||||
if sweep {
|
||||
eprintln!("warning: --sweep needs --embeddings; running BM25 only");
|
||||
}
|
||||
vec![BM25_ONLY]
|
||||
};
|
||||
|
||||
for (mode_idx, mode) in modes.iter().enumerate() {
|
||||
eprintln!("[{}/{}] {}", mode_idx + 1, modes.len(), mode.label);
|
||||
run_mode(&questions, *mode, embeddings.as_ref(), &profile);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and encode the corpus. Returns `None` unless the `embeddings` feature
|
||||
/// is compiled in, so the flag degrades to a warning rather than a hard error.
|
||||
#[cfg(feature = "embeddings")]
|
||||
fn load_embeddings(dir: &str, questions: &[Question]) -> EmbeddingMap {
|
||||
let enc = embedder::Embedder::load(std::path::Path::new(dir))
|
||||
.unwrap_or_else(|e| panic!("failed to load embedder from {dir}: {e}"));
|
||||
let texts = questions.iter().flat_map(|q| {
|
||||
q.haystack_sessions
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|t| t.content.clone())
|
||||
.chain(std::iter::once(q.question.clone()))
|
||||
});
|
||||
enc.encode_unique(texts)
|
||||
.unwrap_or_else(|e| panic!("embedding failed: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "embeddings"))]
|
||||
fn load_embeddings(_dir: &str, _questions: &[Question]) -> EmbeddingMap {
|
||||
EmbeddingMap::new()
|
||||
}
|
||||
|
||||
/// Evaluate every question under one retrieval mode and print its report.
|
||||
fn run_mode(
|
||||
questions: &[Question],
|
||||
mode: Mode,
|
||||
embeddings: Option<&EmbeddingMap>,
|
||||
profile: &DatasetProfile,
|
||||
) {
|
||||
let total = questions.len();
|
||||
let mut overall = Metrics::default();
|
||||
let mut by_type: HashMap<String, Metrics> = HashMap::new();
|
||||
|
||||
@@ -444,7 +826,7 @@ fn main() {
|
||||
eprint!("\r [{}/{}] evaluating...", i + 1, total);
|
||||
}
|
||||
|
||||
let result = evaluate_question(q, 10);
|
||||
let result = evaluate_question(q, 10, mode, embeddings);
|
||||
|
||||
let is_abs = q.question_type.ends_with("_abs");
|
||||
let base_type = if is_abs {
|
||||
@@ -509,5 +891,5 @@ fn main() {
|
||||
|
||||
eprintln!("\r [{total}/{total}] done. ");
|
||||
eprintln!();
|
||||
print_report(&overall, &by_type);
|
||||
print_report(&overall, &by_type, profile, mode);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Optional MiniLM sentence embedder for the LongMemEval bench.
|
||||
//!
|
||||
//! Compiled only under the `embeddings` feature, so the default build of a
|
||||
//! project that prides itself on having no heavyweight dependencies stays
|
||||
//! exactly as it was. Without it the bench runs BM25-only, as it always has.
|
||||
//!
|
||||
//! Loads `sentence-transformers/all-MiniLM-L6-v2` — the same checkpoint
|
||||
//! omni-cortex uses — and produces 384-d mean-pooled, L2-normalised sentence
|
||||
//! embeddings, which is the published recipe for this model (mean over token
|
||||
//! states weighted by the attention mask, *not* the `[CLS]` pooler output).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use candle_core::{DType, Device, Tensor};
|
||||
use candle_nn::VarBuilder;
|
||||
use candle_transformers::models::bert::{BertModel, Config, HiddenAct};
|
||||
use tokenizers::Tokenizer;
|
||||
|
||||
/// Sequences encoded per forward pass. Larger batches amortise the transformer
|
||||
/// call; 64 keeps peak memory modest while still saturating a CPU.
|
||||
const BATCH: usize = 64;
|
||||
|
||||
/// A loaded MiniLM encoder.
|
||||
pub struct Embedder {
|
||||
model: BertModel,
|
||||
tokenizer: Tokenizer,
|
||||
device: Device,
|
||||
}
|
||||
|
||||
impl Embedder {
|
||||
/// Load from a directory holding `model.safetensors` and `tokenizer.json`.
|
||||
///
|
||||
/// `config.json` is read when present; otherwise the published MiniLM-L6-v2
|
||||
/// architecture constants are used, which are pinned rather than guessed.
|
||||
pub fn load(dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
// CUDA when the feature is on and a device is actually present; the CPU
|
||||
// path is correct but roughly two orders of magnitude slower, which is
|
||||
// the difference between minutes and most of a day on the full haystack.
|
||||
let device = match Device::new_cuda(0) {
|
||||
Ok(d) => {
|
||||
eprintln!("Embedder: CUDA device 0");
|
||||
d
|
||||
}
|
||||
Err(e) => {
|
||||
// Loud, because the CPU path is correct but ~100x slower: the
|
||||
// full longmemeval_s haystack is minutes on a GPU and most of a
|
||||
// day on 8 cores. Silently falling back looks like a hang.
|
||||
eprintln!("Embedder: CPU — CUDA unavailable ({e})");
|
||||
eprintln!(
|
||||
" WARNING: CPU embedding is roughly two orders of magnitude slower.\n Expect minutes for longmemeval_oracle and many hours for the full\n longmemeval_s haystack. For the GPU path, rebuild with\n `--features embeddings-cuda` and make sure `nvcc` is on PATH\n (it ships in /usr/local/cuda/bin, which is often not exported)."
|
||||
);
|
||||
Device::Cpu
|
||||
}
|
||||
};
|
||||
let weights = dir.join("model.safetensors");
|
||||
let tok_path = dir.join("tokenizer.json");
|
||||
|
||||
let config: Config = match std::fs::read_to_string(dir.join("config.json")) {
|
||||
Ok(raw) => serde_json::from_str(&raw)?,
|
||||
Err(_) => Config {
|
||||
vocab_size: 30_522,
|
||||
hidden_size: 384,
|
||||
num_hidden_layers: 6,
|
||||
num_attention_heads: 12,
|
||||
intermediate_size: 1_536,
|
||||
hidden_act: HiddenAct::Gelu,
|
||||
hidden_dropout_prob: 0.0,
|
||||
max_position_embeddings: 512,
|
||||
type_vocab_size: 2,
|
||||
initializer_range: 0.02,
|
||||
layer_norm_eps: 1e-12,
|
||||
pad_token_id: 0,
|
||||
position_embedding_type: Default::default(),
|
||||
use_cache: false,
|
||||
classifier_dropout: None,
|
||||
model_type: None,
|
||||
},
|
||||
};
|
||||
|
||||
let vb = unsafe { VarBuilder::from_mmaped_safetensors(&[weights], DType::F32, &device)? };
|
||||
let model = BertModel::load(vb, &config)?;
|
||||
let tokenizer = Tokenizer::from_file(&tok_path).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Self {
|
||||
model,
|
||||
tokenizer,
|
||||
device,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode `texts` into 384-d unit vectors, in order.
|
||||
fn encode_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, Box<dyn std::error::Error>> {
|
||||
let mut tk = self.tokenizer.clone();
|
||||
let tk = tk
|
||||
.with_padding(Some(tokenizers::PaddingParams::default()))
|
||||
.with_truncation(Some(tokenizers::TruncationParams {
|
||||
max_length: 512,
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let encodings = tk
|
||||
.encode_batch(texts.to_vec(), true)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let ids: Vec<u32> = encodings
|
||||
.iter()
|
||||
.flat_map(|e| e.get_ids().to_vec())
|
||||
.collect();
|
||||
let mask: Vec<u32> = encodings
|
||||
.iter()
|
||||
.flat_map(|e| e.get_attention_mask().to_vec())
|
||||
.collect();
|
||||
let (b, l) = (encodings.len(), encodings[0].get_ids().len());
|
||||
|
||||
let ids = Tensor::from_vec(ids, (b, l), &self.device)?;
|
||||
let mask = Tensor::from_vec(mask, (b, l), &self.device)?;
|
||||
let type_ids = ids.zeros_like()?;
|
||||
|
||||
let hidden = self.model.forward(&ids, &type_ids, Some(&mask))?;
|
||||
|
||||
// Mean-pool over real tokens only: sum(hidden * mask) / sum(mask).
|
||||
let mask_f = mask.to_dtype(DType::F32)?.unsqueeze(2)?;
|
||||
let summed = hidden.broadcast_mul(&mask_f)?.sum(1)?;
|
||||
let counts = mask_f.sum(1)?.clamp(1e-9, f32::INFINITY)?;
|
||||
let pooled = summed.broadcast_div(&counts)?;
|
||||
|
||||
// L2-normalise so cosine similarity is a plain dot product.
|
||||
let norm = pooled
|
||||
.sqr()?
|
||||
.sum_keepdim(1)?
|
||||
.sqrt()?
|
||||
.clamp(1e-12, f32::INFINITY)?;
|
||||
let normed = pooled.broadcast_div(&norm)?;
|
||||
|
||||
Ok(normed.to_vec2::<f32>()?)
|
||||
}
|
||||
|
||||
/// Encode every distinct string in `texts` once, returning a lookup map.
|
||||
///
|
||||
/// LongMemEval's haystack sessions are drawn from a shared pool, so the same
|
||||
/// turn text recurs across many questions. Deduplicating before encoding is
|
||||
/// the difference between encoding the corpus once and encoding it per
|
||||
/// question.
|
||||
pub fn encode_unique(
|
||||
&self,
|
||||
texts: impl IntoIterator<Item = String>,
|
||||
) -> Result<HashMap<String, Vec<f32>>, Box<dyn std::error::Error>> {
|
||||
let mut unique: Vec<String> = texts.into_iter().collect();
|
||||
unique.sort_unstable();
|
||||
unique.dedup();
|
||||
|
||||
let total = unique.len();
|
||||
eprintln!("Embedding {total} unique texts with MiniLM (batch {BATCH})...");
|
||||
|
||||
let mut out = HashMap::with_capacity(total);
|
||||
for (n, chunk) in unique.chunks(BATCH).enumerate() {
|
||||
let refs: Vec<&str> = chunk.iter().map(String::as_str).collect();
|
||||
let vecs = self.encode_batch(&refs)?;
|
||||
for (text, v) in chunk.iter().zip(vecs) {
|
||||
out.insert(text.clone(), v);
|
||||
}
|
||||
if n % 50 == 0 {
|
||||
eprint!("\r [{}/{}] embedded...", (n * BATCH).min(total), total);
|
||||
}
|
||||
}
|
||||
eprintln!("\r [{total}/{total}] embedded. ");
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "clawhdf5-cli"
|
||||
version = "2.1.0"
|
||||
version = "2.3.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.3.0" }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -146,7 +146,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
Commands::Recall { index } => {
|
||||
let mem = HDF5Memory::open(&cli.path)?;
|
||||
let mem = HDF5Memory::open_read_only(&cli.path)?;
|
||||
match mem.get_chunk(index) {
|
||||
Some(content) => {
|
||||
let j = serde_json::json!({ "index": index, "chunk": content });
|
||||
@@ -160,7 +160,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
Commands::Stats => {
|
||||
let mem = HDF5Memory::open(&cli.path)?;
|
||||
let mem = HDF5Memory::open_read_only(&cli.path)?;
|
||||
let cfg = mem.config();
|
||||
let j = serde_json::json!({
|
||||
"path": cli.path.display().to_string(),
|
||||
@@ -187,7 +187,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
Commands::AgentsMd { output } => {
|
||||
let mem = HDF5Memory::open(&cli.path)?;
|
||||
let mem = HDF5Memory::open_read_only(&cli.path)?;
|
||||
let md = mem.generate_agents_md();
|
||||
match output {
|
||||
Some(p) => {
|
||||
@@ -199,7 +199,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
Commands::Export => {
|
||||
let mem = HDF5Memory::open(&cli.path)?;
|
||||
let mem = HDF5Memory::open_read_only(&cli.path)?;
|
||||
for i in 0..mem.count() {
|
||||
if let Some(chunk) = mem.get_chunk(i) {
|
||||
let j = serde_json::json!({ "index": i, "chunk": chunk });
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "clawhdf5-derive"
|
||||
version = "2.1.0"
|
||||
version = "2.3.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"]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "clawhdf5-filters"
|
||||
version = "2.1.0"
|
||||
version = "2.3.0"
|
||||
edition = "2024"
|
||||
description = "Filter and compression pipeline for rustyhdf5"
|
||||
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"]
|
||||
@@ -14,7 +14,7 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"]
|
||||
miniz_oxide = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
criterion = { workspace = true }
|
||||
|
||||
[[bench]]
|
||||
name = "deflate_bench"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "clawhdf5-format"
|
||||
version = "2.1.0"
|
||||
version = "2.3.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"]
|
||||
@@ -24,8 +24,8 @@ pco = { version = "1.0", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.1.0" }
|
||||
criterion = { workspace = true }
|
||||
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.3.0" }
|
||||
|
||||
[[bench]]
|
||||
name = "bench"
|
||||
|
||||
@@ -14,6 +14,9 @@ libfuzzer-sys = "0.4"
|
||||
path = ".."
|
||||
features = ["std", "checksum", "deflate"]
|
||||
|
||||
[dependencies.clawhdf5]
|
||||
path = "../../clawhdf5"
|
||||
|
||||
[workspace]
|
||||
members = ["."]
|
||||
|
||||
@@ -56,3 +59,8 @@ doc = false
|
||||
name = "fuzz_full_file"
|
||||
path = "fuzz_targets/fuzz_full_file.rs"
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_dataset_read"
|
||||
path = "fuzz_targets/fuzz_dataset_read.rs"
|
||||
doc = false
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Fuzz Testing for rustyhdf5-format
|
||||
# Fuzz Testing for clawhdf5-format
|
||||
|
||||
Uses [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) (libFuzzer) to test parser robustness against malformed inputs.
|
||||
|
||||
@@ -21,13 +21,14 @@ rustup toolchain install nightly
|
||||
| `fuzz_btree_v2` | `BTreeV2Header::parse` | B-tree v2 header parsing |
|
||||
| `fuzz_filter_pipeline` | `FilterPipeline::parse` | Filter pipeline messages (v1/v2) |
|
||||
| `fuzz_full_file` | signature + superblock + root group | End-to-end file parsing chain |
|
||||
| `fuzz_dataset_read` | `Dataset::read_*` (via `clawhdf5`) | Walks every dataset in the parsed file and exercises the contiguous/chunked/compact raw-data read paths (`chunked_read.rs`, `data_read.rs`) that `fuzz_full_file` doesn't reach |
|
||||
|
||||
## Running
|
||||
|
||||
Run a single target (runs indefinitely until stopped or a crash is found):
|
||||
|
||||
```bash
|
||||
cd crates/rustyhdf5-format
|
||||
cd crates/clawhdf5-format
|
||||
cargo +nightly fuzz run fuzz_datatype
|
||||
```
|
||||
|
||||
@@ -41,12 +42,20 @@ Run all targets for 30 seconds each:
|
||||
|
||||
```bash
|
||||
for target in fuzz_superblock fuzz_object_header fuzz_datatype fuzz_dataspace \
|
||||
fuzz_fractal_heap fuzz_btree_v2 fuzz_filter_pipeline fuzz_full_file; do
|
||||
fuzz_fractal_heap fuzz_btree_v2 fuzz_filter_pipeline fuzz_full_file \
|
||||
fuzz_dataset_read; do
|
||||
echo "=== $target ==="
|
||||
cargo +nightly fuzz run "$target" -- -max_total_time=30 -max_len=4096
|
||||
done
|
||||
```
|
||||
|
||||
## CI
|
||||
|
||||
These targets are **not** run in CI (`.gitea/workflows/ci.yml`) — cargo-fuzz
|
||||
requires nightly and each meaningful run takes minutes, which doesn't fit a
|
||||
per-PR gate. Run them manually on a schedule (e.g. before a release, or after
|
||||
touching parser code) instead.
|
||||
|
||||
## Reproducing Crashes
|
||||
|
||||
If a crash is found, the input is saved to `fuzz/artifacts/<target>/`. Reproduce with:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
const MAX_WALK_DEPTH: usize = 16;
|
||||
|
||||
/// Walk groups/datasets from `group`, exercising every dataset-reading code
|
||||
/// path reachable through the public API (contiguous/chunked/compact raw
|
||||
/// reads via `chunked_read.rs`/`data_read.rs`). Depth-limited independently
|
||||
/// of any parser-level recursion guard, since this is fuzz-harness
|
||||
/// bookkeeping, not something under test.
|
||||
fn walk_group(group: &clawhdf5::Group, depth: usize) {
|
||||
if depth > MAX_WALK_DEPTH {
|
||||
return;
|
||||
}
|
||||
if let Ok(names) = group.datasets() {
|
||||
for name in names {
|
||||
if let Ok(dataset) = group.dataset(&name) {
|
||||
let _ = dataset.shape();
|
||||
let _ = dataset.max_dimensions();
|
||||
let _ = dataset.dtype();
|
||||
let _ = dataset.read_raw_ref();
|
||||
let _ = dataset.read_f64();
|
||||
let _ = dataset.read_f32();
|
||||
let _ = dataset.read_i32();
|
||||
let _ = dataset.read_i64();
|
||||
let _ = dataset.read_u64();
|
||||
let _ = dataset.read_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(names) = group.groups() {
|
||||
for name in names {
|
||||
if let Ok(subgroup) = group.group(&name) {
|
||||
walk_group(&subgroup, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(file) = clawhdf5::File::from_bytes(data.to_vec()) else {
|
||||
return;
|
||||
};
|
||||
walk_group(&file.root(), 0);
|
||||
});
|
||||
@@ -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]
|
||||
|
||||
@@ -24,6 +24,21 @@ pub struct BTreeV1Node {
|
||||
pub children: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Checks that `[offset, offset + needed)` fits within `data`, guarding the
|
||||
/// addition against `usize` overflow from a crafted near-`usize::MAX` offset.
|
||||
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 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()) {
|
||||
@@ -45,7 +60,7 @@ fn read_offset(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
|
||||
|
||||
fn is_undefined(data: &[u8], pos: usize, size: u8) -> bool {
|
||||
let s = size as usize;
|
||||
if pos + s > data.len() {
|
||||
if ensure_len(data, pos, s).is_err() {
|
||||
return false;
|
||||
}
|
||||
data[pos..pos + s].iter().all(|&b| b == 0xFF)
|
||||
@@ -65,12 +80,7 @@ impl BTreeV1Node {
|
||||
// + left_sibling(offset_size) + right_sibling(offset_size)
|
||||
let os = offset_size as usize;
|
||||
let header_size = 8 + os * 2;
|
||||
if offset + header_size > file_data.len() {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: offset + header_size,
|
||||
available: file_data.len(),
|
||||
});
|
||||
}
|
||||
ensure_len(file_data, offset, header_size)?;
|
||||
|
||||
if &file_data[offset..offset + 4] != b"TREE" {
|
||||
return Err(FormatError::InvalidBTreeSignature);
|
||||
@@ -99,12 +109,7 @@ impl BTreeV1Node {
|
||||
let eu = entries_used as usize;
|
||||
let key_size = os; // For type 0, key = offset_size
|
||||
let needed = eu * (key_size + os) + key_size; // eu children + (eu+1) keys
|
||||
if pos + needed > file_data.len() {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: pos + needed,
|
||||
available: file_data.len(),
|
||||
});
|
||||
}
|
||||
ensure_len(file_data, pos, needed)?;
|
||||
|
||||
let mut keys = Vec::with_capacity(eu + 1);
|
||||
let mut children = Vec::with_capacity(eu);
|
||||
@@ -241,6 +246,16 @@ mod tests {
|
||||
assert_eq!(node.right_sibling, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_near_usize_max_offset_rejected_without_overflow() {
|
||||
let data = build_btree_node(0, 0, &[0, 5, 10], &[0x100, 0x200], None, None, 8);
|
||||
let result = BTreeV1Node::parse(&data, usize::MAX - 4, 8, 8);
|
||||
assert!(
|
||||
matches!(result, Err(FormatError::UnexpectedEof { .. })),
|
||||
"expected a clean UnexpectedEof, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_with_siblings_none() {
|
||||
let data = build_btree_node(0, 0, &[0, 8], &[0x300], None, None, 8);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -61,12 +61,7 @@ fn decompress_all_chunks(
|
||||
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() {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: c_addr + size,
|
||||
available: file_data.len(),
|
||||
});
|
||||
}
|
||||
ensure_len(file_data, c_addr, size)?;
|
||||
let raw_chunk = &file_data[c_addr..c_addr + size];
|
||||
|
||||
let decompressed = if let Some(pl) = pipeline {
|
||||
@@ -122,6 +117,62 @@ pub struct ChunkInfo {
|
||||
pub address: u64,
|
||||
}
|
||||
|
||||
/// Checks that `[offset, offset + needed)` fits within `data`, guarding the
|
||||
/// addition against `usize` overflow from a crafted near-`usize::MAX` offset.
|
||||
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(())
|
||||
}
|
||||
|
||||
/// `elements * elem_size` for sizes that come from the file. Dataspace and
|
||||
/// chunk dimensions are untrusted 64-bit fields, so a crafted file can make
|
||||
/// the plain product wrap to a small number (or to something enormous).
|
||||
pub(crate) fn checked_byte_len(elements: u64, elem_size: usize) -> Result<usize, FormatError> {
|
||||
usize::try_from(elements)
|
||||
.ok()
|
||||
.and_then(|n| n.checked_mul(elem_size))
|
||||
.ok_or_else(|| {
|
||||
FormatError::Overflow(format!(
|
||||
"{elements} elements of {elem_size} bytes exceeds the addressable size"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Product of chunk dimensions times the element size, overflow-checked.
|
||||
pub(crate) fn checked_chunk_byte_len(
|
||||
chunk_dims: &[usize],
|
||||
elem_size: usize,
|
||||
) -> Result<usize, FormatError> {
|
||||
chunk_dims
|
||||
.iter()
|
||||
.try_fold(elem_size, |acc, &d| acc.checked_mul(d))
|
||||
.ok_or_else(|| {
|
||||
FormatError::Overflow(format!(
|
||||
"chunk dimensions {chunk_dims:?} x {elem_size} bytes exceeds the addressable size"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// A zero-filled output buffer of `len` bytes. `vec![0; len]` aborts the
|
||||
/// process when the allocation fails; a size taken from the file must surface
|
||||
/// as an error instead.
|
||||
pub(crate) fn alloc_output(len: usize) -> Result<Vec<u8>, FormatError> {
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(len).map_err(|_| {
|
||||
FormatError::Overflow(format!("cannot allocate {len} bytes for dataset output"))
|
||||
})?;
|
||||
out.resize(len, 0);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read_offset(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
|
||||
let s = size as usize;
|
||||
if pos.checked_add(s).is_none_or(|end| end > data.len()) {
|
||||
@@ -150,19 +201,33 @@ pub fn collect_chunk_info(
|
||||
btree_address: u64,
|
||||
ndims: usize,
|
||||
offset_size: u8,
|
||||
_length_size: u8,
|
||||
length_size: u8,
|
||||
) -> Result<Vec<ChunkInfo>, FormatError> {
|
||||
collect_chunk_info_inner(file_data, btree_address, ndims, offset_size, length_size, 0)
|
||||
}
|
||||
|
||||
/// Maximum recursion depth for chunk B-tree traversal (malformed/cyclic data
|
||||
/// protection), matching `btree_v1.rs`'s `MAX_BTREE_DEPTH`.
|
||||
const MAX_CHUNK_BTREE_DEPTH: usize = 64;
|
||||
|
||||
fn collect_chunk_info_inner(
|
||||
file_data: &[u8],
|
||||
btree_address: u64,
|
||||
ndims: usize,
|
||||
offset_size: u8,
|
||||
_length_size: u8,
|
||||
depth: usize,
|
||||
) -> Result<Vec<ChunkInfo>, FormatError> {
|
||||
if depth > MAX_CHUNK_BTREE_DEPTH {
|
||||
return Err(FormatError::NestingDepthExceeded);
|
||||
}
|
||||
|
||||
let offset = btree_address as usize;
|
||||
let os = offset_size as usize;
|
||||
|
||||
// Parse B-tree v1 header
|
||||
let header_size = 8 + os * 2;
|
||||
if offset + header_size > file_data.len() {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: offset + header_size,
|
||||
available: file_data.len(),
|
||||
});
|
||||
}
|
||||
ensure_len(file_data, offset, header_size)?;
|
||||
|
||||
if &file_data[offset..offset + 4] != b"TREE" {
|
||||
return Err(FormatError::InvalidBTreeSignature);
|
||||
@@ -185,12 +250,7 @@ pub fn collect_chunk_info(
|
||||
// Leaf node: keys and children interleaved
|
||||
// key[0], child[0], key[1], child[1], ..., key[N-1], child[N-1], key[N]
|
||||
let needed = entries_used * (key_size + os) + key_size;
|
||||
if pos + needed > file_data.len() {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: pos + needed,
|
||||
available: file_data.len(),
|
||||
});
|
||||
}
|
||||
ensure_len(file_data, pos, needed)?;
|
||||
|
||||
let mut chunks = Vec::with_capacity(entries_used);
|
||||
for _ in 0..entries_used {
|
||||
@@ -231,12 +291,7 @@ pub fn collect_chunk_info(
|
||||
} else {
|
||||
// Internal node: recurse into children
|
||||
let needed = entries_used * (key_size + os) + key_size;
|
||||
if pos + needed > file_data.len() {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: pos + needed,
|
||||
available: file_data.len(),
|
||||
});
|
||||
}
|
||||
ensure_len(file_data, pos, needed)?;
|
||||
|
||||
let mut child_addrs = Vec::with_capacity(entries_used);
|
||||
for _ in 0..entries_used {
|
||||
@@ -248,8 +303,14 @@ pub fn collect_chunk_info(
|
||||
|
||||
let mut all_chunks = Vec::new();
|
||||
for child_addr in child_addrs {
|
||||
let child_chunks =
|
||||
collect_chunk_info(file_data, child_addr, ndims, offset_size, _length_size)?;
|
||||
let child_chunks = collect_chunk_info_inner(
|
||||
file_data,
|
||||
child_addr,
|
||||
ndims,
|
||||
offset_size,
|
||||
_length_size,
|
||||
depth + 1,
|
||||
)?;
|
||||
all_chunks.extend(child_chunks);
|
||||
}
|
||||
Ok(all_chunks)
|
||||
@@ -301,15 +362,17 @@ pub fn generate_implicit_chunks(
|
||||
}
|
||||
|
||||
/// Read a chunked dataset, decompressing chunks as needed.
|
||||
pub fn read_chunked_data(
|
||||
/// Every allocated chunk of a chunked dataset, for any supported chunk index,
|
||||
/// plus the spatial chunk dimensions. Chunks the file never allocated (sparse
|
||||
/// datasets) are simply absent from the list.
|
||||
pub fn list_chunks(
|
||||
file_data: &[u8],
|
||||
layout: &DataLayout,
|
||||
dataspace: &Dataspace,
|
||||
datatype: &Datatype,
|
||||
pipeline: Option<&FilterPipeline>,
|
||||
elem_size: usize,
|
||||
offset_size: u8,
|
||||
length_size: u8,
|
||||
) -> Result<Vec<u8>, FormatError> {
|
||||
) -> Result<(Vec<ChunkInfo>, Vec<usize>), FormatError> {
|
||||
let (
|
||||
chunk_dimensions,
|
||||
version,
|
||||
@@ -343,11 +406,11 @@ 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 - 1;
|
||||
let rank = ndims
|
||||
.checked_sub(1)
|
||||
.ok_or_else(|| FormatError::ChunkedReadError("chunked layout has no dimensions".into()))?;
|
||||
let chunk_dims: Vec<usize> = chunk_dimensions[..rank]
|
||||
.iter()
|
||||
.map(|&d| d as usize)
|
||||
@@ -371,7 +434,7 @@ pub fn read_chunked_data(
|
||||
}
|
||||
(4, Some(1)) => {
|
||||
// Single chunk — one chunk covering the entire dataset
|
||||
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
|
||||
let chunk_byte_size = checked_chunk_byte_len(&chunk_dims, elem_size)?;
|
||||
let (csize, fmask) = if let Some(fs) = single_filtered_size {
|
||||
(fs as u32, single_filter_mask.unwrap_or(0))
|
||||
} else {
|
||||
@@ -386,24 +449,24 @@ pub fn read_chunked_data(
|
||||
}
|
||||
(4, Some(2)) => {
|
||||
// Implicit index — use spatial chunk dims only
|
||||
let spatial_chunk_dims: Vec<u32> = chunk_dimensions[..rank].to_vec();
|
||||
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||
generate_implicit_chunks(
|
||||
addr,
|
||||
&dataspace.dimensions,
|
||||
&spatial_chunk_dims,
|
||||
spatial_chunk_dims,
|
||||
elem_size as u32,
|
||||
)
|
||||
}
|
||||
(4, Some(3)) => {
|
||||
// Fixed Array — use spatial chunk dims only
|
||||
let spatial_chunk_dims: Vec<u32> = chunk_dimensions[..rank].to_vec();
|
||||
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,
|
||||
spatial_chunk_dims,
|
||||
elem_size as u32,
|
||||
offset_size,
|
||||
length_size,
|
||||
@@ -411,14 +474,14 @@ pub fn read_chunked_data(
|
||||
}
|
||||
(4, Some(4)) => {
|
||||
// Extensible Array — use spatial chunk dims only
|
||||
let spatial_chunk_dims: Vec<u32> = chunk_dimensions[..rank].to_vec();
|
||||
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,
|
||||
spatial_chunk_dims,
|
||||
elem_size as u32,
|
||||
offset_size,
|
||||
length_size,
|
||||
@@ -431,10 +494,38 @@ pub fn read_chunked_data(
|
||||
}
|
||||
};
|
||||
|
||||
Ok((chunks, chunk_dims))
|
||||
}
|
||||
|
||||
pub fn read_chunked_data(
|
||||
file_data: &[u8],
|
||||
layout: &DataLayout,
|
||||
dataspace: &Dataspace,
|
||||
datatype: &Datatype,
|
||||
pipeline: Option<&FilterPipeline>,
|
||||
offset_size: u8,
|
||||
length_size: u8,
|
||||
) -> Result<Vec<u8>, FormatError> {
|
||||
let elem_size = datatype.type_size() as usize;
|
||||
let (chunks, chunk_dims) = list_chunks(
|
||||
file_data,
|
||||
layout,
|
||||
dataspace,
|
||||
elem_size,
|
||||
offset_size,
|
||||
length_size,
|
||||
)?;
|
||||
let rank = chunk_dims.len();
|
||||
let ds_dims: Vec<usize> = dataspace.dimensions.iter().map(|&d| d as usize).collect();
|
||||
|
||||
// Assemble output
|
||||
let total_elements = dataspace.num_elements() as usize;
|
||||
let total_bytes = total_elements * elem_size;
|
||||
let mut output = vec![0u8; total_bytes];
|
||||
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
|
||||
if total_bytes == 0 {
|
||||
// Also keeps the stride products below in range: with a zero-sized
|
||||
// dimension the total is 0 even if other dimensions are huge.
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut output = alloc_output(total_bytes)?;
|
||||
|
||||
let mut ds_strides = vec![1usize; rank];
|
||||
for i in (0..rank.saturating_sub(1)).rev() {
|
||||
@@ -446,8 +537,7 @@ pub fn read_chunked_data(
|
||||
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
|
||||
}
|
||||
|
||||
let chunk_total_elements: usize = chunk_dims.iter().product();
|
||||
let chunk_total_bytes = chunk_total_elements * elem_size;
|
||||
let chunk_total_bytes = checked_chunk_byte_len(&chunk_dims, elem_size)?;
|
||||
|
||||
// Fast path: no filters — copy directly from file_data without intermediate alloc
|
||||
if pipeline.is_none() {
|
||||
@@ -461,12 +551,7 @@ pub fn read_chunked_data(
|
||||
|
||||
let c_addr = chunk_info.address as usize;
|
||||
let size = chunk_info.chunk_size as usize;
|
||||
if c_addr + size > file_data.len() {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: c_addr + size,
|
||||
available: file_data.len(),
|
||||
});
|
||||
}
|
||||
ensure_len(file_data, c_addr, size)?;
|
||||
let chunk_data = &file_data[c_addr..c_addr + size];
|
||||
|
||||
if rank == 0 {
|
||||
@@ -579,7 +664,9 @@ pub fn read_chunked_data_cached(
|
||||
|
||||
let elem_size = datatype.type_size() as usize;
|
||||
let ndims = chunk_dimensions.len();
|
||||
let rank = ndims - 1;
|
||||
let rank = ndims
|
||||
.checked_sub(1)
|
||||
.ok_or_else(|| FormatError::ChunkedReadError("chunked layout has no dimensions".into()))?;
|
||||
let chunk_dims: Vec<usize> = chunk_dimensions[..rank]
|
||||
.iter()
|
||||
.map(|&d| d as usize)
|
||||
@@ -604,7 +691,7 @@ pub fn read_chunked_data_cached(
|
||||
let chunks = match (version, chunk_index_type) {
|
||||
(3, _) => collect_chunk_info(file_data, addr, ndims, offset_size, length_size)?,
|
||||
(4, Some(1)) => {
|
||||
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
|
||||
let chunk_byte_size = checked_chunk_byte_len(&chunk_dims, elem_size)?;
|
||||
let (csize, fmask) = if let Some(fs) = single_filtered_size {
|
||||
(fs as u32, single_filter_mask.unwrap_or(0))
|
||||
} else {
|
||||
@@ -618,30 +705,30 @@ pub fn read_chunked_data_cached(
|
||||
}]
|
||||
}
|
||||
(4, Some(2)) => {
|
||||
let spatial_chunk_dims: Vec<u32> = chunk_dimensions[..rank].to_vec();
|
||||
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||
generate_implicit_chunks(
|
||||
addr,
|
||||
&dataspace.dimensions,
|
||||
&spatial_chunk_dims,
|
||||
spatial_chunk_dims,
|
||||
elem_size as u32,
|
||||
)
|
||||
}
|
||||
(4, Some(3)) => {
|
||||
let spatial_chunk_dims: Vec<u32> = chunk_dimensions[..rank].to_vec();
|
||||
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,
|
||||
spatial_chunk_dims,
|
||||
elem_size as u32,
|
||||
offset_size,
|
||||
length_size,
|
||||
)?
|
||||
}
|
||||
(4, Some(4)) => {
|
||||
let spatial_chunk_dims: Vec<u32> = chunk_dimensions[..rank].to_vec();
|
||||
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||
let header = ExtensibleArrayHeader::parse(
|
||||
file_data,
|
||||
addr as usize,
|
||||
@@ -652,7 +739,7 @@ pub fn read_chunked_data_cached(
|
||||
file_data,
|
||||
&header,
|
||||
&dataspace.dimensions,
|
||||
&spatial_chunk_dims,
|
||||
spatial_chunk_dims,
|
||||
elem_size as u32,
|
||||
offset_size,
|
||||
length_size,
|
||||
@@ -670,9 +757,13 @@ pub fn read_chunked_data_cached(
|
||||
let chunks = cache.all_indexed_chunks().unwrap_or_default();
|
||||
|
||||
// Assemble output
|
||||
let total_elements = dataspace.num_elements() as usize;
|
||||
let total_bytes = total_elements * elem_size;
|
||||
let mut output = vec![0u8; total_bytes];
|
||||
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
|
||||
if total_bytes == 0 {
|
||||
// Also keeps the stride products below in range: with a zero-sized
|
||||
// dimension the total is 0 even if other dimensions are huge.
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut output = alloc_output(total_bytes)?;
|
||||
|
||||
let mut ds_strides = vec![1usize; rank];
|
||||
for i in (0..rank.saturating_sub(1)).rev() {
|
||||
@@ -684,8 +775,7 @@ pub fn read_chunked_data_cached(
|
||||
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
|
||||
}
|
||||
|
||||
let chunk_total_elements: usize = chunk_dims.iter().product();
|
||||
let chunk_total_bytes = chunk_total_elements * elem_size;
|
||||
let chunk_total_bytes = checked_chunk_byte_len(&chunk_dims, elem_size)?;
|
||||
|
||||
for chunk_info in &chunks {
|
||||
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
|
||||
@@ -697,12 +787,7 @@ pub fn read_chunked_data_cached(
|
||||
// Decompress from file
|
||||
let c_addr = chunk_info.address as usize;
|
||||
let size = chunk_info.chunk_size as usize;
|
||||
if c_addr + size > file_data.len() {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: c_addr + size,
|
||||
available: file_data.len(),
|
||||
});
|
||||
}
|
||||
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 {
|
||||
@@ -935,7 +1020,9 @@ pub fn read_chunked_data_sweep(
|
||||
|
||||
let elem_size = datatype.type_size() as usize;
|
||||
let ndims = chunk_dimensions.len();
|
||||
let rank = ndims - 1;
|
||||
let rank = ndims
|
||||
.checked_sub(1)
|
||||
.ok_or_else(|| FormatError::ChunkedReadError("chunked layout has no dimensions".into()))?;
|
||||
let chunk_dims: Vec<usize> = chunk_dimensions[..rank]
|
||||
.iter()
|
||||
.map(|&d| d as usize)
|
||||
@@ -960,7 +1047,7 @@ pub fn read_chunked_data_sweep(
|
||||
let chunks = match (version, chunk_index_type) {
|
||||
(3, _) => collect_chunk_info(file_data, addr, ndims, offset_size, length_size)?,
|
||||
(4, Some(1)) => {
|
||||
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
|
||||
let chunk_byte_size = checked_chunk_byte_len(&chunk_dims, elem_size)?;
|
||||
let (csize, fmask) = if let Some(fs) = single_filtered_size {
|
||||
(fs as u32, single_filter_mask.unwrap_or(0))
|
||||
} else {
|
||||
@@ -974,30 +1061,30 @@ pub fn read_chunked_data_sweep(
|
||||
}]
|
||||
}
|
||||
(4, Some(2)) => {
|
||||
let spatial_chunk_dims: Vec<u32> = chunk_dimensions[..rank].to_vec();
|
||||
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||
generate_implicit_chunks(
|
||||
addr,
|
||||
&dataspace.dimensions,
|
||||
&spatial_chunk_dims,
|
||||
spatial_chunk_dims,
|
||||
elem_size as u32,
|
||||
)
|
||||
}
|
||||
(4, Some(3)) => {
|
||||
let spatial_chunk_dims: Vec<u32> = chunk_dimensions[..rank].to_vec();
|
||||
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,
|
||||
spatial_chunk_dims,
|
||||
elem_size as u32,
|
||||
offset_size,
|
||||
length_size,
|
||||
)?
|
||||
}
|
||||
(4, Some(4)) => {
|
||||
let spatial_chunk_dims: Vec<u32> = chunk_dimensions[..rank].to_vec();
|
||||
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||
let header = ExtensibleArrayHeader::parse(
|
||||
file_data,
|
||||
addr as usize,
|
||||
@@ -1008,7 +1095,7 @@ pub fn read_chunked_data_sweep(
|
||||
file_data,
|
||||
&header,
|
||||
&dataspace.dimensions,
|
||||
&spatial_chunk_dims,
|
||||
spatial_chunk_dims,
|
||||
elem_size as u32,
|
||||
offset_size,
|
||||
length_size,
|
||||
@@ -1026,9 +1113,13 @@ pub fn read_chunked_data_sweep(
|
||||
let chunks = cache.all_indexed_chunks().unwrap_or_default();
|
||||
|
||||
// Assemble output
|
||||
let total_elements = dataspace.num_elements() as usize;
|
||||
let total_bytes = total_elements * elem_size;
|
||||
let mut output = vec![0u8; total_bytes];
|
||||
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
|
||||
if total_bytes == 0 {
|
||||
// Also keeps the stride products below in range: with a zero-sized
|
||||
// dimension the total is 0 even if other dimensions are huge.
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut output = alloc_output(total_bytes)?;
|
||||
|
||||
let mut ds_strides = vec![1usize; rank];
|
||||
for i in (0..rank.saturating_sub(1)).rev() {
|
||||
@@ -1040,8 +1131,7 @@ pub fn read_chunked_data_sweep(
|
||||
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
|
||||
}
|
||||
|
||||
let chunk_total_elements: usize = chunk_dims.iter().product();
|
||||
let chunk_total_bytes = chunk_total_elements * elem_size;
|
||||
let chunk_total_bytes = checked_chunk_byte_len(&chunk_dims, elem_size)?;
|
||||
|
||||
for chunk_info in &chunks {
|
||||
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
|
||||
@@ -1062,12 +1152,7 @@ pub fn read_chunked_data_sweep(
|
||||
// Decompress from file
|
||||
let c_addr = chunk_info.address as usize;
|
||||
let size = chunk_info.chunk_size as usize;
|
||||
if c_addr + size > file_data.len() {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: c_addr + size,
|
||||
available: file_data.len(),
|
||||
});
|
||||
}
|
||||
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 {
|
||||
@@ -1161,7 +1246,9 @@ pub fn read_chunked_data_indexed(
|
||||
|
||||
let elem_size = datatype.type_size() as usize;
|
||||
let ndims = chunk_dimensions.len();
|
||||
let rank = ndims - 1;
|
||||
let rank = ndims
|
||||
.checked_sub(1)
|
||||
.ok_or_else(|| FormatError::ChunkedReadError("chunked layout has no dimensions".into()))?;
|
||||
let chunk_dims: Vec<usize> = chunk_dimensions[..rank]
|
||||
.iter()
|
||||
.map(|&d| d as usize)
|
||||
@@ -1186,7 +1273,7 @@ pub fn read_chunked_data_indexed(
|
||||
let chunks = match (version, chunk_index_type) {
|
||||
(3, _) => collect_chunk_info(file_data, addr, ndims, offset_size, length_size)?,
|
||||
(4, Some(1)) => {
|
||||
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
|
||||
let chunk_byte_size = checked_chunk_byte_len(&chunk_dims, elem_size)?;
|
||||
let (csize, fmask) = if let Some(fs) = single_filtered_size {
|
||||
(fs as u32, single_filter_mask.unwrap_or(0))
|
||||
} else {
|
||||
@@ -1200,30 +1287,30 @@ pub fn read_chunked_data_indexed(
|
||||
}]
|
||||
}
|
||||
(4, Some(2)) => {
|
||||
let spatial_chunk_dims: Vec<u32> = chunk_dimensions[..rank].to_vec();
|
||||
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||
generate_implicit_chunks(
|
||||
addr,
|
||||
&dataspace.dimensions,
|
||||
&spatial_chunk_dims,
|
||||
spatial_chunk_dims,
|
||||
elem_size as u32,
|
||||
)
|
||||
}
|
||||
(4, Some(3)) => {
|
||||
let spatial_chunk_dims: Vec<u32> = chunk_dimensions[..rank].to_vec();
|
||||
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,
|
||||
spatial_chunk_dims,
|
||||
elem_size as u32,
|
||||
offset_size,
|
||||
length_size,
|
||||
)?
|
||||
}
|
||||
(4, Some(4)) => {
|
||||
let spatial_chunk_dims: Vec<u32> = chunk_dimensions[..rank].to_vec();
|
||||
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||
let header = ExtensibleArrayHeader::parse(
|
||||
file_data,
|
||||
addr as usize,
|
||||
@@ -1234,7 +1321,7 @@ pub fn read_chunked_data_indexed(
|
||||
file_data,
|
||||
&header,
|
||||
&dataspace.dimensions,
|
||||
&spatial_chunk_dims,
|
||||
spatial_chunk_dims,
|
||||
elem_size as u32,
|
||||
offset_size,
|
||||
length_size,
|
||||
@@ -1278,12 +1365,7 @@ pub fn read_chunked_data_indexed(
|
||||
} else {
|
||||
let c_addr = *file_offset as usize;
|
||||
let size = *file_size as usize;
|
||||
if c_addr + size > file_data.len() {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: c_addr + size,
|
||||
available: file_data.len(),
|
||||
});
|
||||
}
|
||||
ensure_len(file_data, c_addr, size)?;
|
||||
let raw_chunk = &file_data[c_addr..c_addr + size];
|
||||
let decompressed = if let Some(pl) = pipeline {
|
||||
if *filter_mask == 0 {
|
||||
@@ -1331,9 +1413,18 @@ fn copy_chunk_to_output(
|
||||
// Fast path for 1-D: single contiguous copy per chunk
|
||||
let global_start = chunk_offsets[0];
|
||||
let copy_len = chunk_dims[0].min(ds_dims[0].saturating_sub(global_start));
|
||||
let src_bytes = copy_len * elem_size;
|
||||
let dst_start = global_start * elem_size;
|
||||
if src_bytes > 0 && dst_start + src_bytes <= output.len() && src_bytes <= chunk_data.len() {
|
||||
let (Some(src_bytes), Some(dst_start)) = (
|
||||
copy_len.checked_mul(elem_size),
|
||||
global_start.checked_mul(elem_size),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
if src_bytes > 0
|
||||
&& dst_start
|
||||
.checked_add(src_bytes)
|
||||
.is_some_and(|end| end <= output.len())
|
||||
&& src_bytes <= chunk_data.len()
|
||||
{
|
||||
output[dst_start..dst_start + src_bytes].copy_from_slice(&chunk_data[..src_bytes]);
|
||||
}
|
||||
return;
|
||||
@@ -1343,19 +1434,29 @@ fn copy_chunk_to_output(
|
||||
let inner_dim = rank - 1;
|
||||
let inner_chunk_len =
|
||||
chunk_dims[inner_dim].min(ds_dims[inner_dim].saturating_sub(chunk_offsets[inner_dim]));
|
||||
let row_bytes = inner_chunk_len * elem_size;
|
||||
let Some(row_bytes) = inner_chunk_len.checked_mul(elem_size) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if row_bytes == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Number of rows = product of all outer chunk dimensions
|
||||
let outer_count: usize = chunk_dims[..inner_dim].iter().product();
|
||||
let Some(outer_count) = chunk_dims[..inner_dim]
|
||||
.iter()
|
||||
.try_fold(1usize, |acc, &d| acc.checked_mul(d))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Outer strides for iterating chunk-local coordinates
|
||||
let mut outer_strides = vec![1usize; inner_dim];
|
||||
for i in (0..inner_dim.saturating_sub(1)).rev() {
|
||||
outer_strides[i] = outer_strides[i + 1] * chunk_dims[i + 1];
|
||||
let Some(stride) = outer_strides[i + 1].checked_mul(chunk_dims[i + 1]) else {
|
||||
return;
|
||||
};
|
||||
outer_strides[i] = stride;
|
||||
}
|
||||
|
||||
for outer_idx in 0..outer_count {
|
||||
@@ -1375,13 +1476,29 @@ fn copy_chunk_to_output(
|
||||
remaining %= outer_strides[d];
|
||||
}
|
||||
|
||||
let global_coord = chunk_offsets[d] + coord_in_chunk;
|
||||
let Some(global_coord) = chunk_offsets[d].checked_add(coord_in_chunk) else {
|
||||
out_of_bounds = true;
|
||||
break;
|
||||
};
|
||||
if global_coord >= ds_dims[d] {
|
||||
out_of_bounds = true;
|
||||
break;
|
||||
}
|
||||
ds_flat += global_coord * ds_strides[d];
|
||||
src_flat += coord_in_chunk * chunk_strides[d];
|
||||
let (Some(ds_term), Some(src_term)) = (
|
||||
global_coord.checked_mul(ds_strides[d]),
|
||||
coord_in_chunk.checked_mul(chunk_strides[d]),
|
||||
) else {
|
||||
out_of_bounds = true;
|
||||
break;
|
||||
};
|
||||
let (Some(new_ds_flat), Some(new_src_flat)) =
|
||||
(ds_flat.checked_add(ds_term), src_flat.checked_add(src_term))
|
||||
else {
|
||||
out_of_bounds = true;
|
||||
break;
|
||||
};
|
||||
ds_flat = new_ds_flat;
|
||||
src_flat = new_src_flat;
|
||||
}
|
||||
|
||||
if out_of_bounds {
|
||||
@@ -1389,12 +1506,27 @@ fn copy_chunk_to_output(
|
||||
}
|
||||
|
||||
// Add innermost dimension offset
|
||||
ds_flat += chunk_offsets[inner_dim] * ds_strides[inner_dim];
|
||||
let Some(inner_term) = chunk_offsets[inner_dim].checked_mul(ds_strides[inner_dim]) else {
|
||||
continue;
|
||||
};
|
||||
let Some(ds_flat) = ds_flat.checked_add(inner_term) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let src_start = src_flat * elem_size;
|
||||
let dst_start = ds_flat * elem_size;
|
||||
let (Some(src_start), Some(dst_start)) = (
|
||||
src_flat.checked_mul(elem_size),
|
||||
ds_flat.checked_mul(elem_size),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if src_start + row_bytes <= chunk_data.len() && dst_start + row_bytes <= output.len() {
|
||||
let fits = src_start
|
||||
.checked_add(row_bytes)
|
||||
.is_some_and(|end| end <= chunk_data.len())
|
||||
&& dst_start
|
||||
.checked_add(row_bytes)
|
||||
.is_some_and(|end| end <= output.len());
|
||||
if fits {
|
||||
output[dst_start..dst_start + row_bytes]
|
||||
.copy_from_slice(&chunk_data[src_start..src_start + row_bytes]);
|
||||
}
|
||||
@@ -1405,6 +1537,64 @@ fn copy_chunk_to_output(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn simple_space(dimensions: Vec<u64>) -> Dataspace {
|
||||
Dataspace {
|
||||
space_type: crate::dataspace::DataspaceType::Simple,
|
||||
rank: dimensions.len() as u8,
|
||||
dimensions,
|
||||
max_dimensions: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crafted_dimensions_are_errors_not_wraparound() {
|
||||
// 2^63 * 2 wraps to 0 with a plain product; 2^40 * 2^40 wraps too.
|
||||
for dims in [
|
||||
vec![1u64 << 63, 2],
|
||||
vec![1 << 40, 1 << 40],
|
||||
vec![u64::MAX, u64::MAX],
|
||||
] {
|
||||
let space = simple_space(dims.clone());
|
||||
assert!(
|
||||
matches!(space.checked_num_elements(), Err(FormatError::Overflow(_))),
|
||||
"{dims:?}"
|
||||
);
|
||||
// The infallible accessor saturates instead of wrapping.
|
||||
assert_eq!(space.num_elements(), u64::MAX, "{dims:?}");
|
||||
}
|
||||
assert_eq!(simple_space(vec![3, 4]).checked_num_elements().unwrap(), 12);
|
||||
// A zero-sized dimension makes the whole product 0, not an overflow.
|
||||
assert_eq!(
|
||||
simple_space(vec![0, 1 << 40, 1 << 40])
|
||||
.checked_num_elements()
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_length_helpers_check_overflow() {
|
||||
assert_eq!(checked_byte_len(10, 8).unwrap(), 80);
|
||||
assert!(matches!(
|
||||
checked_byte_len(u64::MAX, 8),
|
||||
Err(FormatError::Overflow(_))
|
||||
));
|
||||
assert_eq!(checked_chunk_byte_len(&[10, 10], 4).unwrap(), 400);
|
||||
assert!(matches!(
|
||||
checked_chunk_byte_len(&[usize::MAX, 2], 4),
|
||||
Err(FormatError::Overflow(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unallocatable_output_is_an_error_not_an_abort() {
|
||||
assert_eq!(alloc_output(16).unwrap(), vec![0u8; 16]);
|
||||
assert!(matches!(
|
||||
alloc_output(usize::MAX / 2),
|
||||
Err(FormatError::Overflow(_))
|
||||
));
|
||||
}
|
||||
|
||||
fn write_offset(buf: &mut Vec<u8>, val: u64, size: u8) {
|
||||
match size {
|
||||
4 => buf.extend_from_slice(&(val as u32).to_le_bytes()),
|
||||
@@ -1599,9 +1789,9 @@ mod tests {
|
||||
let chunk_bytes = chunk_size_elems * elem_size; // full chunk allocation
|
||||
|
||||
// Write chunk data (full chunk size, padding with zeros)
|
||||
for i in start..end {
|
||||
for (i, value) in values.iter().enumerate().take(end).skip(start) {
|
||||
let byte_offset = data_offset + (i - start) * elem_size;
|
||||
file_data[byte_offset..byte_offset + 8].copy_from_slice(&values[i].to_le_bytes());
|
||||
file_data[byte_offset..byte_offset + 8].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
chunk_infos.push(ChunkInfo {
|
||||
@@ -1639,6 +1829,82 @@ mod tests {
|
||||
(file_data, layout, dataspace)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_chunked_data_rejects_zero_dim_chunk_layout() {
|
||||
// Found by fuzzing: chunk_dimensions.len() == 0 caused `ndims - 1` to
|
||||
// underflow. A malformed/degenerate chunked layout must error cleanly.
|
||||
let layout = DataLayout::Chunked {
|
||||
chunk_dimensions: vec![],
|
||||
btree_address: Some(0),
|
||||
version: 3,
|
||||
chunk_index_type: None,
|
||||
single_chunk_filtered_size: None,
|
||||
single_chunk_filter_mask: None,
|
||||
};
|
||||
let dataspace = Dataspace {
|
||||
space_type: DataspaceType::Simple,
|
||||
rank: 1,
|
||||
dimensions: vec![10],
|
||||
max_dimensions: None,
|
||||
};
|
||||
let datatype = make_f64_type();
|
||||
let file_data = vec![0u8; 64];
|
||||
let result = read_chunked_data(&file_data, &layout, &dataspace, &datatype, None, 8, 8);
|
||||
assert!(
|
||||
matches!(result, Err(FormatError::ChunkedReadError(_))),
|
||||
"expected a clean ChunkedReadError, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_chunk_to_output_1d_rejects_overflowing_offset_without_panicking() {
|
||||
// Found by fuzzing: `global_start * elem_size` overflowed for a
|
||||
// crafted large chunk offset.
|
||||
let chunk_data = vec![1u8; 16];
|
||||
let mut output = vec![0u8; 16];
|
||||
let chunk_offsets = [usize::MAX - 1];
|
||||
let chunk_dims = [1usize];
|
||||
let ds_dims = [usize::MAX];
|
||||
let ds_strides = [1usize];
|
||||
let chunk_strides = [1usize];
|
||||
copy_chunk_to_output(
|
||||
&chunk_data,
|
||||
&mut output,
|
||||
&chunk_offsets,
|
||||
&chunk_dims,
|
||||
&ds_dims,
|
||||
&ds_strides,
|
||||
&chunk_strides,
|
||||
8,
|
||||
1,
|
||||
);
|
||||
// No panic; the out-of-range write was skipped, output left untouched.
|
||||
assert_eq!(output, vec![0u8; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_chunk_to_output_nd_rejects_overflowing_offset_without_panicking() {
|
||||
let chunk_data = vec![1u8; 16];
|
||||
let mut output = vec![0u8; 16];
|
||||
let chunk_offsets = [usize::MAX - 1, 0];
|
||||
let chunk_dims = [1usize, 1usize];
|
||||
let ds_dims = [usize::MAX, usize::MAX];
|
||||
let ds_strides = [1usize, 1usize];
|
||||
let chunk_strides = [1usize, 1usize];
|
||||
copy_chunk_to_output(
|
||||
&chunk_data,
|
||||
&mut output,
|
||||
&chunk_offsets,
|
||||
&chunk_dims,
|
||||
&ds_dims,
|
||||
&ds_strides,
|
||||
&chunk_strides,
|
||||
8,
|
||||
2,
|
||||
);
|
||||
assert_eq!(output, vec![0u8; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_1d_two_chunks_no_compression() {
|
||||
let values: Vec<f64> = (0..20).map(|i| i as f64).collect();
|
||||
@@ -1703,8 +1969,8 @@ mod tests {
|
||||
for chunk_idx in 0..2 {
|
||||
let start = chunk_idx * chunk_elems;
|
||||
let mut chunk_bytes = Vec::new();
|
||||
for i in start..start + chunk_elems {
|
||||
chunk_bytes.extend_from_slice(&values[i].to_le_bytes());
|
||||
for value in values.iter().skip(start).take(chunk_elems) {
|
||||
chunk_bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
let compressed = compress_chunk(&chunk_bytes, &pipeline, elem_size as u32).unwrap();
|
||||
|
||||
@@ -1851,6 +2117,54 @@ mod tests {
|
||||
assert_eq!(err, FormatError::InvalidBTreeNodeType(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_chunk_info_rejects_near_usize_max_offset() {
|
||||
let file_data = vec![0u8; 64];
|
||||
let result = collect_chunk_info(&file_data, u64::MAX - 4, 2, 8, 8);
|
||||
assert!(
|
||||
matches!(result, Err(FormatError::UnexpectedEof { .. })),
|
||||
"expected a clean UnexpectedEof, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_chunk_info_rejects_self_referencing_internal_node() {
|
||||
// A type-1 internal node (level 1) whose single child address points
|
||||
// back to itself: an infinite-recursion / cyclic B-tree attack.
|
||||
let ndims = 2;
|
||||
let os: u8 = 8;
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(b"TREE");
|
||||
buf.push(1); // node_type = 1 (raw data chunks)
|
||||
buf.push(1); // node_level = 1 (internal)
|
||||
buf.extend_from_slice(&1u16.to_le_bytes()); // entries_used = 1
|
||||
write_offset(&mut buf, u64::MAX, os); // left sibling undefined
|
||||
write_offset(&mut buf, u64::MAX, os); // right sibling undefined
|
||||
// key[0]: chunk_size(4) + filter_mask(4) + ndims offsets
|
||||
buf.extend_from_slice(&0u32.to_le_bytes());
|
||||
buf.extend_from_slice(&0u32.to_le_bytes());
|
||||
for _ in 0..ndims {
|
||||
write_offset(&mut buf, 0, os);
|
||||
}
|
||||
// child[0]: points back to offset 0 (this same node) — cyclic.
|
||||
write_offset(&mut buf, 0, os);
|
||||
// final key
|
||||
buf.extend_from_slice(&0u32.to_le_bytes());
|
||||
buf.extend_from_slice(&0u32.to_le_bytes());
|
||||
for _ in 0..ndims {
|
||||
write_offset(&mut buf, u64::MAX, os);
|
||||
}
|
||||
|
||||
let mut file_data = vec![0u8; 256];
|
||||
file_data[..buf.len()].copy_from_slice(&buf);
|
||||
|
||||
let result = collect_chunk_info(&file_data, 0, ndims, os, os);
|
||||
assert!(
|
||||
matches!(result, Err(FormatError::NestingDepthExceeded)),
|
||||
"expected a clean NestingDepthExceeded, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Implicit chunk generation tests ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -17,6 +17,21 @@ use crate::datatype::{Datatype, DatatypeByteOrder};
|
||||
use crate::error::FormatError;
|
||||
use crate::filter_pipeline::FilterPipeline;
|
||||
|
||||
/// Checks that `[offset, offset + needed)` fits within `data`, guarding the
|
||||
/// addition against `usize` overflow from a crafted near-`usize::MAX` offset.
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Zero-copy read of contiguous raw data, returning a borrowed slice.
|
||||
///
|
||||
/// For contiguous layouts, returns a direct `&[u8]` slice into `file_data`.
|
||||
@@ -47,12 +62,7 @@ pub fn read_raw_data_zerocopy<'a>(
|
||||
actual: sz,
|
||||
});
|
||||
}
|
||||
if addr + sz > file_data.len() {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: addr + sz,
|
||||
available: file_data.len(),
|
||||
});
|
||||
}
|
||||
ensure_len(file_data, addr, sz)?;
|
||||
Ok(Some(&file_data[addr..addr + sz]))
|
||||
}
|
||||
_ => Ok(None),
|
||||
@@ -169,12 +179,7 @@ fn read_raw_data_full_impl(
|
||||
actual: sz,
|
||||
});
|
||||
}
|
||||
if addr + sz > file_data.len() {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: addr + sz,
|
||||
available: file_data.len(),
|
||||
});
|
||||
}
|
||||
ensure_len(file_data, addr, sz)?;
|
||||
Ok(file_data[addr..addr + sz].to_vec())
|
||||
}
|
||||
DataLayout::Chunked { .. } => read_chunked_data(
|
||||
@@ -470,8 +475,10 @@ fn read_virtual_data(
|
||||
use crate::selection::Selection;
|
||||
|
||||
let elem_size = datatype.type_size() as usize;
|
||||
let total_elems = dataspace.num_elements() as usize;
|
||||
let mut out = vec![0u8; total_elems.saturating_mul(elem_size)];
|
||||
let mut out = crate::chunked_read::alloc_output(crate::chunked_read::checked_byte_len(
|
||||
dataspace.checked_num_elements()?,
|
||||
elem_size,
|
||||
)?)?;
|
||||
|
||||
let virtual_dims = &dataspace.dimensions;
|
||||
|
||||
@@ -593,7 +600,7 @@ fn read_named_dataset_raw(
|
||||
}
|
||||
|
||||
/// Extract selected elements from a full dataset buffer.
|
||||
fn extract_selection_from_buffer(
|
||||
pub fn extract_selection_from_buffer(
|
||||
full_data: &[u8],
|
||||
dims: &[u64],
|
||||
elem_size: usize,
|
||||
@@ -611,12 +618,14 @@ fn extract_selection_from_buffer(
|
||||
block,
|
||||
} => {
|
||||
let rank = dims.len();
|
||||
let output_elements: usize = count
|
||||
let output_elements = count
|
||||
.iter()
|
||||
.zip(block.iter())
|
||||
.map(|(&c, &b)| (c * b) as usize)
|
||||
.product();
|
||||
let mut output = vec![0u8; output_elements * elem_size];
|
||||
.try_fold(1u64, |acc, (&c, &b)| acc.checked_mul(c.checked_mul(b)?))
|
||||
.ok_or_else(|| FormatError::Overflow("hyperslab count x block overflows".into()))?;
|
||||
let mut output = crate::chunked_read::alloc_output(
|
||||
crate::chunked_read::checked_byte_len(output_elements, elem_size)?,
|
||||
)?;
|
||||
|
||||
// Compute dataset strides (row-major)
|
||||
let mut ds_strides = vec![1usize; rank];
|
||||
@@ -1218,6 +1227,15 @@ pub fn read_compound_fields(
|
||||
for m in members {
|
||||
let field_size = m.datatype.type_size() as usize;
|
||||
let offset = m.byte_offset as usize;
|
||||
if offset
|
||||
.checked_add(field_size)
|
||||
.is_none_or(|end| end > elem_size)
|
||||
{
|
||||
return Err(FormatError::Overflow(format!(
|
||||
"compound member '{}': byte_offset({offset}) + field_size({field_size}) exceeds element size({elem_size})",
|
||||
m.name
|
||||
)));
|
||||
}
|
||||
let mut field_raw = Vec::with_capacity(count * field_size);
|
||||
for i in 0..count {
|
||||
let elem_start = i * elem_size + offset;
|
||||
@@ -1749,11 +1767,11 @@ mod tests {
|
||||
fn f16_bits(v: f32) -> u16 {
|
||||
// Encode a few exact values used by the test.
|
||||
match v {
|
||||
x if x == 0.0 => 0x0000,
|
||||
x if x == 1.0 => 0x3c00,
|
||||
x if x == -2.0 => 0xc000,
|
||||
x if x == 0.5 => 0x3800,
|
||||
x if x == 65504.0 => 0x7bff, // f16 max
|
||||
0.0 => 0x0000,
|
||||
1.0 => 0x3c00,
|
||||
-2.0 => 0xc000,
|
||||
0.5 => 0x3800,
|
||||
65504.0 => 0x7bff, // f16 max
|
||||
_ => panic!("unsupported test value {v}"),
|
||||
}
|
||||
}
|
||||
@@ -2116,6 +2134,43 @@ mod tests {
|
||||
assert_eq!(id_vals, vec![10, 20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_compound_rejects_byte_offset_overrun() {
|
||||
use crate::datatype::CompoundMember;
|
||||
// Compound declares size=8, but the member's byte_offset(4) + its
|
||||
// field_size(8, f64) = 12 > 8 — a crafted out-of-range byte_offset.
|
||||
let dt = Datatype::Compound {
|
||||
size: 8,
|
||||
members: vec![CompoundMember {
|
||||
name: "bad".to_string(),
|
||||
byte_offset: 4,
|
||||
datatype: make_f64_le_type(),
|
||||
}],
|
||||
};
|
||||
let raw = vec![0u8; 8]; // one element, matches declared size
|
||||
let result = read_compound_fields(&raw, &dt);
|
||||
assert!(
|
||||
matches!(result, Err(FormatError::Overflow(_))),
|
||||
"expected a clean Overflow error, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_raw_data_zerocopy_rejects_near_usize_max_offset() {
|
||||
let file_data = vec![0u8; 64];
|
||||
let dataspace = make_simple_dataspace(&[4]);
|
||||
let datatype = make_i32_le_type();
|
||||
let layout = DataLayout::Contiguous {
|
||||
address: Some(u64::MAX - 4),
|
||||
size: 16,
|
||||
};
|
||||
let result = read_raw_data_zerocopy(&file_data, &layout, &dataspace, &datatype);
|
||||
assert!(
|
||||
matches!(result, Err(FormatError::UnexpectedEof { .. })),
|
||||
"expected a clean UnexpectedEof, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_compound_single_field_by_name() {
|
||||
use crate::datatype::CompoundMember;
|
||||
@@ -2135,7 +2190,7 @@ mod tests {
|
||||
],
|
||||
};
|
||||
let mut raw = Vec::new();
|
||||
raw.extend_from_slice(&3.14f64.to_le_bytes());
|
||||
raw.extend_from_slice(&3.25f64.to_le_bytes());
|
||||
raw.extend_from_slice(&42i32.to_le_bytes());
|
||||
|
||||
let field = read_compound_field(&raw, &dt, "id").unwrap();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -204,11 +204,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 +372,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 +382,29 @@ impl Datatype {
|
||||
});
|
||||
}
|
||||
} else if version == 1 || version == 2 {
|
||||
// v1/v2: name, offset(4), dimensionality(1), reserved(3), dim_perm(4),
|
||||
// reserved_dims(up to 4*4=16), member datatype
|
||||
// v1/v2: name (null-terminated, padded to a multiple of 8
|
||||
// bytes), offset(4), member datatype. v1 additionally
|
||||
// carries the legacy per-member array fields between the
|
||||
// offset and the member datatype: dimensionality(1),
|
||||
// reserved(3), dim_perm(4), reserved(4), 4 dim sizes(16).
|
||||
// v1 is what default (non-`latest`) libver bounds emit.
|
||||
for _ in 0..num_members {
|
||||
let (name, name_len) = read_null_terminated_string(data, pos)?;
|
||||
pos += name_len;
|
||||
// v1: names padded to 8-byte boundary
|
||||
if version == 1 {
|
||||
let total_name_bytes = name_len;
|
||||
let padded = (total_name_bytes + 7) & !7;
|
||||
pos = pos - name_len + padded;
|
||||
}
|
||||
let padded = name_len.checked_add(7).ok_or(FormatError::UnexpectedEof {
|
||||
expected: usize::MAX,
|
||||
available: data.len(),
|
||||
})? & !7;
|
||||
ensure_len(data, pos, padded)?;
|
||||
pos += padded;
|
||||
ensure_len(data, pos, 4)?;
|
||||
let byte_offset = LittleEndian::read_u32(&data[pos..pos + 4]) as u64;
|
||||
pos += 4;
|
||||
// dimensionality(1) + reserved(3) + dim_perm(4) + 4 dim slots(16) = 24
|
||||
ensure_len(data, pos, 24)?;
|
||||
pos += 24;
|
||||
let (member_dt, consumed) = 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,
|
||||
@@ -415,7 +435,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 +488,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 +514,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 +535,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 +552,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 +846,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 +1144,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);
|
||||
|
||||
@@ -114,6 +114,20 @@ pub enum FormatError {
|
||||
InvalidAttributeInfoVersion(u8),
|
||||
/// Invalid shared message version.
|
||||
InvalidSharedMessageVersion(u8),
|
||||
/// A message is marked shared but was parsed without access to the file,
|
||||
/// so the reference to the real message could not be followed.
|
||||
UnresolvedSharedMessage,
|
||||
/// The dataset's raw data is stored in external files (External Data
|
||||
/// Files message), which this reader does not follow.
|
||||
ExternalDataFilesUnsupported,
|
||||
/// The path goes through an external link (a link into another file),
|
||||
/// which this reader does not follow.
|
||||
ExternalLinkUnsupported {
|
||||
/// The file the link points into.
|
||||
filename: String,
|
||||
/// The object path within that file.
|
||||
object_path: String,
|
||||
},
|
||||
/// Invalid SOHM table version.
|
||||
InvalidSohmTableVersion(u8),
|
||||
/// Invalid SOHM table signature (expected "SMTB").
|
||||
@@ -307,6 +321,22 @@ impl fmt::Display for FormatError {
|
||||
FormatError::InvalidSharedMessageVersion(v) => {
|
||||
write!(f, "invalid shared message version: {v}")
|
||||
}
|
||||
FormatError::ExternalLinkUnsupported {
|
||||
filename,
|
||||
object_path,
|
||||
} => write!(
|
||||
f,
|
||||
"path goes through an external link to {object_path} in {filename}, which is \
|
||||
not supported"
|
||||
),
|
||||
FormatError::ExternalDataFilesUnsupported => write!(
|
||||
f,
|
||||
"dataset raw data is stored in external file(s), which is not supported"
|
||||
),
|
||||
FormatError::UnresolvedSharedMessage => write!(
|
||||
f,
|
||||
"message is shared but no file data was available to resolve it"
|
||||
),
|
||||
FormatError::InvalidSohmTableVersion(v) => {
|
||||
write!(f, "invalid SOHM table version: {v}")
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -1045,24 +1045,30 @@ fn pcodec_compress(data: &[u8], element_size: usize) -> Result<Vec<u8>, FormatEr
|
||||
match element_size {
|
||||
4 => {
|
||||
let nums: Vec<f32> = data
|
||||
.chunks_exact(4)
|
||||
.map(|b| f32::from_le_bytes(b.try_into().unwrap()))
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|b| f32::from_le_bytes(*b))
|
||||
.collect();
|
||||
simple_compress(&nums, &config)
|
||||
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
|
||||
}
|
||||
8 => {
|
||||
let nums: Vec<f64> = data
|
||||
.chunks_exact(8)
|
||||
.map(|b| f64::from_le_bytes(b.try_into().unwrap()))
|
||||
.as_chunks::<8>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|b| f64::from_le_bytes(*b))
|
||||
.collect();
|
||||
simple_compress(&nums, &config)
|
||||
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
|
||||
}
|
||||
_ => {
|
||||
let nums: Vec<u32> = data
|
||||
.chunks_exact(4)
|
||||
.map(|b| u32::from_le_bytes(b.try_into().unwrap()))
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|b| u32::from_le_bytes(*b))
|
||||
.collect();
|
||||
simple_compress(&nums, &config)
|
||||
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
|
||||
@@ -1092,11 +1098,7 @@ fn pcodec_decompress(
|
||||
} else {
|
||||
MAX_DECOMPRESS_SIZE
|
||||
};
|
||||
let n = if element_size != 0 {
|
||||
limit_bytes / element_size
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let n = limit_bytes.checked_div(element_size).unwrap_or(0);
|
||||
match element_size {
|
||||
4 => {
|
||||
let mut buf = vec![0f32; n];
|
||||
@@ -1543,8 +1545,10 @@ mod tests {
|
||||
|
||||
fn as_f32(bytes: &[u8]) -> Vec<f32> {
|
||||
bytes
|
||||
.chunks_exact(4)
|
||||
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| f32::from_le_bytes(*c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1578,8 +1582,10 @@ mod tests {
|
||||
|
||||
fn as_f64(bytes: &[u8]) -> Vec<f64> {
|
||||
bytes
|
||||
.chunks_exact(8)
|
||||
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
|
||||
.as_chunks::<8>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| f64::from_le_bytes(*c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -63,14 +63,15 @@ fn resolve_compact_entries(
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Resolve entries from dense storage (fractal heap + B-tree v2).
|
||||
fn resolve_dense_entries(
|
||||
/// Visit every link in dense storage (fractal heap + B-tree v2 name index).
|
||||
fn for_each_dense_link(
|
||||
file_data: &[u8],
|
||||
link_info: &LinkInfoMessage,
|
||||
fh_addr: u64,
|
||||
offset_size: u8,
|
||||
length_size: u8,
|
||||
) -> Result<Vec<GroupEntry>, FormatError> {
|
||||
mut visit: impl FnMut(LinkMessage),
|
||||
) -> Result<(), FormatError> {
|
||||
// Parse fractal heap
|
||||
let fh = FractalHeapHeader::parse(file_data, fh_addr as usize, offset_size, length_size)?;
|
||||
|
||||
@@ -81,7 +82,6 @@ fn resolve_dense_entries(
|
||||
let btree_hdr = BTreeV2Header::parse(file_data, btree_addr as usize, offset_size, length_size)?;
|
||||
let records = collect_btree_v2_records(file_data, &btree_hdr, offset_size, length_size)?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for record in &records {
|
||||
// For type 5 (name index): hash(4) + heap_id(heap_id_length)
|
||||
// For type 6 (creation order): creation_order(8) + heap_id(heap_id_length)
|
||||
@@ -98,9 +98,27 @@ fn resolve_dense_entries(
|
||||
|
||||
// Read managed object from fractal heap
|
||||
let link_data = fh.read_managed_object(file_data, id_bytes, offset_size)?;
|
||||
visit(LinkMessage::parse(&link_data, offset_size)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Parse as Link message
|
||||
let link = LinkMessage::parse(&link_data, offset_size)?;
|
||||
/// Resolve entries from dense storage (fractal heap + B-tree v2).
|
||||
fn resolve_dense_entries(
|
||||
file_data: &[u8],
|
||||
link_info: &LinkInfoMessage,
|
||||
fh_addr: u64,
|
||||
offset_size: u8,
|
||||
length_size: u8,
|
||||
) -> Result<Vec<GroupEntry>, FormatError> {
|
||||
let mut entries = Vec::new();
|
||||
for_each_dense_link(
|
||||
file_data,
|
||||
link_info,
|
||||
fh_addr,
|
||||
offset_size,
|
||||
length_size,
|
||||
|link| {
|
||||
if let LinkTarget::Hard {
|
||||
object_header_address,
|
||||
} = link.link_target
|
||||
@@ -111,9 +129,63 @@ fn resolve_dense_entries(
|
||||
cache_type: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
)?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
/// The soft or external link called `name` in this group, if there is one.
|
||||
/// Hard links are what `resolve_group_entries` returns; this is consulted only
|
||||
/// when a path component isn't among them.
|
||||
fn find_symbolic_link(
|
||||
file_data: &[u8],
|
||||
object_header: &ObjectHeader,
|
||||
name: &str,
|
||||
offset_size: u8,
|
||||
length_size: u8,
|
||||
) -> Result<Option<LinkTarget>, FormatError> {
|
||||
if is_v1_group(object_header) {
|
||||
let Some(sym_msg) = object_header
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.msg_type == MessageType::SymbolTable)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let stm = SymbolTableMessage::parse(&sym_msg.data, offset_size)?;
|
||||
return group_v1::find_v1_soft_link(file_data, &stm, name, offset_size, length_size)
|
||||
.map(|target| target.map(|target_path| LinkTarget::Soft { target_path }));
|
||||
}
|
||||
if !is_v2_group(object_header) {
|
||||
return Ok(None);
|
||||
}
|
||||
let is_symbolic = |t: &LinkTarget| !matches!(t, LinkTarget::Hard { .. });
|
||||
let link_info = find_link_info(object_header, offset_size)?;
|
||||
let mut found = None;
|
||||
if let Some(fh_addr) = link_info.fractal_heap_address {
|
||||
for_each_dense_link(
|
||||
file_data,
|
||||
&link_info,
|
||||
fh_addr,
|
||||
offset_size,
|
||||
length_size,
|
||||
|link| {
|
||||
if link.name == name && is_symbolic(&link.link_target) {
|
||||
found = Some(link.link_target);
|
||||
}
|
||||
},
|
||||
)?;
|
||||
} else {
|
||||
for msg in &object_header.messages {
|
||||
if msg.msg_type == MessageType::Link {
|
||||
let link = LinkMessage::parse(&msg.data, offset_size)?;
|
||||
if link.name == name && is_symbolic(&link.link_target) {
|
||||
found = Some(link.link_target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Find and parse the Link Info message from an object header.
|
||||
@@ -158,6 +230,19 @@ pub fn resolve_path_any(
|
||||
file_data: &[u8],
|
||||
superblock: &Superblock,
|
||||
path: &str,
|
||||
) -> Result<u64, FormatError> {
|
||||
resolve_path_following_links(file_data, superblock, path, 0)
|
||||
}
|
||||
|
||||
/// Soft links followed while resolving one path. Guards against link cycles
|
||||
/// (`a -> b -> a`), which are legal to create.
|
||||
const MAX_SOFT_LINK_DEPTH: u8 = 16;
|
||||
|
||||
fn resolve_path_following_links(
|
||||
file_data: &[u8],
|
||||
superblock: &Superblock,
|
||||
path: &str,
|
||||
depth: u8,
|
||||
) -> Result<u64, FormatError> {
|
||||
let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
if components.is_empty() {
|
||||
@@ -176,7 +261,9 @@ pub fn resolve_path_any(
|
||||
for (i, component) in components.iter().enumerate() {
|
||||
let entries = resolve_group_entries(file_data, ¤t_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, ¤t_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))),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -16,6 +16,21 @@ pub struct LocalHeap {
|
||||
pub data_segment_address: u64,
|
||||
}
|
||||
|
||||
/// Checks that `[offset, offset + needed)` fits within `data`, guarding the
|
||||
/// addition against `usize` overflow from a crafted near-`usize::MAX` offset.
|
||||
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 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()) {
|
||||
@@ -47,12 +62,7 @@ impl LocalHeap {
|
||||
let ls = length_size as usize;
|
||||
let os = offset_size as usize;
|
||||
let total = 8 + ls * 2 + os;
|
||||
if offset + total > file_data.len() {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: offset + total,
|
||||
available: file_data.len(),
|
||||
});
|
||||
}
|
||||
ensure_len(file_data, offset, total)?;
|
||||
|
||||
if &file_data[offset..offset + 4] != b"HEAP" {
|
||||
return Err(FormatError::InvalidLocalHeapSignature);
|
||||
@@ -172,6 +182,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_near_usize_max_offset_without_panicking() {
|
||||
// Found by fuzzing: `offset + total` overflowed for a crafted
|
||||
// near-usize::MAX offset.
|
||||
let file = build_heap_file(0, 100, &["hello"], 8, 8);
|
||||
let result = LocalHeap::parse(&file, usize::MAX - 4, 8, 8);
|
||||
assert!(
|
||||
matches!(result, Err(FormatError::UnexpectedEof { .. })),
|
||||
"expected a clean UnexpectedEof, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_heap_header() {
|
||||
let file = build_heap_file(0, 100, &["hello", "world"], 8, 8);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
//! data-integrity verification.
|
||||
//!
|
||||
//! Enable with the `provenance` Cargo feature (on by default).
|
||||
//!
|
||||
//! The hash is unkeyed, so this detects accidental corruption only — it is
|
||||
//! not a tamper-evidence or authenticity guarantee. See [`verify_dataset`].
|
||||
|
||||
#[cfg(not(feature = "std"))]
|
||||
use alloc::{format, string::String, vec::Vec};
|
||||
@@ -115,6 +118,11 @@ pub enum VerifyResult {
|
||||
///
|
||||
/// `file_data` is the entire HDF5 file bytes; `header` is the parsed object
|
||||
/// header for the dataset of interest.
|
||||
///
|
||||
/// This only detects *accidental* corruption. The hash is unkeyed and stored
|
||||
/// alongside the data it protects, so anyone able to modify the dataset can
|
||||
/// also recompute and overwrite `_provenance_sha256` — a `VerifyResult::Ok`
|
||||
/// is not a tamper-evidence or authenticity guarantee.
|
||||
pub fn verify_dataset(
|
||||
file_data: &[u8],
|
||||
header: &ObjectHeader,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user