Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
817c5eee41 |
@@ -22,25 +22,5 @@ jobs:
|
|||||||
run: rustup component add rustfmt clippy
|
run: rustup component add rustfmt clippy
|
||||||
- name: Install thumbv7em-none-eabihf target
|
- name: Install thumbv7em-none-eabihf target
|
||||||
run: rustup target add thumbv7em-none-eabihf
|
run: rustup target add thumbv7em-none-eabihf
|
||||||
- name: Install Python interop dependencies
|
|
||||||
# The interop suites used to skip silently when python3/h5py were
|
|
||||||
# missing, so they never ran in CI. Install them and make a missing
|
|
||||||
# dependency a failure (CLAWHDF5_REQUIRE_INTEROP below).
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y --no-install-recommends python3 python3-venv
|
|
||||||
python3 -m venv /opt/interop
|
|
||||||
/opt/interop/bin/pip install --no-cache-dir h5py numpy netCDF4 xarray
|
|
||||||
echo "/opt/interop/bin" >> "$GITHUB_PATH"
|
|
||||||
- name: Show interop library versions
|
|
||||||
run: /opt/interop/bin/python -c "import h5py, netCDF4; print('h5py', h5py.__version__, 'HDF5', h5py.version.hdf5_version, 'netCDF4', netCDF4.__version__)"
|
|
||||||
- name: Run CI script
|
- name: Run CI script
|
||||||
env:
|
|
||||||
# Name the interpreter outright rather than relying on $GITHUB_PATH
|
|
||||||
# reaching the test processes: if `python3` resolved to the system
|
|
||||||
# one instead of the venv, every interop suite would skip.
|
|
||||||
# CLAWHDF5_REQUIRE_INTEROP turns that skip into a failure, so the
|
|
||||||
# two together mean the suites either run or the build goes red.
|
|
||||||
CLAWHDF5_PYTHON: /opt/interop/bin/python
|
|
||||||
CLAWHDF5_REQUIRE_INTEROP: "1"
|
|
||||||
run: bash scripts/ci-test.sh
|
run: bash scripts/ci-test.sh
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ benchmarks/longmemeval/*.json
|
|||||||
|
|
||||||
# Local model weights (MiniLM etc.) — large, not committed
|
# Local model weights (MiniLM etc.) — large, not committed
|
||||||
weights/
|
weights/
|
||||||
.venv
|
|
||||||
|
|||||||
+1
-494
@@ -28,402 +28,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Memory footprint
|
|
||||||
|
|
||||||
`cargo run --release -p clawhdf5-bench --bin search_harness -- --footprint --full`,
|
|
||||||
384-dim `f32`. The figure that matters is **reopened**: a store loaded from
|
|
||||||
disk, which is what a long-lived process holds.
|
|
||||||
|
|
||||||
Measured with a counting global allocator, not RSS. RSS cannot see this from
|
|
||||||
inside one process — freeing a large structure returns its pages to the
|
|
||||||
allocator's pool rather than to the OS, so allocating the next one shows no
|
|
||||||
change at all. Measured that way a store holding the corpus twice and one
|
|
||||||
holding it once came out *identical* (1.00x both), which is how the first
|
|
||||||
attempt at this measurement went.
|
|
||||||
|
|
||||||
| N | vectors (raw) | reopened, before | reopened, after |
|
|
||||||
|---:|---:|---:|---:|
|
|
||||||
| 1 000 | 1 MiB | 5 MiB (3.41x) | 4 MiB (2.39x) |
|
|
||||||
| 10 000 | 15 MiB | 50 MiB (3.43x) | 35 MiB (2.42x) |
|
|
||||||
| 100 000 | 146 MiB | 505 MiB (3.44x) | **357 MiB (2.43x)** |
|
|
||||||
|
|
||||||
The cache stored every embedding twice — once as a `Vec<Vec<f32>>` and once
|
|
||||||
flattened for the batched kernels, kept in lock-step on every push, update and
|
|
||||||
compaction. Storing only the flat buffer and indexing into it gives back
|
|
||||||
almost exactly one copy of the corpus (148 MiB at 100k) and one heap
|
|
||||||
allocation per entry. Recall and query latency are unchanged.
|
|
||||||
|
|
||||||
What remains at 2.43x: the flat vectors (1.0x), the HNSW index's own copy of
|
|
||||||
them (1.0x), and text, ids and graph (~0.4x). The index copy is the next
|
|
||||||
target — it is what a quantised or borrowed representation would address.
|
|
||||||
|
|
||||||
### Quantising the index copy (`quantized_index`)
|
|
||||||
|
|
||||||
`MemoryConfig::quantized_index` stores the index's copy as `i8` instead of
|
|
||||||
`f32`. Same harness, same binary, `--footprint --full` with and without
|
|
||||||
`--int8`:
|
|
||||||
|
|
||||||
| N | vectors (raw) | indexes, f32 | indexes, int8 | reopened, f32 | reopened, int8 |
|
|
||||||
|---:|---:|---:|---:|---:|---:|
|
|
||||||
| 1 000 | 1 MiB | 2 MiB | 1 MiB | 4 MiB (2.40x) | 2 MiB (1.64x) |
|
|
||||||
| 10 000 | 15 MiB | 32 MiB | 14 MiB | 44 MiB (3.03x) | 27 MiB (1.81x) |
|
|
||||||
| 100 000 | 146 MiB | 266 MiB | **123 MiB** | 399 MiB (2.72x) | **256 MiB (1.74x)** |
|
|
||||||
|
|
||||||
The scale is **per row**, not global. A unit-length row in `d` dimensions has
|
|
||||||
components around `1/sqrt(d)`, so a fixed `[-1, 1]` scale spends fewer than 12
|
|
||||||
of the 255 levels on a 128-dimensional vector: measured against an exact
|
|
||||||
ranking that gives 0.35 top-10 overlap — unusable. Scaling each row by its own
|
|
||||||
largest component brings the same measurement to 0.99.
|
|
||||||
|
|
||||||
Quantised distances still cost recall on their own, and **`ef` does not buy it
|
|
||||||
back**, because the loss is in the distances rather than in the graph
|
|
||||||
(`--ann-only --full`, N = 100 000):
|
|
||||||
|
|
||||||
| ef | recall@10, f32 | recall@10, int8 | recall@10, int8 + re-score |
|
|
||||||
|---:|---:|---:|---:|
|
|
||||||
| 32 | 0.9775 | 0.9415 | 0.9785 |
|
|
||||||
| 64 | 0.9945 | 0.9625 | 0.9940 |
|
|
||||||
| 128 | 0.9995 | 0.9670 | 0.9990 |
|
|
||||||
| 256 | 0.9995 | 0.9670 (ceiling) | 0.9990 |
|
|
||||||
|
|
||||||
Re-scoring closes the gap: the store already holds the exact embeddings, so
|
|
||||||
the query path re-scores the candidate pool against them before fusion. That
|
|
||||||
is done automatically whenever the index is quantised. What it costs is
|
|
||||||
throughput — about 13% of QPS and 16% of build time at 100 000 x 384. So the
|
|
||||||
setting trades ~13% of query speed for ~36% of the process's memory at equal
|
|
||||||
recall. It is **off by default**: the right side of that trade depends on
|
|
||||||
whether the deployment is short of memory or short of CPU.
|
|
||||||
|
|
||||||
A measurement trap worth recording: the synthetic `clustered` generator in the
|
|
||||||
`clawhdf5-ann` tests draws clusters far tighter than any real embedding, so
|
|
||||||
neighbours there sit closer together than the quantisation error and top-10
|
|
||||||
*identity* is noise. Scored on that fixture int8 looks catastrophic (0.57
|
|
||||||
overlap) — a fact about the fixture, not the storage. The tests use random
|
|
||||||
vectors, and recall is measured against brute-force ground truth rather than
|
|
||||||
against the f32 index, whose own approximation errors a re-scored search is
|
|
||||||
entitled to get right.
|
|
||||||
|
|
||||||
## Read harness
|
|
||||||
|
|
||||||
Produced by `cargo run --release -p clawhdf5-bench --bin read_harness`: a 4096 x
|
|
||||||
2048 `f64` dataset (64 MB) written three ways, read in full and through four
|
|
||||||
hyperslab selections, each from a fresh file handle. The last column is the
|
|
||||||
point: does a selection cost what the *selection* costs?
|
|
||||||
|
|
||||||
### Baseline (v2.4.0): every selection decodes the whole dataset
|
|
||||||
|
|
||||||
4096 x 2048 f64 (64 MB per dataset), chunks 256 x 256, file 129 MB
|
|
||||||
|
|
||||||
| layout | read | selected | time ms | MB/s of selection | vs full read |
|
|
||||||
|---|---|---:|---:|---:|---:|
|
|
||||||
| chunked + deflate | full (first) | 64 MB | 181.8 | 352 | |
|
|
||||||
| chunked + deflate | full (repeat) | 64 MB | 162.1 | 395 | 1.00x |
|
|
||||||
| chunked + deflate | 64 x 64 window (1 chunk) | 0.03 MB | 104.89 | 0 | 0.577x |
|
|
||||||
| chunked + deflate | 512 x 512 window (4-9 chunks) | 2.00 MB | 110.26 | 18 | 0.606x |
|
|
||||||
| chunked + deflate | one row | 0.02 MB | 105.81 | 0 | 0.582x |
|
|
||||||
| chunked + deflate | one column | 0.03 MB | 108.37 | 0 | 0.596x |
|
|
||||||
| chunked | full (first) | 64 MB | 97.4 | 657 | |
|
|
||||||
| chunked | full (repeat) | 64 MB | 86.7 | 738 | 1.00x |
|
|
||||||
| chunked | 64 x 64 window (1 chunk) | 0.03 MB | 40.97 | 1 | 0.420x |
|
|
||||||
| chunked | 512 x 512 window (4-9 chunks) | 2.00 MB | 44.66 | 45 | 0.458x |
|
|
||||||
| chunked | one row | 0.02 MB | 30.88 | 1 | 0.317x |
|
|
||||||
| chunked | one column | 0.03 MB | 30.27 | 1 | 0.311x |
|
|
||||||
| contiguous | full (first) | 64 MB | 57.6 | 1112 | |
|
|
||||||
| contiguous | full (repeat) | 64 MB | 53.5 | 1195 | 1.00x |
|
|
||||||
| contiguous | 64 x 64 window (1 chunk) | 0.03 MB | 30.97 | 1 | 0.538x |
|
|
||||||
| contiguous | 512 x 512 window (4-9 chunks) | 2.00 MB | 31.64 | 63 | 0.550x |
|
|
||||||
| contiguous | one row | 0.02 MB | 31.90 | 0 | 0.554x |
|
|
||||||
| contiguous | one column | 0.03 MB | 29.36 | 1 | 0.510x |
|
|
||||||
|
|
||||||
### After: partial reads
|
|
||||||
|
|
||||||
Only the rows of a contiguous dataset, or the chunks, that overlap the
|
|
||||||
selection's bounding box are read/decoded. A 64 x 64 window of the compressed
|
|
||||||
dataset: **105 -> 0.39 ms**; one row: **106 -> 2.7 ms**; one column:
|
|
||||||
**108 -> 5.2 ms**. (Absolute full-read times differ between the two runs
|
|
||||||
because the machine's speed drifted; compare the *vs full read* column.)
|
|
||||||
|
|
||||||
4096 x 2048 f64 (64 MB per dataset), chunks 256 x 256, file 129 MB
|
|
||||||
|
|
||||||
| layout | read | selected | time ms | MB/s of selection | vs full read |
|
|
||||||
|---|---|---:|---:|---:|---:|
|
|
||||||
| chunked + deflate | full (first) | 64 MB | 112.5 | 569 | |
|
|
||||||
| chunked + deflate | full (repeat) | 64 MB | 104.5 | 612 | 1.00x |
|
|
||||||
| chunked + deflate | 64 x 64 window (1 chunk) | 0.03 MB | 0.39 | 81 | 0.003x |
|
|
||||||
| chunked + deflate | 512 x 512 window (4-9 chunks) | 2.00 MB | 4.85 | 412 | 0.043x |
|
|
||||||
| chunked + deflate | one row | 0.02 MB | 2.69 | 6 | 0.024x |
|
|
||||||
| chunked + deflate | one column | 0.03 MB | 5.23 | 6 | 0.046x |
|
|
||||||
| chunked | full (first) | 64 MB | 70.0 | 915 | |
|
|
||||||
| chunked | full (repeat) | 64 MB | 61.7 | 1037 | 1.00x |
|
|
||||||
| chunked | 64 x 64 window (1 chunk) | 0.03 MB | 0.06 | 541 | 0.001x |
|
|
||||||
| chunked | 512 x 512 window (4-9 chunks) | 2.00 MB | 1.99 | 1005 | 0.028x |
|
|
||||||
| chunked | one row | 0.02 MB | 0.05 | 285 | 0.001x |
|
|
||||||
| chunked | one column | 0.03 MB | 0.45 | 69 | 0.006x |
|
|
||||||
| contiguous | full (first) | 64 MB | 60.3 | 1062 | |
|
|
||||||
| contiguous | full (repeat) | 64 MB | 56.4 | 1134 | 1.00x |
|
|
||||||
| contiguous | 64 x 64 window (1 chunk) | 0.03 MB | 0.08 | 396 | 0.001x |
|
|
||||||
| contiguous | 512 x 512 window (4-9 chunks) | 2.00 MB | 2.12 | 944 | 0.035x |
|
|
||||||
| contiguous | one row | 0.02 MB | 0.03 | 576 | 0.000x |
|
|
||||||
| contiguous | one column | 0.03 MB | 2.55 | 12 | 0.042x |
|
|
||||||
|
|
||||||
### After: parallel cached decode, fewer copies (full reads)
|
|
||||||
|
|
||||||
Full-read times, old and new binaries run alternately at the same moment (this
|
|
||||||
machine's absolute speed drifts over a long session, so only same-moment
|
|
||||||
comparisons mean anything):
|
|
||||||
|
|
||||||
| layout (64 MB `f64`) | before | after |
|
|
||||||
|---|---:|---:|
|
|
||||||
| chunked + deflate | 110 ms | 69 ms |
|
|
||||||
| chunked | 72 ms | 60 ms |
|
|
||||||
| contiguous | 56 ms | 30 ms |
|
|
||||||
|
|
||||||
What changed: the facade's cached read path decompressed chunks one at a time
|
|
||||||
(only the uncached reader was parallel) and pushed every chunk through a 16 MiB
|
|
||||||
cache that a 64 MB read simply churns; it now decodes cache misses in parallel
|
|
||||||
batches and caches only datasets that fit. Unfiltered chunks are copied
|
|
||||||
straight from the file bytes instead of via two intermediate buffers. A
|
|
||||||
contiguous dataset is converted straight from the file bytes (one copy instead
|
|
||||||
of two), and the native-endian conversions no longer zero a buffer they are
|
|
||||||
about to overwrite.
|
|
||||||
|
|
||||||
## Search harness baseline (v2.3.0)
|
|
||||||
|
|
||||||
Produced by `cargo run --release -p clawhdf5-bench --bin search_harness -- --full`
|
|
||||||
on deterministic **clustered** synthetic data (384-dim, unit-normalised; points =
|
|
||||||
cluster centre + noise — uniform random vectors are nearly equidistant in high
|
|
||||||
dimension and say nothing about embeddings). Recall is measured against an exact
|
|
||||||
brute-force scan, 200 queries. This is the *before* picture for the search
|
|
||||||
hot-path work; every change to that path should be justified by a re-run.
|
|
||||||
|
|
||||||
Two things stand out:
|
|
||||||
|
|
||||||
* **HNSW recall does not respond to `ef`** and degrades sharply with size
|
|
||||||
(0.87 → 0.67 → 0.31 recall@10 at 1K / 10K / 100K). Latency plateaus at the same
|
|
||||||
point, i.e. the search exhausts the nodes it can reach: on clustered data the
|
|
||||||
graph is poorly connected. The index selects neighbours by plain top-M
|
|
||||||
distance rather than the HNSW paper's diversity heuristic.
|
|
||||||
* **End-to-end `hybrid_search` is ~1000x slower than its vector stage** (49 ms
|
|
||||||
vs ~0.03 ms at 10K; 884 ms at 100K). Each query rebuilds the BM25 index from
|
|
||||||
scratch and rewrites the whole `.h5` file. The first query after `open()`
|
|
||||||
additionally rebuilds the HNSW index (10.5 s at 100K).
|
|
||||||
|
|
||||||
### HNSW, N = 1000, dim = 384, M = 16, ef_construction = 64
|
|
||||||
|
|
||||||
build: 72.4 ms (13818 vectors/s) · exact scan: 3854 QPS, p50 258 µs
|
|
||||||
|
|
||||||
| ef | recall@10 | QPS | p50 µs | p99 µs |
|
|
||||||
|---:|---:|---:|---:|---:|
|
|
||||||
| 16 | 0.8710 | 59484 | 16 | 31 |
|
|
||||||
| 32 | 0.8730 | 46302 | 21 | 25 |
|
|
||||||
| 64 | 0.8730 | 31683 | 31 | 44 |
|
|
||||||
| 128 | 0.8730 | 24715 | 40 | 49 |
|
|
||||||
| 256 | 0.8730 | 24788 | 40 | 50 |
|
|
||||||
|
|
||||||
### HNSW, N = 10000, dim = 384, M = 16, ef_construction = 64
|
|
||||||
|
|
||||||
build: 802.5 ms (12461 vectors/s) · exact scan: 418 QPS, p50 2363 µs
|
|
||||||
|
|
||||||
| ef | recall@10 | QPS | p50 µs | p99 µs |
|
|
||||||
|---:|---:|---:|---:|---:|
|
|
||||||
| 16 | 0.6695 | 44031 | 19 | 51 |
|
|
||||||
| 32 | 0.6705 | 45066 | 22 | 30 |
|
|
||||||
| 64 | 0.6705 | 32746 | 30 | 41 |
|
|
||||||
| 128 | 0.6705 | 27542 | 36 | 51 |
|
|
||||||
| 256 | 0.6705 | 27754 | 36 | 49 |
|
|
||||||
|
|
||||||
### HNSW, N = 100000, dim = 384, M = 16, ef_construction = 64
|
|
||||||
|
|
||||||
build: 9752.6 ms (10254 vectors/s) · exact scan: 40 QPS, p50 24648 µs
|
|
||||||
|
|
||||||
| ef | recall@10 | QPS | p50 µs | p99 µs |
|
|
||||||
|---:|---:|---:|---:|---:|
|
|
||||||
| 16 | 0.3085 | 18046 | 57 | 84 |
|
|
||||||
| 32 | 0.3110 | 21621 | 43 | 75 |
|
|
||||||
| 64 | 0.3130 | 20015 | 49 | 70 |
|
|
||||||
| 128 | 0.3135 | 15822 | 63 | 99 |
|
|
||||||
| 256 | 0.3135 | 15308 | 66 | 124 |
|
|
||||||
|
|
||||||
### End to end: `HDF5Memory::hybrid_search` (k = 10, weights 0.7 / 0.3)
|
|
||||||
|
|
||||||
| N | ingest ms | checkpoint ms | open ms | first query ms | p50 ms | p99 ms | QPS |
|
|
||||||
|---:|---:|---:|---:|---:|---:|---:|---:|
|
|
||||||
| 1000 | 11 | 3.9 | 0.9 | 68.1 | 5.48 | 5.57 | 182.5 |
|
|
||||||
| 10000 | 114 | 32.2 | 10.9 | 845.0 | 48.56 | 78.65 | 19.8 |
|
|
||||||
| 100000 | 1486 | 713.0 | 354.5 | 10486.5 | 883.51 | 975.23 | 1.1 |
|
|
||||||
wrote /tmp/claude-1000/-home-osobh-projects-clawhdf5/422f755e-dd25-4c35-8613-5439087e3aaa/scratchpad/baseline_full.json
|
|
||||||
|
|
||||||
### After: HNSW neighbour-selection heuristic
|
|
||||||
|
|
||||||
Same harness, same data, after replacing closest-M neighbour selection with the
|
|
||||||
HNSW paper's diversity heuristic (Algorithm 4, keeping pruned connections) for
|
|
||||||
both new links and back-link pruning. Recall@10 at `ef = 64`: **0.87 → 1.00**
|
|
||||||
(1K), **0.67 → 1.00** (10K), **0.31 → 0.98** (100K), and it now rises with
|
|
||||||
`ef` as it should. The cost is a slower build (extra distance evaluations per
|
|
||||||
insert: ~3.5x at 10K); the distance-kernel work that follows targets that.
|
|
||||||
|
|
||||||
### HNSW, N = 1000, dim = 384, M = 16, ef_construction = 64
|
|
||||||
|
|
||||||
build: 221.3 ms (4519 vectors/s) · exact scan: 3851 QPS, p50 258 µs
|
|
||||||
|
|
||||||
| ef | recall@10 | QPS | p50 µs | p99 µs |
|
|
||||||
|---:|---:|---:|---:|---:|
|
|
||||||
| 16 | 0.9990 | 54760 | 18 | 29 |
|
|
||||||
| 32 | 1.0000 | 40422 | 24 | 44 |
|
|
||||||
| 64 | 1.0000 | 27744 | 36 | 51 |
|
|
||||||
| 128 | 1.0000 | 13164 | 74 | 106 |
|
|
||||||
| 256 | 1.0000 | 6879 | 144 | 175 |
|
|
||||||
|
|
||||||
### HNSW, N = 10000, dim = 384, M = 16, ef_construction = 64
|
|
||||||
|
|
||||||
build: 2733.5 ms (3658 vectors/s) · exact scan: 423 QPS, p50 2362 µs
|
|
||||||
|
|
||||||
| ef | recall@10 | QPS | p50 µs | p99 µs |
|
|
||||||
|---:|---:|---:|---:|---:|
|
|
||||||
| 16 | 0.9975 | 31321 | 27 | 61 |
|
|
||||||
| 32 | 1.0000 | 32427 | 29 | 48 |
|
|
||||||
| 64 | 1.0000 | 22738 | 42 | 62 |
|
|
||||||
| 128 | 1.0000 | 10055 | 99 | 129 |
|
|
||||||
| 256 | 1.0000 | 4649 | 214 | 266 |
|
|
||||||
|
|
||||||
### HNSW, N = 100000, dim = 384, M = 16, ef_construction = 64
|
|
||||||
|
|
||||||
build: 36472.8 ms (2742 vectors/s) · exact scan: 40 QPS, p50 24644 µs
|
|
||||||
|
|
||||||
| ef | recall@10 | QPS | p50 µs | p99 µs |
|
|
||||||
|---:|---:|---:|---:|---:|
|
|
||||||
| 16 | 0.9235 | 11394 | 82 | 194 |
|
|
||||||
| 32 | 0.9675 | 12788 | 73 | 161 |
|
|
||||||
| 64 | 0.9840 | 10406 | 91 | 186 |
|
|
||||||
| 128 | 0.9990 | 7633 | 126 | 248 |
|
|
||||||
| 256 | 0.9990 | 2823 | 352 | 510 |
|
|
||||||
|
|
||||||
### After: persistent keyword index, no store rewrite per query
|
|
||||||
|
|
||||||
`hybrid_search` used to rebuild the BM25 index from scratch (re-tokenising every
|
|
||||||
record) and rewrite the whole `.h5` file on **every query**. The index is now
|
|
||||||
kept for the life of the store and updated incrementally, and activation boosts
|
|
||||||
are persisted by the next checkpoint instead of inside the query. Steady-state
|
|
||||||
p50: **5.5 → 0.24 ms** (1K), **49 → 2.1 ms** (10K), **884 → 23 ms** (100K).
|
|
||||||
|
|
||||||
The first query after `open()` is slower than before (it pays for the better —
|
|
||||||
slower — HNSW build plus the one-off keyword index build); persisting the HNSW
|
|
||||||
index removes that.
|
|
||||||
|
|
||||||
### End to end: `HDF5Memory::hybrid_search` (k = 10, weights 0.7 / 0.3)
|
|
||||||
|
|
||||||
| N | ingest ms | checkpoint ms | open ms | first query ms | p50 ms | p99 ms | QPS |
|
|
||||||
|---:|---:|---:|---:|---:|---:|---:|---:|
|
|
||||||
| 1000 | 11 | 3.8 | 0.9 | 195.9 | 0.24 | 0.27 | 4130.4 |
|
|
||||||
| 10000 | 104 | 31.1 | 10.9 | 2627.1 | 2.09 | 2.11 | 479.5 |
|
|
||||||
| 100000 | 1436 | 684.7 | 278.0 | 36308.1 | 22.90 | 25.46 | 43.5 |
|
|
||||||
|
|
||||||
### After: vector index persisted with the checkpoint
|
|
||||||
|
|
||||||
The HNSW graph (not the vectors, which the store already holds) is saved to
|
|
||||||
`<store>.h5.ann` at each checkpoint and reloaded by `open()`, tied to that
|
|
||||||
checkpoint by a generation id. The index is now built once per store (the *cold
|
|
||||||
index build* column — the first query ever), not once per session. First query
|
|
||||||
after `open()`: **196 → 1.7 ms** (1K), **2627 → 15 ms** (10K),
|
|
||||||
**36308 → 159 ms** (100K); what remains is the one-off keyword index build.
|
|
||||||
Batch saves no longer force a full rebuild either: appended records join the
|
|
||||||
index incrementally.
|
|
||||||
|
|
||||||
| N | ingest ms | cold index build ms | checkpoint ms | open ms | first query after open ms | p50 ms | p99 ms | QPS |
|
|
||||||
|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
|
||||||
| 1000 | 14 | 220 | 6.1 | 1.2 | 1.7 | 0.24 | 0.27 | 4049.9 |
|
|
||||||
| 10000 | 120 | 2916 | 33.1 | 14.0 | 15.4 | 2.15 | 3.30 | 421.2 |
|
|
||||||
| 100000 | 1591 | 40515 | 747.3 | 324.7 | 158.9 | 23.07 | 30.42 | 41.3 |
|
|
||||||
|
|
||||||
### After: unit-vector dot product, reusable visited set
|
|
||||||
|
|
||||||
Cosine distance recomputed both vector norms on every evaluation; the index now
|
|
||||||
stores unit vectors and uses a plain dot product. The per-call `HashSet` of
|
|
||||||
visited nodes became a reusable epoch-stamped array. Recall is unchanged.
|
|
||||||
Build: **2.75 -> 1.89 s** (10K), **~38 -> 21 s** (100K). QPS at `ef = 64`:
|
|
||||||
**22.7K -> 39K** (10K), **10.4K -> 14K** (100K).
|
|
||||||
|
|
||||||
### HNSW, N = 1000, dim = 384, M = 16, ef_construction = 64
|
|
||||||
|
|
||||||
build: 113.4 ms (8821 vectors/s) · exact scan: 4375 QPS, p50 225 µs
|
|
||||||
|
|
||||||
| ef | recall@10 | QPS | p50 µs | p99 µs |
|
|
||||||
|---:|---:|---:|---:|---:|
|
|
||||||
| 16 | 0.9990 | 144379 | 7 | 15 |
|
|
||||||
| 32 | 1.0000 | 110654 | 9 | 17 |
|
|
||||||
| 64 | 1.0000 | 80446 | 12 | 25 |
|
|
||||||
| 128 | 1.0000 | 38220 | 26 | 36 |
|
|
||||||
| 256 | 1.0000 | 20041 | 50 | 62 |
|
|
||||||
|
|
||||||
### HNSW, N = 10000, dim = 384, M = 16, ef_construction = 64
|
|
||||||
|
|
||||||
build: 1519.4 ms (6581 vectors/s) · exact scan: 422 QPS, p50 2368 µs
|
|
||||||
|
|
||||||
| ef | recall@10 | QPS | p50 µs | p99 µs |
|
|
||||||
|---:|---:|---:|---:|---:|
|
|
||||||
| 16 | 0.9975 | 54608 | 15 | 45 |
|
|
||||||
| 32 | 1.0000 | 66009 | 14 | 24 |
|
|
||||||
| 64 | 1.0000 | 49854 | 19 | 31 |
|
|
||||||
| 128 | 1.0000 | 22403 | 45 | 57 |
|
|
||||||
| 256 | 1.0000 | 10096 | 100 | 120 |
|
|
||||||
|
|
||||||
### HNSW, N = 100000, dim = 384, M = 16, ef_construction = 64
|
|
||||||
|
|
||||||
build: 21084.6 ms (4743 vectors/s) · exact scan: 39 QPS, p50 24739 µs
|
|
||||||
|
|
||||||
| ef | recall@10 | QPS | p50 µs | p99 µs |
|
|
||||||
|---:|---:|---:|---:|---:|
|
|
||||||
| 16 | 0.9235 | 15139 | 61 | 154 |
|
|
||||||
| 32 | 0.9675 | 18181 | 53 | 121 |
|
|
||||||
| 64 | 0.9840 | 13980 | 70 | 139 |
|
|
||||||
| 128 | 0.9990 | 10959 | 86 | 174 |
|
|
||||||
| 256 | 0.9990 | 3731 | 254 | 697 |
|
|
||||||
|
|
||||||
|
|
||||||
### After: unranked keyword scores, top-k merge (rankings unchanged)
|
|
||||||
|
|
||||||
A fusion study (`search_harness --fusion-study`) showed that capping the
|
|
||||||
keyword candidate pool is **not** a safe optimisation: against the current
|
|
||||||
full-corpus normalisation the final top-10 overlap is only 0.83-0.92 and the
|
|
||||||
first result changes for 10-35% of queries, for only a 2x saving. So the fusion
|
|
||||||
semantics were left alone and the same answer made cheaper: fusion needs every
|
|
||||||
keyword score but not their ranking, so BM25 now returns them unsorted from a
|
|
||||||
dense accumulator (it hashed every posting and then sorted every match), and
|
|
||||||
the merge selects its top k instead of sorting every candidate. Steady-state
|
|
||||||
p50: **0.24 -> 0.07 ms** (1K), **2.1 -> 0.49 ms** (10K), **23 -> 4.65 ms**
|
|
||||||
(100K) — **79x / 100x / 190x** faster than the v2.3.0 baseline, with identical
|
|
||||||
results.
|
|
||||||
|
|
||||||
### End to end: `HDF5Memory::hybrid_search` (k = 10, weights 0.7 / 0.3)
|
|
||||||
|
|
||||||
| N | ingest ms | cold index build ms | checkpoint ms | open ms | first query after open ms | p50 ms | p99 ms | QPS |
|
|
||||||
|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
|
||||||
| 1000 | 11 | 112 | 4.0 | 1.1 | 1.4 | 0.07 | 0.08 | 14077.9 |
|
|
||||||
| 10000 | 104 | 1487 | 33.8 | 13.7 | 13.9 | 0.49 | 0.51 | 2020.9 |
|
|
||||||
| 100000 | 1376 | 20285 | 728.9 | 353.1 | 142.2 | 4.65 | 4.78 | 214.7 |
|
|
||||||
|
|
||||||
### After: batched bulk build (optionally parallel); deletions handled in search
|
|
||||||
|
|
||||||
Profiling showed **90% of a build's distance evaluations are in back-link
|
|
||||||
pruning**. The bulk build now inserts in batches: plan each node's neighbours
|
|
||||||
against the graph as it stood at the start of the batch, link, then prune every
|
|
||||||
overflowing list once. That is less work even single-threaded (a node gaining
|
|
||||||
several back-links in a batch is pruned once), and with the `parallel` feature
|
|
||||||
planning and pruning run on a thread pool. The graph is deterministic and the
|
|
||||||
same with or without the feature. Parallelising *within* one insert was tried
|
|
||||||
first and gave only 1.45x on 16 cores (tasks too small).
|
|
||||||
|
|
||||||
| build | 1K | 10K | 100K |
|
|
||||||
|---|---:|---:|---:|
|
|
||||||
| v2.4.0 | 116 ms | 1676 ms | ~21 s |
|
|
||||||
| batched | 83 ms | 1074 ms | 19.2 s |
|
|
||||||
| batched + `parallel` (16 cores) | 34 ms | 388 ms | 5.9 s |
|
|
||||||
|
|
||||||
Recall on clustered data is unchanged or slightly better (100K, `ef = 64`:
|
|
||||||
0.984 -> 0.9945). On uniform random data it dips slightly (10K, `ef = 64`:
|
|
||||||
0.474 -> 0.444), the cost of batch members not seeing each other while
|
|
||||||
planning; batches are capped at 1/16 of the graph and 512 nodes.
|
|
||||||
|
|
||||||
## Vector Search Latency
|
## Vector Search Latency
|
||||||
|
|
||||||
Brute-force cosine similarity over 384-dimensional embeddings (OpenAI text-embedding-3-small size).
|
Brute-force cosine similarity over 384-dimensional embeddings (OpenAI text-embedding-3-small size).
|
||||||
@@ -672,102 +276,6 @@ Session-level:
|
|||||||
| Vector only | 85.4% | 94.2% | 96.6% | 0.8901 |
|
| Vector only | 85.4% | 94.2% | 96.6% | 0.8901 |
|
||||||
| Hybrid | **88.2%** | **95.8%** | **97.8%** | **0.9158** |
|
| Hybrid | **88.2%** | **95.8%** | **97.8%** | **0.9158** |
|
||||||
|
|
||||||
### Fusion method — weighted vs. RRF, full haystack, n=500
|
|
||||||
|
|
||||||
Reciprocal rank fusion has been in the codebase since early on but was only
|
|
||||||
reachable as a free function over a linear scan, so it had never been compared
|
|
||||||
with the weighted sum on equal terms. `HDF5Memory::hybrid_search_with` now
|
|
||||||
takes a `Fusion`, and both run over the same HNSW + BM25 candidates:
|
|
||||||
|
|
||||||
| Mode | turn Hit@1 | Hit@5 | Hit@10 | MRR | session Hit@1 | session MRR |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| BM25 only | **53.8%** | 75.0% | 81.6% | 0.6320 | 86.2% | 0.8948 |
|
|
||||||
| Vector only | 36.0% | 71.8% | 81.6% | 0.5031 | 85.4% | 0.8901 |
|
|
||||||
| **Weighted 0.4 / 0.6** | 51.6% | **81.4%** | **87.8%** | **0.6430** | **91.0%** | **0.9347** |
|
|
||||||
| RRF (k=60) | 45.0% | 78.8% | 87.6% | 0.5967 | 89.6% | 0.9253 |
|
|
||||||
|
|
||||||
**RRF loses to the tuned weighted sum** — 6.6pp of turn Hit@1 and 0.046 of MRR
|
|
||||||
— and lands almost exactly where the old `0.7/0.3` weighting did (44.2% /
|
|
||||||
0.5856). That is not a coincidence: RRF combines the two stages by rank with
|
|
||||||
*equal* influence, and on this corpus the stages are not equally good. BM25
|
|
||||||
alone beats the vector stage by 17.8pp at Hit@1, so any scheme that treats them
|
|
||||||
as peers gives up rank-1 accuracy, and RRF discards the score magnitudes that
|
|
||||||
would say which stage to believe.
|
|
||||||
|
|
||||||
This is a property of the corpus, not a defect in RRF: its selling point is
|
|
||||||
robustness when the two stages' scores are not comparable and there is no
|
|
||||||
labelled data to tune against. Here there is, so the weighted sum is kept as
|
|
||||||
the default. `Fusion::Rrf` remains available for callers whose stages are more
|
|
||||||
evenly matched.
|
|
||||||
|
|
||||||
### Keyword tokenizer — stemming, full haystack, n=500
|
|
||||||
|
|
||||||
The keyword stage lowercases and splits on non-alphanumerics, with no stemming,
|
|
||||||
so "training" and "trains" are unrelated terms. `TokenFilter::Stemmed` strips
|
|
||||||
common English inflections (plurals, `-ing`/`-ed`, with consonant un-doubling)
|
|
||||||
from documents and queries alike. Turn-level:
|
|
||||||
|
|
||||||
| Mode | Hit@1 | Hit@5 | Hit@10 | MRR | session Hit@1 |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| BM25 only | **53.8%** | 75.0% | 81.6% | 0.6320 | 86.2% |
|
|
||||||
| BM25 only, stemmed | 52.0% | 77.8% | 84.0% | 0.6320 | 88.0% |
|
|
||||||
| Hybrid 0.4/0.6 | 51.6% | **81.4%** | 87.8% | **0.6430** | 91.0% |
|
|
||||||
| Hybrid 0.4/0.6, stemmed | 50.2% | **81.4%** | **88.2%** | 0.6394 | **91.4%** |
|
|
||||||
|
|
||||||
**Stemming is a trade, not a win, and the default stays off.** It reliably buys
|
|
||||||
depth and costs the top rank: on BM25 alone, +2.8pp Hit@5 and +2.4pp Hit@10 for
|
|
||||||
−1.8pp Hit@1, with MRR unchanged to four decimal places — the gains deeper down
|
|
||||||
exactly offset the loss at rank 1. That is what conflation does: merging
|
|
||||||
"train"/"training"/"trains" surfaces documents an exact-match query would never
|
|
||||||
reach, and also lets a near-miss outrank the exact hit.
|
|
||||||
|
|
||||||
On the configuration that actually ships (hybrid 0.4/0.6) the trade is
|
|
||||||
narrower still — Hit@5 identical, Hit@10 +0.4pp, Hit@1 −1.4pp, MRR −0.004 —
|
|
||||||
because the vector stage already supplies much of the recall stemming would
|
|
||||||
add. There is no case here for changing the default; `TokenFilter::Stemmed`
|
|
||||||
is available via `HDF5Memory::set_token_filter` for callers who want Hit@5/@10
|
|
||||||
over rank-1 precision.
|
|
||||||
|
|
||||||
### Re-ranking and recency — full haystack, n=500
|
|
||||||
|
|
||||||
`reranker::rerank` combines temporal decay, source authority and Hebbian
|
|
||||||
activation. Until now its combined score contained **no relevance term at
|
|
||||||
all** — `RerankInput` did not carry the retrieval score — so a caller that
|
|
||||||
re-ranked its candidates threw the retriever's ordering away and returned them
|
|
||||||
ordered by age. The OpenClaw backend did exactly that on every search.
|
|
||||||
|
|
||||||
Measuring that is unambiguous. "Recency" below is the share of
|
|
||||||
`knowledge-update` questions where the newest gold session outranked the stale
|
|
||||||
one (see `newest_gold_first`); ~45% is chance.
|
|
||||||
|
|
||||||
| Mode | Hit@1 | Hit@5 | Hit@10 | MRR | recency |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| Hybrid 0.4/0.6, no re-rank | 51.6% | **81.4%** | 87.8% | 0.6430 | 45.0% |
|
|
||||||
| + re-rank, **metadata only** (pre-fix) | 11.0% | 24.8% | 43.8% | 0.1829 | **87.5%** |
|
|
||||||
| + re-rank, relevance-led, half-life 1 day | **52.0%** | 79.8% | 87.8% | 0.6403 | 51.7% |
|
|
||||||
| + re-rank, relevance-led, half-life 7 days | 51.8% | 80.8% | 87.6% | **0.6437** | **52.2%** |
|
|
||||||
| + re-rank, relevance-led, half-life 30 days | 51.8% | 81.0% | 87.8% | 0.6427 | 51.4% |
|
|
||||||
| + re-rank, relevance-led, half-life 90 days | **52.0%** | 80.4% | 87.8% | 0.6425 | 50.8% |
|
|
||||||
|
|
||||||
**The pre-fix row is the finding.** Ordering candidates by recency alone costs
|
|
||||||
40.6pp of Hit@1 and two thirds of MRR: the results are the newest memories in
|
|
||||||
the pool rather than the ones that answer the question. It does ace the recency
|
|
||||||
metric, which is exactly what makes that metric worth having — a number that
|
|
||||||
only goes up when a change is good would not have caught this.
|
|
||||||
|
|
||||||
With relevance leading, retrieval is preserved (Hit@1 +0.4pp, MRR −0.003
|
|
||||||
against no re-ranking) and recency discrimination gains 6–7pp. That is a real
|
|
||||||
improvement but not a solved problem: recency only breaks near-ties, so it
|
|
||||||
cannot reach the 87.5% the degenerate ordering gets. Those two rows are the
|
|
||||||
ends of a trade-off, and the default sits deliberately near the relevance end.
|
|
||||||
|
|
||||||
**Half-life is not a sensitive knob.** Across 1, 7, 30 and 90 days recency
|
|
||||||
moves 1.4pp and MRR 0.003 — inside the noise of a 500-question run — because
|
|
||||||
the temporal term is capped by its weight (0.3) while relevance differences
|
|
||||||
between candidates are larger. The 24-hour default is kept; there is no
|
|
||||||
measured reason to change it, and a corpus-matched value is not the lever it
|
|
||||||
looks like.
|
|
||||||
|
|
||||||
### Weight sweep — full haystack, n=500
|
### Weight sweep — full haystack, n=500
|
||||||
|
|
||||||
`0.7/0.3` was a documented default, never a searched one. Sweeping
|
`0.7/0.3` was a documented default, never a searched one. Sweeping
|
||||||
@@ -810,8 +318,7 @@ 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
|
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",
|
mode ablation measured — read them as "the shape of each stage in isolation",
|
||||||
and take the operating point from the sweep. `0.4/0.6` is now the shipped
|
and take the operating point from the sweep.
|
||||||
default (`hybrid::DEFAULT_FUSION`).
|
|
||||||
|
|
||||||
The same pattern shows up independently in omni-cortex's four-signal RRF ablation,
|
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.
|
where adding BM25 to a dense retriever raised nDCG@5 while lowering Hit@1 and MRR.
|
||||||
|
|||||||
+1
-432
@@ -1,427 +1,6 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v2.6.0 (2026-09-20)
|
## Unreleased
|
||||||
|
|
||||||
### Upgrade Notes
|
|
||||||
- **Re-ranked results change, substantially for the better.** `RerankInput`
|
|
||||||
and `ReRankConfig` gained fields (`relevance`, `relevance_weight`), so
|
|
||||||
literal constructions need updating; `..Default::default()` does not. Any
|
|
||||||
caller that re-ranked was previously getting results ordered by age with the
|
|
||||||
retrieval score discarded — see below.
|
|
||||||
- **Breaking:** `MemoryCache::embeddings` is a `cache::Embeddings` rather than
|
|
||||||
a `Vec<Vec<f32>>` (indexing still yields a `&[f32]` row); `embeddings_flat`
|
|
||||||
is gone, replaced by `flat_embeddings()`; `rebuild_flat()` is a deprecated
|
|
||||||
no-op.
|
|
||||||
- `MemoryConfig` gained `quantized_index` (default `false`, so behaviour is
|
|
||||||
unchanged unless you opt in); literal constructions need the field.
|
|
||||||
|
|
||||||
### Retrieval quality
|
|
||||||
- `clawhdf5-agent`: **re-ranking discarded the retrieval score.**
|
|
||||||
`reranker::rerank` built its combined score from temporal decay, source
|
|
||||||
authority and Hebbian activation only — `RerankInput` had no relevance field
|
|
||||||
— so re-ranking a candidate pool reordered it by age and threw the
|
|
||||||
retriever's ordering away. The OpenClaw backend re-ranked every search, so
|
|
||||||
this was its shipping behaviour: measured over the full LongMemEval haystack
|
|
||||||
it cost **40.6pp of Hit@1** (11.0% vs 51.6%) and two thirds of MRR (0.183 vs
|
|
||||||
0.643). `RerankInput::relevance` and `ReRankConfig::relevance_weight` (1.0 by
|
|
||||||
default) fix it: relevance leads and the metadata signals break near-ties,
|
|
||||||
which restores retrieval (Hit@1 +0.4pp vs no re-ranking) and improves
|
|
||||||
recency discrimination by 6–7pp. **Breaking:** `RerankInput` and
|
|
||||||
`ReRankConfig` gained fields, so literal constructions need updating;
|
|
||||||
`..Default::default()` does not.
|
|
||||||
- `clawhdf5-bench`: the LongMemEval harness feeds the dataset's real session
|
|
||||||
dates to the store instead of a synthetic counter (decay needs true
|
|
||||||
intervals, not just the right order), and reports `newest_gold_first` — on a
|
|
||||||
`knowledge-update` question, did the newest gold session outrank the stale
|
|
||||||
one it supersedes? Plain recall cannot see this, because both are labelled
|
|
||||||
gold. New `--rerank-sweep`.
|
|
||||||
|
|
||||||
### Memory
|
|
||||||
- `clawhdf5-agent`: **`MemoryConfig::quantized_index`** stores the vector
|
|
||||||
index's own copy of the embeddings as `i8` rather than `f32`, which at 100k
|
|
||||||
384-dim entries takes the index from 266 to 123 MiB and the whole reopened
|
|
||||||
store from 399 to 256 MiB (2.72x -> **1.74x** the raw vectors). Quantised
|
|
||||||
distances are approximate and `ef` cannot compensate — recall@10 tops out at
|
|
||||||
0.967 against f32's 0.9995 — so the query path re-scores the candidate pool
|
|
||||||
against the exact embeddings the store already holds, which restores recall
|
|
||||||
(0.9940 vs 0.9945 at ef=64) for about 13% of QPS. **Off by default**: it
|
|
||||||
trades query speed for memory, and which side is worth more depends on the
|
|
||||||
deployment. The setting is persisted, so a reopened store does not silently
|
|
||||||
revert to four times the index memory.
|
|
||||||
- `clawhdf5-ann`: `Storage::Int8` and the `build_with` / `new_with` /
|
|
||||||
`from_graph_bytes_with` constructors that select it. The scale is per row,
|
|
||||||
not global — a fixed `[-1, 1]` scale spends fewer than 12 of the 255 levels
|
|
||||||
on a unit-length 128-dim vector and is unusable (0.35 top-10 overlap against
|
|
||||||
an exact ranking, versus 0.99 per row). `compact()` keeps the storage it was
|
|
||||||
given; serialized indexes still carry f32 vectors, so a quantised index is
|
|
||||||
rebuilt rather than loaded.
|
|
||||||
- `clawhdf5-agent`: **a loaded store holds ~30% less memory** (100k 384-dim
|
|
||||||
entries: 505 -> 357 MiB, 3.44x -> 2.43x the raw vectors). The cache kept
|
|
||||||
every embedding twice — a `Vec<Vec<f32>>` and a flattened copy for the
|
|
||||||
batched kernels, maintained in lock-step — so it now stores only the flat
|
|
||||||
buffer and indexes into it. Recall and query latency are unchanged.
|
|
||||||
**Breaking:** `MemoryCache::embeddings` is a `cache::Embeddings` rather than
|
|
||||||
a `Vec<Vec<f32>>` (indexing still yields a `&[f32]` row); `embeddings_flat`
|
|
||||||
is gone, replaced by `flat_embeddings()`; `rebuild_flat()` is a deprecated
|
|
||||||
no-op. Rows are now always exactly `dim` long — shorter ones are
|
|
||||||
zero-padded — which makes the ragged-row case that used to silently
|
|
||||||
misalign the flattened copy unrepresentable.
|
|
||||||
- `clawhdf5-bench`: `search_harness --footprint` reports live heap use per
|
|
||||||
stage, measured with a counting allocator (RSS cannot see a structure freed
|
|
||||||
into the allocator's own pool).
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- The Python interop suites honour **`CLAWHDF5_PYTHON`**, and `ci-test.sh`
|
|
||||||
picks up a `.venv/bin/python` automatically. On a PEP 668 "externally
|
|
||||||
managed" system h5py cannot be installed into the system interpreter at all,
|
|
||||||
so every interop suite — the h5py writer round-trips, the facade, netCDF4
|
|
||||||
and the reference files — was skipping silently. A silent skip here is
|
|
||||||
exactly how the v5 compound-datatype bug reached a release.
|
|
||||||
`CLAWHDF5_REQUIRE_INTEROP=1` still turns a skip into a failure.
|
|
||||||
|
|
||||||
## v2.5.0 (2026-09-19)
|
|
||||||
|
|
||||||
### Upgrade Notes
|
|
||||||
- **Retrieval rankings change, for the better.** The default fusion weights
|
|
||||||
move from `0.7/0.3` to `0.4/0.6` (`hybrid::DEFAULT_FUSION`), measured over the
|
|
||||||
full LongMemEval haystack: turn-level Hit@1 51.6% vs 44.2%, MRR 0.643 vs
|
|
||||||
0.586. `unified_search` and the OpenClaw backend pick this up automatically;
|
|
||||||
callers passing weights to `hybrid_search` explicitly are unaffected.
|
|
||||||
- **Out-of-range selections are now errors.** `read_*_selection` used to return
|
|
||||||
data for a selection that ran past a dataset edge — a hyperslab came back
|
|
||||||
zero-padded, and a point with an out-of-range coordinate wrapped into the
|
|
||||||
next row. Both are now `FormatError::SelectionOutOfBounds`. Code relying on
|
|
||||||
the old (wrong) values will start seeing errors.
|
|
||||||
- **Large compressed datasets written without explicit chunk dimensions get a
|
|
||||||
different layout.** They used to be stored as one chunk; they are now split
|
|
||||||
to ~1 MiB chunks. The files stay standard and h5py-readable, and explicit
|
|
||||||
`with_chunks` is unaffected.
|
|
||||||
- `rayon` is now a default dependency of `clawhdf5-agent` (the parallel index
|
|
||||||
build). Opt out with `--no-default-features --features float16,hnsw`.
|
|
||||||
- `clawhdf5-ann` search results no longer shrink when records near the query
|
|
||||||
have been deleted, so a search that previously returned fewer than `k`
|
|
||||||
results now returns `k`.
|
|
||||||
|
|
||||||
### Retrieval quality
|
|
||||||
- `clawhdf5-agent`: optional keyword stemming — `bm25::TokenFilter::Stemmed`
|
|
||||||
and `HDF5Memory::set_token_filter`, so "training" and "trains" match. **Off
|
|
||||||
by default**, on measurement rather than principle: over the full LongMemEval
|
|
||||||
haystack it buys depth and costs the top rank (BM25 alone: Hit@5 +2.8pp,
|
|
||||||
Hit@10 +2.4pp, Hit@1 −1.8pp, MRR unchanged), and on the shipping hybrid
|
|
||||||
configuration the trade is narrower still. See `BENCHMARKS.md`.
|
|
||||||
- `clawhdf5-agent`: **`QueryExpander::expand` panicked on ordinary non-ASCII
|
|
||||||
input** — `"İ AI"` was enough. It searched a lowercased copy of the query and
|
|
||||||
then sliced the *original* with those offsets, which only works while
|
|
||||||
lowercasing preserves byte length (Turkish `İ` is 2 bytes and lowercases to
|
|
||||||
3). Depending on where the offsets drifted it either corrupted the output
|
|
||||||
("İstanbul AI trip" lost a character) or panicked. Matching now walks the
|
|
||||||
original string.
|
|
||||||
- `clawhdf5-agent`: query expansion no longer rewrites text inside words.
|
|
||||||
`replace_word_case_insensitive` did a plain substring replace despite its
|
|
||||||
name, so "training" became "trArtificial Intelligencening" and "programming"
|
|
||||||
became "Pull Requestogramming" — every acronym expansion of ordinary prose
|
|
||||||
was corrupt. Matches now require word boundaries; genuine acronyms
|
|
||||||
(`API`, `database`) still expand.
|
|
||||||
- `clawhdf5-agent`: **the default fusion weights are now the measured ones.**
|
|
||||||
A sweep of every 0.1 step over the full LongMemEval haystack (500 questions,
|
|
||||||
real MiniLM embeddings) shows the long-standing `0.7/0.3` default is
|
|
||||||
*strictly dominated* by `0.4/0.6` — turn-level Hit@1 51.6% vs 44.2%, Hit@5
|
|
||||||
81.4% vs 79.2%, Hit@10 87.8% vs 85.8%, MRR 0.643 vs 0.586, and better at
|
|
||||||
session level too. The finding was recorded in `BENCHMARKS.md` but had never
|
|
||||||
been applied: `unified_search` and the OpenClaw backend both hardcoded
|
|
||||||
`0.7/0.3`. They now use `hybrid::DEFAULT_FUSION`. **Callers passing weights
|
|
||||||
to `hybrid_search` explicitly are unaffected** — pass `0.4`/`0.6` (or use
|
|
||||||
`hybrid_search_with`) to get the tuned behaviour.
|
|
||||||
- `clawhdf5-agent`: fusion is now selectable. New `hybrid::Fusion`
|
|
||||||
(`Weighted { vector, keyword }` or `Rrf { k }`), `hybrid::fuse`,
|
|
||||||
`hybrid::hybrid_search_fused` and `HDF5Memory::hybrid_search_with`.
|
|
||||||
Reciprocal rank fusion existed but was unreachable from the store, so it had
|
|
||||||
never been measured against the weighted sum; the LongMemEval bench now has
|
|
||||||
an `RRF` mode.
|
|
||||||
|
|
||||||
### HDF5 Read Path
|
|
||||||
- **Selection reads cost what the selection costs.** `read_*_selection` decoded
|
|
||||||
the *entire* dataset and then picked elements out, so a 64 x 64 window of a
|
|
||||||
64 MB compressed dataset took 105 ms - about as long as reading all of it.
|
|
||||||
Now only the rows (contiguous) or chunks that overlap the selection's
|
|
||||||
bounding box are read and decompressed: that window takes 0.39 ms, one row
|
|
||||||
2.7 ms, one column 5.2 ms. Results are identical to the full-read path
|
|
||||||
(equivalence-tested over random hyperslabs and point lists, ranks 1-3,
|
|
||||||
contiguous / chunked / deflate). New `read_harness` bench binary.
|
|
||||||
- **Faster full reads** (same-moment A/B, 64 MB `f64`): chunked + deflate
|
|
||||||
110 -> 69 ms, chunked 72 -> 60 ms, contiguous 56 -> 30 ms. The facade's
|
|
||||||
cached read path now decompresses cache misses in parallel batches (it was
|
|
||||||
sequential; only the uncached reader was parallel) and caches only datasets
|
|
||||||
that fit the chunk cache; unfiltered chunks are copied straight from the file
|
|
||||||
bytes; a contiguous dataset is converted straight from the file bytes; and
|
|
||||||
the native-endian conversions no longer zero a buffer before overwriting it.
|
|
||||||
- **Datasets indexed by a version-2 B-tree now read** (layout v4, chunk index
|
|
||||||
type 5 — what `libver='latest'` uses for two or more unlimited dimensions;
|
|
||||||
previously "unsupported chunked layout"). The four copies of the chunk-index
|
|
||||||
dispatch are now one shared function, so every read path gets it.
|
|
||||||
- **`H5T_STD_REF` references** (HDF5 1.12+, datatype message version 4) parse:
|
|
||||||
`ReferenceType` gains `Object2`, `DatasetRegion2` and `Attribute`, and
|
|
||||||
`read_object_references` decodes the new object references. Previously any
|
|
||||||
dataset of this type failed with `InvalidReferenceType(2)`. Tested against a
|
|
||||||
file written by HDF5 2.0 itself (fixture + generator script committed).
|
|
||||||
- **Automatic chunk sizes.** Asking for compression (or any filter) without
|
|
||||||
`with_chunks` used to store the whole dataset as one chunk, so any read had
|
|
||||||
to decompress everything and nothing could be decoded in parallel. Datasets up
|
|
||||||
to 1 MiB stay a single chunk, as before; larger ones are split by halving the
|
|
||||||
dimensions in turn until a chunk is at most 1 MiB (the approach h5py takes).
|
|
||||||
**Behaviour change:** large compressed datasets written without explicit
|
|
||||||
chunk dimensions get a different (standard, h5py-readable) layout. Explicit
|
|
||||||
`with_chunks` is unaffected.
|
|
||||||
- **Out-of-range selections are errors.** They used to return data: a hyperslab
|
|
||||||
past an edge came back padded with zeros, and a point whose column was out of
|
|
||||||
range wrapped into the next row and returned that element. Now
|
|
||||||
`FormatError::SelectionOutOfBounds` (also for a rank mismatch or overlapping
|
|
||||||
blocks).
|
|
||||||
|
|
||||||
### Search
|
|
||||||
- `clawhdf5-ann`: **faster index builds.** Back-link pruning is 90% of a
|
|
||||||
build's distance evaluations; the bulk build now inserts in batches and
|
|
||||||
prunes each overflowing neighbour list once per batch (10K: 1676 -> 1074 ms).
|
|
||||||
With the `parallel` feature, planning and pruning run on a thread pool (10K:
|
|
||||||
388 ms, 100K: ~21 s -> 5.9 s on 16 cores). The graph is deterministic and
|
|
||||||
identical with or without the feature. `clawhdf5-agent`'s `parallel` feature
|
|
||||||
enables it for the agent's index and is now **on by default** (adds `rayon`
|
|
||||||
to the default dependency set; build with `--no-default-features --features
|
|
||||||
float16,hnsw` to opt out).
|
|
||||||
- `clawhdf5-ann`: `HnswIndex::search` returned fewer than `k` results — often
|
|
||||||
none — when the records nearest the query had been deleted: it collected `ef`
|
|
||||||
candidates, *then* dropped the deleted ones, *then* took `k`. Deleted nodes
|
|
||||||
are now traversed as waypoints but never occupy a result slot, so a search
|
|
||||||
returns the `k` nearest live records. Matters for any store that deletes or
|
|
||||||
supersedes memories without compacting straight away.
|
|
||||||
|
|
||||||
## v2.4.0 (2026-09-19)
|
|
||||||
|
|
||||||
### Upgrade Notes
|
|
||||||
- **Search results improve on upgrade.** The HNSW index now reaches true
|
|
||||||
neighbours it previously could not (recall@10 0.31 -> 0.98 at 100K records on
|
|
||||||
clustered data), so `hybrid_search` rankings change for the better. The agent
|
|
||||||
rebuilds its index from the store automatically; a standalone `HnswIndex`
|
|
||||||
persisted with `to_hdf5_bytes` keeps its old graph until rebuilt.
|
|
||||||
- **`hybrid_search` no longer writes the store.** Hebbian activation boosts are
|
|
||||||
persisted by the next checkpoint (any flushing write, `flush_wal`, or when
|
|
||||||
the `HDF5Memory` is dropped) instead of inside every query; a crash before
|
|
||||||
then forgets only the boosts since the last checkpoint. Activation weights
|
|
||||||
are now capped at 16.
|
|
||||||
- A new sidecar file, `<store>.h5.ann`, holds the vector index graph. It is
|
|
||||||
derived data: safe to delete (the index is rebuilt), copied by `snapshot()`,
|
|
||||||
and worth including when copying a store by hand to avoid a rebuild.
|
|
||||||
- `BM25Index` no longer caches IDF and gained `add_document`,
|
|
||||||
`remove_document`, `pad_to`, `scores`, `len` and `is_empty`; results are now
|
|
||||||
deterministic (ties break by record id).
|
|
||||||
|
|
||||||
### Search
|
|
||||||
- `clawhdf5-ann`: **HNSW recall fix.** Neighbours were chosen as the plain
|
|
||||||
closest-M, which on clustered data (what embeddings look like) turns each
|
|
||||||
cluster into an island: recall@10 was 0.87 / 0.67 / 0.31 at 1K / 10K / 100K
|
|
||||||
vectors and did not improve with `ef`. The index now uses the HNSW paper's
|
|
||||||
diversity heuristic (Algorithm 4 with kept pruned connections) when linking a
|
|
||||||
new node and when pruning back-links: recall@10 at `ef = 64` is 1.00 / 1.00 /
|
|
||||||
0.98 and responds to `ef`. Builds are slower (~3.5x at 10K). Existing
|
|
||||||
persisted indexes keep their old graph until rebuilt; the agent rebuilds its
|
|
||||||
index from the cache, so stores pick this up automatically.
|
|
||||||
- `clawhdf5-agent`: **`hybrid_search` is 23-39x faster in steady state** (p50
|
|
||||||
5.5 -> 0.24 ms at 1K records, 49 -> 2.1 ms at 10K, 884 -> 23 ms at 100K).
|
|
||||||
Every query used to rebuild the BM25 index from scratch and rewrite the whole
|
|
||||||
`.h5` file. The keyword index now lives for the life of the store and is
|
|
||||||
updated incrementally (add / remove / in-place update, exactly equivalent to
|
|
||||||
a fresh build - property-tested), and a query no longer writes the store.
|
|
||||||
**Behaviour change:** Hebbian activation boosts are persisted by the next
|
|
||||||
checkpoint (any flushing write, `flush_wal`, or drop) rather than
|
|
||||||
immediately; a crash in between forgets only the boosts since the last
|
|
||||||
checkpoint. Activation weights are now capped (16.0) - they grew without
|
|
||||||
bound.
|
|
||||||
- `clawhdf5-agent`: **the vector index is persisted**, so `open()` no longer
|
|
||||||
rebuilds it on the first search (first query after open: 2627 -> 15 ms at 10K
|
|
||||||
records, 36 s -> 159 ms at 100K). The HNSW graph — not the vectors, which the
|
|
||||||
store already holds — is written to `<store>.h5.ann` at each checkpoint and
|
|
||||||
tied to it by a generation id in `/meta`; a missing, stale, damaged or
|
|
||||||
structurally invalid sidecar is ignored and the index rebuilt. Records
|
|
||||||
replayed from the WAL join the loaded index incrementally; a replayed update
|
|
||||||
or delete invalidates it. `snapshot()` copies it. Batch saves no longer force
|
|
||||||
a full index rebuild.
|
|
||||||
- `clawhdf5-ann`: faster HNSW build and search with identical recall. The
|
|
||||||
cosine metric stores unit vectors and compares them with a plain dot product
|
|
||||||
(it re-derived both norms on every distance evaluation), and the per-call
|
|
||||||
`HashSet` of visited nodes is a reusable epoch-stamped array. Build 2.75 ->
|
|
||||||
1.89 s at 10K and ~38 -> 21 s at 100K; QPS at `ef = 64` 22.7K -> 39K at 10K.
|
|
||||||
Distances returned by `search` are unchanged (1 - cosine). Indexes loaded
|
|
||||||
from older HDF5 files are normalised on load.
|
|
||||||
- `clawhdf5-accel`: the SIMD backend is detected once per process instead of
|
|
||||||
on every kernel call.
|
|
||||||
- `clawhdf5-ann`: `HnswIndex::graph_to_bytes` / `from_graph_bytes` — graph-only
|
|
||||||
serialization (checksummed, every neighbour id and level validated on load).
|
|
||||||
- `clawhdf5-agent`: a further 4-5x on `hybrid_search` with **identical
|
|
||||||
rankings** (p50 now 0.07 / 0.49 / 4.65 ms at 1K / 10K / 100K — 79x / 100x /
|
|
||||||
190x faster than v2.3.0). Fusion needs every keyword score but not their
|
|
||||||
ranking: new `BM25Index::scores` returns them unsorted from a dense
|
|
||||||
accumulator (it hashed every posting, then sorted every match), and
|
|
||||||
`merge_vector_keyword` selects its top k instead of sorting every candidate.
|
|
||||||
Capping the keyword candidate pool was measured and rejected: it changes the
|
|
||||||
top-10 for most queries (`search_harness --fusion-study`).
|
|
||||||
- `clawhdf5-agent`: BM25 results are deterministic (ties break by record id),
|
|
||||||
top-k uses a bounded heap, and the "WAND early termination" that computed a
|
|
||||||
bound and then ignored it is gone. IDF is computed per query.
|
|
||||||
- `clawhdf5-bench`: new `search_harness` binary — HNSW recall@10 / QPS / latency
|
|
||||||
per `ef` against an exact scan, and end-to-end `hybrid_search` timings, on
|
|
||||||
deterministic clustered (or `--uniform`) data. Baseline in `BENCHMARKS.md`.
|
|
||||||
|
|
||||||
## v2.3.0 (2026-09-19)
|
|
||||||
|
|
||||||
### Upgrade Notes
|
|
||||||
- **A memory store now has a single writer.** `HDF5Memory::create`/`open` take
|
|
||||||
an exclusive lock (`<store>.h5.lock`); a second open of the same store — in
|
|
||||||
the same or another process — returns `MemoryError::Locked`. Code that opened
|
|
||||||
a second handle just to read should use `HDF5Memory::open_read_only`.
|
|
||||||
- **Unsigned array attributes arrive as `AttrValue::U64Array`**, not
|
|
||||||
`I64Array`, and `attrs()` may now return `AttrValue::Raw`. Exhaustive matches
|
|
||||||
on `AttrValue` need the two new arms.
|
|
||||||
- **WAL header version 3 → 4.** v3 files are read and upgraded in place, but a
|
|
||||||
store written by 2.3.0 with a pending WAL cannot be opened by 2.2.0 or
|
|
||||||
earlier (it is refused, not corrupted). Checkpoint first
|
|
||||||
(`flush_wal`) if you need to downgrade.
|
|
||||||
- `MemoryConfig::compression` now uses deflate unless the agent's new `zstd`
|
|
||||||
feature is enabled; it previously failed outright in a default build.
|
|
||||||
- `MemoryError` gained `Locked`; `FormatError` gained `UnresolvedSharedMessage`,
|
|
||||||
`ExternalDataFilesUnsupported` and `ExternalLinkUnsupported`; `MessageType`
|
|
||||||
gained `ExternalDataFiles`.
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- `clawhdf5-format`: compound datatypes written with **default libver bounds**
|
|
||||||
(datatype message version 1 — what plain `h5py.File(path, 'w')` produces)
|
|
||||||
were mis-parsed. The v1 member layout carries 28 bytes of legacy array
|
|
||||||
fields after the byte offset (the parser skipped 24), and v2 pads member
|
|
||||||
names to 8 bytes and has no array fields at all (the parser did neither), so
|
|
||||||
every member after the first byte offset was read from the wrong position —
|
|
||||||
typically surfacing as `Overflow("compound member ...")` on read. Found by
|
|
||||||
adding a default-libver axis to the h5py interop tests; byte-level regression
|
|
||||||
tests for v1 and v2 added.
|
|
||||||
- `clawhdf5-gpu`: `gpu_tests` could hang forever under the default parallel
|
|
||||||
test runner — every test created its own wgpu instance and device at once.
|
|
||||||
Tests now serialise GPU access, and GPU→CPU readback waits are bounded
|
|
||||||
(30 s) so a wedged driver returns `GpuError::BufferMap` instead of blocking.
|
|
||||||
- `clawhdf5-agent`: `benches/bench.rs` and `benches/memory_bench.rs` no longer
|
|
||||||
compiled against the current `strategy`/`consolidation` APIs.
|
|
||||||
|
|
||||||
### HDF5 Compatibility
|
|
||||||
- `clawhdf5-format`/`clawhdf5`: datasets and attributes that use a **committed
|
|
||||||
(named) datatype** now read correctly. They store a shared-message reference;
|
|
||||||
the facade parsed the reference bytes as the datatype (`Time { size: 0 }`,
|
|
||||||
unreadable data) and silently dropped such attributes. The shared-reference
|
|
||||||
parser itself was wrong for real files: version 2 has no reserved bytes, and
|
|
||||||
the version 3 types were inverted (1 = SOHM heap, 2 = committed).
|
|
||||||
- **Fill values are applied on read.** There was no Fill Value message parser:
|
|
||||||
the holes of a sparse chunked dataset read as zeros even when the fill value
|
|
||||||
was not zero (silently wrong data), and a dataset that was created but never
|
|
||||||
written failed with `NoDataAllocated` where h5py returns a filled array.
|
|
||||||
Messages 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
|
### Security
|
||||||
- `clawhdf5-format`: bounded decompression output (`MAX_DECOMPRESS_SIZE`) for
|
- `clawhdf5-format`: bounded decompression output (`MAX_DECOMPRESS_SIZE`) for
|
||||||
@@ -666,16 +245,6 @@
|
|||||||
reading compound types and — critically — every chunked/compressed dataset
|
reading compound types and — critically — every chunked/compressed dataset
|
||||||
written by HDF5 2.0. Found by running the h5py interop tests against
|
written by HDF5 2.0. Found by running the h5py interop tests against
|
||||||
h5py 3.16 / HDF5 2.0.
|
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
|
### Performance
|
||||||
- `clawhdf5-format`: chunked writes now compress all chunks up front via
|
- `clawhdf5-format`: chunked writes now compress all chunks up front via
|
||||||
|
|||||||
@@ -33,66 +33,7 @@ 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 approximate `clawhdf5-ann` index for the vector stage (the index mirrors
|
||||||
the cache and self-heals on drift). Build the agent with
|
the cache and self-heals on drift). Build the agent with
|
||||||
`--no-default-features --features float16` to force the exact linear cosine scan.
|
`--no-default-features --features float16` to force the exact linear cosine scan.
|
||||||
The agent's `parallel` feature (also default) builds the index on a thread
|
- WAL (write-ahead log) for crash-safe persistence, with a CRC32 trailer per entry so a corrupted entry stops replay cleanly instead of loading bad data
|
||||||
pool; the graph is identical with or without it.
|
|
||||||
The index uses the HNSW paper's diversity heuristic for neighbour selection
|
|
||||||
(plain closest-M capped recall on clustered data: 0.31 recall@10 at 100K). Its
|
|
||||||
graph is saved to `<store>.h5.ann` at each checkpoint and reloaded by `open()`
|
|
||||||
(tied to the checkpoint by a generation id; stale/damaged sidecars are
|
|
||||||
ignored and the index rebuilt). `MemoryConfig::quantized_index` (off by
|
|
||||||
default, persisted) stores the index's own copy of the embeddings as `i8`,
|
|
||||||
which roughly halves a loaded store's memory (2.72x -> 1.74x the raw vectors
|
|
||||||
at 100K); because quantised distances are approximate and `ef` cannot
|
|
||||||
compensate, the query path then re-scores the candidate pool against the
|
|
||||||
exact embeddings, which holds recall at the f32 index's level and costs
|
|
||||||
~13% of QPS. `hybrid_search` keeps one incremental BM25
|
|
||||||
index for the life of the store and never writes the store: Hebbian
|
|
||||||
activation boosts are persisted by the next checkpoint (or on drop), not per
|
|
||||||
query. Measure any search-path change with
|
|
||||||
`cargo run --release -p clawhdf5-bench --bin search_harness` (baselines in
|
|
||||||
`BENCHMARKS.md`).
|
|
||||||
- WAL (write-ahead log) for crash-safe persistence, with a chained CRC32
|
|
||||||
trailer per entry (each entry's CRC folds in the previous entry's CRC) so a
|
|
||||||
corrupted, reordered, duplicated, or spliced entry stops replay cleanly
|
|
||||||
instead of loading bad or tampered data. The pre-chaining per-entry-CRC
|
|
||||||
format (v2) is still fully readable; the oldest no-CRC format (v1) is only
|
|
||||||
reachable through the one-time migration path in `HDF5Memory::open`, not
|
|
||||||
through the public `WalFile::read_entries`.
|
|
||||||
**What the WAL guarantees:** integrity, ordering, and recovery from a
|
|
||||||
*process* crash at any point — including between a checkpoint and the WAL
|
|
||||||
truncate (each checkpoint records a `WalMark` in `/meta`, and `open()` skips
|
|
||||||
the WAL prefix the `.h5` already contains, so entries are never applied
|
|
||||||
twice). Checkpoints and snapshots are made durable as a unit (temp file
|
|
||||||
synced, renamed, directory synced). **What it does not guarantee:**
|
|
||||||
individual WAL appends are *not* fsynced (a deliberate latency trade-off), so
|
|
||||||
saves made since the last checkpoint can be lost on power failure or kernel
|
|
||||||
panic. Current header version is 4 (adds the `Update` record used by
|
|
||||||
`save_or_update`); v3 files are read and upgraded in place.
|
|
||||||
- A store has a **single writer**: `HDF5Memory::create`/`open` hold an exclusive
|
|
||||||
advisory lock on `<store>.h5.lock` and a second opener gets
|
|
||||||
`MemoryError::Locked`. Use `HDF5Memory::open_read_only` for a lock-free,
|
|
||||||
never-writing point-in-time view (the CLI's `recall`/`stats`/`agents-md`/
|
|
||||||
`export` do). An unreadable WAL (torn header, bad magic) is quarantined to
|
|
||||||
`<store>.h5.wal.corrupt-<ts>` rather than blocking `open()`; a WAL with an
|
|
||||||
unknown *newer* version still fails and is left untouched.
|
|
||||||
- `MemoryConfig::compression` uses deflate by default; enable the agent's
|
|
||||||
`zstd` feature to compress embeddings with Zstd instead (links libzstd).
|
|
||||||
- `Dataset::verify_provenance()` (clawhdf5 facade, `provenance` feature, on by
|
|
||||||
default) recomputes a dataset's SHA-256 and compares it against the
|
|
||||||
`_provenance_sha256` attribute written automatically on save when
|
|
||||||
`DatasetBuilder::with_provenance` is used. It's opt-in per call, not run
|
|
||||||
automatically on open — it decodes and hashes the whole dataset. The hash
|
|
||||||
is unkeyed (tamper-*evident*, not tamper-*proof*): it detects accidental
|
|
||||||
corruption, not a deliberate actor able to modify both the data and the
|
|
||||||
stored hash.
|
|
||||||
- `clawhdf5-agent`'s `HDF5Memory::save`/`save_batch`/`save_or_update` run every
|
|
||||||
write through an in-memory (session-scoped, not persisted to disk)
|
|
||||||
provenance ledger and write-anomaly detector: a content hash per record
|
|
||||||
(`provenance.rs`) for detecting accidental mid-session corruption, plus
|
|
||||||
rate-limit/injection-pattern/source-distribution checks (`anomaly.rs`).
|
|
||||||
Alerts never block a save — drain them with `HDF5Memory::take_anomaly_alerts`.
|
|
||||||
`MemorySource` for this bookkeeping is inferred from the caller-supplied
|
|
||||||
`source_channel` string (a heuristic, not an authenticated trust boundary).
|
|
||||||
- GPU-accelerated batch I/O for large dataset processing
|
- GPU-accelerated batch I/O for large dataset processing
|
||||||
- Python and Node.js bindings for cross-language use
|
- Python and Node.js bindings for cross-language use
|
||||||
- NetCDF-4 compatibility for scientific data interop
|
- NetCDF-4 compatibility for scientific data interop
|
||||||
|
|||||||
+2
-2
@@ -21,10 +21,10 @@ members = [
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "2.6.0"
|
version = "2.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -432,13 +432,6 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
|
|||||||
| `agent` | no | Full agent memory layer |
|
| `agent` | no | Full agent memory layer |
|
||||||
| `float16` | **yes** | Half-precision embedding storage (2× compression) |
|
| `float16` | **yes** | Half-precision embedding storage (2× compression) |
|
||||||
| `hnsw` | **yes** | HNSW approximate vector index for `hybrid_search` (via `clawhdf5-ann`); disable for an exact linear scan |
|
| `hnsw` | **yes** | HNSW approximate vector index for `hybrid_search` (via `clawhdf5-ann`); disable for an exact linear scan |
|
||||||
|
|
||||||
`MemoryConfig::quantized_index` (off by default) stores the HNSW index's own
|
|
||||||
copy of the embeddings as `i8`, roughly halving a loaded store's memory
|
|
||||||
(2.72x -> 1.74x the raw vectors at 100k x 384). Quantised distances are
|
|
||||||
approximate, so the query path re-scores the candidate pool against the exact
|
|
||||||
embeddings the store already holds — recall matches the `f32` index, at about
|
|
||||||
13% fewer queries per second. See `BENCHMARKS.md`, "Quantising the index copy".
|
|
||||||
| `parallel` | no | Rayon parallel search |
|
| `parallel` | no | Rayon parallel search |
|
||||||
| `fast-math` | no | BLAS matrix-vector multiply |
|
| `fast-math` | no | BLAS matrix-vector multiply |
|
||||||
| `accelerate` | no | Apple Accelerate / AMX (macOS) |
|
| `accelerate` | no | Apple Accelerate / AMX (macOS) |
|
||||||
@@ -499,14 +492,6 @@ cargo build -p clawhdf5-agent --features "agent,float16,accelerate,parallel,gpu"
|
|||||||
# Tests
|
# Tests
|
||||||
cargo test --workspace # all 1,650+ tests
|
cargo test --workspace # all 1,650+ tests
|
||||||
cargo test -p clawhdf5-agent # agent memory tests
|
cargo test -p clawhdf5-agent # agent memory tests
|
||||||
scripts/ci-test.sh # what CI runs: fmt, clippy matrix, tests,
|
|
||||||
# h5py/netCDF4 interop, no_std
|
|
||||||
|
|
||||||
# The interop suites need a Python with h5py; on a PEP 668 system that has to
|
|
||||||
# be a virtualenv. `ci-test.sh` finds `.venv` on its own, or set
|
|
||||||
# CLAWHDF5_PYTHON. Without one they skip — set CLAWHDF5_REQUIRE_INTEROP=1 to
|
|
||||||
# make that a failure instead.
|
|
||||||
python3 -m venv .venv && .venv/bin/pip install h5py numpy netCDF4 xarray
|
|
||||||
|
|
||||||
# Benchmarks
|
# Benchmarks
|
||||||
cargo bench -p clawhdf5-agent # agent memory suite
|
cargo bench -p clawhdf5-agent # agent memory suite
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-accel"
|
name = "clawhdf5-accel"
|
||||||
version = "2.6.0"
|
version = "2.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "SIMD-accelerated operations for rustyhdf5"
|
description = "SIMD-accelerated operations for rustyhdf5"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["hdf5", "simd", "acceleration", "performance"]
|
keywords = ["hdf5", "simd", "acceleration", "performance"]
|
||||||
categories = ["science", "algorithms"]
|
categories = ["science", "algorithms"]
|
||||||
|
|||||||
@@ -111,11 +111,7 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let denom = (norm_a * norm_b).sqrt();
|
let denom = (norm_a * norm_b).sqrt();
|
||||||
if denom < f32::EPSILON {
|
if denom == 0.0 { 0.0 } else { dot / denom }
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
dot / denom
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,11 +89,7 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let denom = (norm_a * norm_b).sqrt();
|
let denom = (norm_a * norm_b).sqrt();
|
||||||
if denom < f32::EPSILON {
|
if denom == 0.0 { 0.0 } else { dot / denom }
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
dot / denom
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,14 +61,8 @@ pub enum Backend {
|
|||||||
Scalar,
|
Scalar,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The best available SIMD backend, detected once per process. Every kernel
|
/// Detect the best available SIMD backend at runtime.
|
||||||
/// dispatches through this, so it sits in the innermost loop of every search.
|
|
||||||
pub fn detect_backend() -> Backend {
|
pub fn detect_backend() -> Backend {
|
||||||
static BACKEND: std::sync::OnceLock<Backend> = std::sync::OnceLock::new();
|
|
||||||
*BACKEND.get_or_init(detect_backend_uncached)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_backend_uncached() -> Backend {
|
|
||||||
#[cfg(target_arch = "aarch64")]
|
#[cfg(target_arch = "aarch64")]
|
||||||
{
|
{
|
||||||
return Backend::Neon; // Always available on aarch64
|
return Backend::Neon; // Always available on aarch64
|
||||||
@@ -367,18 +361,6 @@ mod tests {
|
|||||||
assert!(approx_eq(cosine_similarity(&a, &b), 0.0, EPSILON));
|
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]
|
#[test]
|
||||||
fn test_cosine_scalar_vs_dispatch() {
|
fn test_cosine_scalar_vs_dispatch() {
|
||||||
let a: Vec<f32> = (0..384).map(|i| (i as f32).sin()).collect();
|
let a: Vec<f32> = (0..384).map(|i| (i as f32).sin()).collect();
|
||||||
|
|||||||
@@ -94,11 +94,7 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let denom = (norm_a * norm_b).sqrt();
|
let denom = (norm_a * norm_b).sqrt();
|
||||||
if denom < f32::EPSILON {
|
if denom == 0.0 { 0.0 } else { dot / denom }
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
dot / denom
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NEON L2 distance.
|
/// NEON L2 distance.
|
||||||
|
|||||||
@@ -21,11 +21,7 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
|||||||
norm_b += y * y;
|
norm_b += y * y;
|
||||||
}
|
}
|
||||||
let denom = (norm_a * norm_b).sqrt();
|
let denom = (norm_a * norm_b).sqrt();
|
||||||
if denom < f32::EPSILON {
|
if denom == 0.0 { 0.0 } else { dot / denom }
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
dot / denom
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn batch_cosine(query: &[f32], vectors: &[&[f32]], results: &mut [(usize, f32)]) {
|
pub fn batch_cosine(query: &[f32], vectors: &[&[f32]], results: &mut [(usize, f32)]) {
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-agent"
|
name = "clawhdf5-agent"
|
||||||
version = "2.6.0"
|
version = "2.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "HDF5-backed persistent memory store for on-device AI agents"
|
description = "HDF5-backed persistent memory store for on-device AI agents"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["agent", "memory", "hdf5", "vector-search", "embedding"]
|
keywords = ["agent", "memory", "hdf5", "vector-search", "embedding"]
|
||||||
categories = ["database", "science", "algorithms"]
|
categories = ["database", "science", "algorithms"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0", features = ["parallel", "fast-checksum"] }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0", features = ["parallel", "fast-checksum"] }
|
||||||
clawhdf5 = { path = "../clawhdf5", version = "2.6.0" }
|
clawhdf5 = { path = "../clawhdf5", version = "2.1.0" }
|
||||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.6.0", features = ["mmap"] }
|
clawhdf5-io = { path = "../clawhdf5-io", version = "2.1.0", features = ["mmap"] }
|
||||||
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.6.0" }
|
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.1.0" }
|
||||||
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.6.0", optional = true }
|
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.1.0", optional = true }
|
||||||
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.6.0", optional = true, default-features = false }
|
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.1.0", optional = true, default-features = false }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
byteorder = "1"
|
byteorder = "1"
|
||||||
half = { workspace = true, optional = true }
|
half = { workspace = true, optional = true }
|
||||||
@@ -45,14 +45,9 @@ name = "memory_bench"
|
|||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["float16", "hnsw", "parallel"]
|
default = ["float16", "hnsw"]
|
||||||
float16 = ["half"]
|
float16 = ["half"]
|
||||||
# Rayon-parallel brute-force search strategies, and a parallel bulk build of
|
parallel = ["rayon"]
|
||||||
# the HNSW index (same graph, several times faster on a multi-core machine).
|
|
||||||
parallel = ["rayon", "clawhdf5-ann?/parallel"]
|
|
||||||
# Compress embeddings with Zstd instead of deflate when
|
|
||||||
# `MemoryConfig::compression` is on. Off by default: it links libzstd (C).
|
|
||||||
zstd = ["clawhdf5/zstd"]
|
|
||||||
# HNSW approximate-nearest-neighbour acceleration for the vector stage of
|
# HNSW approximate-nearest-neighbour acceleration for the vector stage of
|
||||||
# hybrid_search. On by default; the index is rebuilt from the cache on demand
|
# 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
|
# and stays self-consistent with the persisted memory store. Disable with
|
||||||
|
|||||||
@@ -483,7 +483,7 @@ fn rayon_benches(c: &mut Criterion) {
|
|||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
let query_norm = vector_search::compute_norm(&query);
|
let query_norm = vector_search::compute_norm(&query);
|
||||||
let num_cores = rayon::current_num_threads().max(1);
|
let num_cores = rayon::current_num_threads().max(1);
|
||||||
let chunk_size = n.div_ceil(num_cores);
|
let chunk_size = (n + num_cores - 1) / num_cores;
|
||||||
let mut results: Vec<(usize, f32)> = vectors
|
let mut results: Vec<(usize, f32)> = vectors
|
||||||
.par_chunks(chunk_size)
|
.par_chunks(chunk_size)
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -537,7 +537,7 @@ fn rayon_benches(c: &mut Criterion) {
|
|||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
let query_norm = vector_search::compute_norm(&query);
|
let query_norm = vector_search::compute_norm(&query);
|
||||||
let num_cores = rayon::current_num_threads().max(1);
|
let num_cores = rayon::current_num_threads().max(1);
|
||||||
let chunk_size = n.div_ceil(num_cores);
|
let chunk_size = (n + num_cores - 1) / num_cores;
|
||||||
let mut results: Vec<(usize, f32)> = vectors
|
let mut results: Vec<(usize, f32)> = vectors
|
||||||
.par_chunks(chunk_size)
|
.par_chunks(chunk_size)
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -766,22 +766,12 @@ fn adaptive_benches(c: &mut Criterion) {
|
|||||||
.map(|v| vector_search::compute_norm(v))
|
.map(|v| vector_search::compute_norm(v))
|
||||||
.collect();
|
.collect();
|
||||||
let tombstones = vec![0u8; n];
|
let tombstones = vec![0u8; n];
|
||||||
let flat: Vec<f32> = vectors.iter().flatten().copied().collect();
|
|
||||||
|
|
||||||
c.bench_function("adaptive_search_10k", |b| {
|
c.bench_function("adaptive_search_10k", |b| {
|
||||||
let hw = HardwareCapabilities::detect();
|
let hw = HardwareCapabilities::detect();
|
||||||
let strat = strategy::auto_select_strategy(n, &hw);
|
let strat = strategy::auto_select_strategy(n, &hw);
|
||||||
b.iter(|| {
|
b.iter(|| {
|
||||||
strategy::search_with_metrics(
|
strategy::search_with_metrics(&query, &vectors, &norms, &tombstones, 10, strat, None)
|
||||||
&query,
|
|
||||||
&vectors,
|
|
||||||
&flat,
|
|
||||||
&norms,
|
|
||||||
&tombstones,
|
|
||||||
10,
|
|
||||||
strat,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -791,7 +781,6 @@ fn adaptive_benches(c: &mut Criterion) {
|
|||||||
strategy::search_with_metrics(
|
strategy::search_with_metrics(
|
||||||
&query,
|
&query,
|
||||||
&vectors,
|
&vectors,
|
||||||
&flat,
|
|
||||||
&norms,
|
&norms,
|
||||||
&tombstones,
|
&tombstones,
|
||||||
10,
|
10,
|
||||||
@@ -806,7 +795,6 @@ fn adaptive_benches(c: &mut Criterion) {
|
|||||||
strategy::search_with_metrics(
|
strategy::search_with_metrics(
|
||||||
&query,
|
&query,
|
||||||
&vectors,
|
&vectors,
|
||||||
&flat,
|
|
||||||
&norms,
|
&norms,
|
||||||
&tombstones,
|
&tombstones,
|
||||||
10,
|
10,
|
||||||
@@ -821,7 +809,6 @@ fn adaptive_benches(c: &mut Criterion) {
|
|||||||
strategy::search_with_metrics(
|
strategy::search_with_metrics(
|
||||||
&query,
|
&query,
|
||||||
&vectors,
|
&vectors,
|
||||||
&flat,
|
|
||||||
&norms,
|
&norms,
|
||||||
&tombstones,
|
&tombstones,
|
||||||
10,
|
10,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use clawhdf5_agent::bm25::BM25Index;
|
use clawhdf5_agent::bm25::BM25Index;
|
||||||
use clawhdf5_agent::consolidation::{
|
use clawhdf5_agent::consolidation::{
|
||||||
ConsolidationConfig, ConsolidationEngine, ImportanceScorer, ImportanceWeights, MemorySource,
|
ConsolidationConfig, ConsolidationEngine, ImportanceScorer, ImportanceWeights, MemorySource,
|
||||||
UntrustedSource,
|
|
||||||
};
|
};
|
||||||
use clawhdf5_agent::hybrid::{hybrid_search, rrf_hybrid_search};
|
use clawhdf5_agent::hybrid::{hybrid_search, rrf_hybrid_search};
|
||||||
use clawhdf5_agent::knowledge::KnowledgeCache;
|
use clawhdf5_agent::knowledge::KnowledgeCache;
|
||||||
@@ -286,12 +285,7 @@ fn consolidation_benches(c: &mut Criterion) {
|
|||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let embedding = make_vec(&mut rng, DIM);
|
let embedding = make_vec(&mut rng, DIM);
|
||||||
let chunk = format!("memory record {i} with some content");
|
let chunk = format!("memory record {i} with some content");
|
||||||
engine.add_memory(
|
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
|
||||||
chunk,
|
|
||||||
embedding,
|
|
||||||
UntrustedSource::User,
|
|
||||||
now + i as f64,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
engine
|
engine
|
||||||
},
|
},
|
||||||
@@ -313,10 +307,9 @@ fn consolidation_benches(c: &mut Criterion) {
|
|||||||
for i in 0..50usize {
|
for i in 0..50usize {
|
||||||
let embedding = make_vec(&mut rng, DIM);
|
let embedding = make_vec(&mut rng, DIM);
|
||||||
let chunk = format!("existing record {i}");
|
let chunk = format!("existing record {i}");
|
||||||
engine.add_memory(chunk, embedding, UntrustedSource::User, now + i as f64);
|
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
|
||||||
}
|
}
|
||||||
let records = engine.records().to_vec();
|
let records = engine.records().to_vec();
|
||||||
let record_refs: Vec<&_> = records.iter().collect();
|
|
||||||
let weights = ImportanceWeights::default();
|
let weights = ImportanceWeights::default();
|
||||||
let query_embedding = make_vec(&mut rng, DIM);
|
let query_embedding = make_vec(&mut rng, DIM);
|
||||||
let sample_text =
|
let sample_text =
|
||||||
@@ -324,7 +317,7 @@ fn consolidation_benches(c: &mut Criterion) {
|
|||||||
|
|
||||||
group.bench_function("bench_importance_scoring", |b| {
|
group.bench_function("bench_importance_scoring", |b| {
|
||||||
b.iter(|| {
|
b.iter(|| {
|
||||||
let surprise = ImportanceScorer::score_surprise(&query_embedding, &record_refs);
|
let surprise = ImportanceScorer::score_surprise(&query_embedding, &records);
|
||||||
let correction = ImportanceScorer::score_correction(&MemorySource::Correction);
|
let correction = ImportanceScorer::score_correction(&MemorySource::Correction);
|
||||||
let length = ImportanceScorer::score_length(sample_text);
|
let length = ImportanceScorer::score_length(sample_text);
|
||||||
ImportanceScorer::score_combined(surprise, correction, length, &weights)
|
ImportanceScorer::score_combined(surprise, correction, length, &weights)
|
||||||
@@ -361,7 +354,7 @@ fn temporal_benches(c: &mut Criterion) {
|
|||||||
// Insert benchmark: measure time to insert 10k timestamps one by one
|
// Insert benchmark: measure time to insert 10k timestamps one by one
|
||||||
group.bench_function("bench_temporal_insert_10k", |b| {
|
group.bench_function("bench_temporal_insert_10k", |b| {
|
||||||
b.iter_batched(
|
b.iter_batched(
|
||||||
TemporalIndex::new,
|
|| TemporalIndex::new(),
|
||||||
|mut idx| {
|
|mut idx| {
|
||||||
for i in 0..N {
|
for i in 0..N {
|
||||||
// Shuffle insertion order slightly using a simple offset pattern
|
// Shuffle insertion order slightly using a simple offset pattern
|
||||||
@@ -449,8 +442,7 @@ fn large_consolidation_benches(c: &mut Criterion) {
|
|||||||
let mut group = c.benchmark_group("consolidation_large");
|
let mut group = c.benchmark_group("consolidation_large");
|
||||||
group.sample_size(10);
|
group.sample_size(10);
|
||||||
|
|
||||||
{
|
for (label, n) in [("10k", 10_000usize)] {
|
||||||
let (label, n) = ("10k", 10_000usize);
|
|
||||||
group.bench_with_input(
|
group.bench_with_input(
|
||||||
BenchmarkId::new("bench_consolidation_cycle", label),
|
BenchmarkId::new("bench_consolidation_cycle", label),
|
||||||
&n,
|
&n,
|
||||||
@@ -467,12 +459,7 @@ fn large_consolidation_benches(c: &mut Criterion) {
|
|||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let embedding = make_vec(&mut rng, DIM);
|
let embedding = make_vec(&mut rng, DIM);
|
||||||
let chunk = format!("memory record {i} with content");
|
let chunk = format!("memory record {i} with content");
|
||||||
engine.add_memory(
|
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
|
||||||
chunk,
|
|
||||||
embedding,
|
|
||||||
UntrustedSource::User,
|
|
||||||
now + i as f64,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
engine
|
engine
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
target/
|
|
||||||
artifacts/
|
|
||||||
coverage/
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
#![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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -118,7 +118,6 @@ mod tests {
|
|||||||
created_at: "2025-01-01T00:00:00Z".to_string(),
|
created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||||
wal_enabled: false,
|
wal_enabled: false,
|
||||||
wal_max_entries: 500,
|
wal_max_entries: 500,
|
||||||
quantized_index: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,68 +82,6 @@ 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
|
// WriteEvent
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -161,9 +99,6 @@ pub struct WriteEvent {
|
|||||||
// WriteAnomalyDetector
|
// 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.
|
/// Tracks write events and raises alerts for suspicious behaviour.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct WriteAnomalyDetector {
|
pub struct WriteAnomalyDetector {
|
||||||
@@ -192,23 +127,6 @@ impl WriteAnomalyDetector {
|
|||||||
if event.timestamp > self.last_timestamp {
|
if event.timestamp > self.last_timestamp {
|
||||||
self.last_timestamp = event.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
|
*self
|
||||||
.session_counts
|
.session_counts
|
||||||
.entry(event.session_id.clone())
|
.entry(event.session_id.clone())
|
||||||
@@ -228,13 +146,6 @@ impl WriteAnomalyDetector {
|
|||||||
/// Returns an alert if the number of writes in the last 60 seconds exceeds
|
/// 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_minute`, or if any session has exceeded
|
||||||
/// `config.max_writes_per_session`.
|
/// `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> {
|
pub fn check_rate_anomaly(&self) -> Option<AnomalyAlert> {
|
||||||
let recent = self.window.len() as u32;
|
let recent = self.window.len() as u32;
|
||||||
if recent > self.config.max_writes_per_minute {
|
if recent > self.config.max_writes_per_minute {
|
||||||
@@ -245,31 +156,11 @@ impl WriteAnomalyDetector {
|
|||||||
} else {
|
} else {
|
||||||
Severity::Medium
|
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 {
|
return Some(AnomalyAlert {
|
||||||
severity,
|
severity,
|
||||||
message: format!(
|
message: format!(
|
||||||
"Rate limit exceeded: {} writes in last 60s (max {}){}",
|
"Rate limit exceeded: {} writes in last 60s (max {})",
|
||||||
recent, self.config.max_writes_per_minute, attribution
|
recent, self.config.max_writes_per_minute
|
||||||
),
|
),
|
||||||
timestamp: self.last_timestamp,
|
timestamp: self.last_timestamp,
|
||||||
});
|
});
|
||||||
@@ -297,24 +188,11 @@ impl WriteAnomalyDetector {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/// Returns an alert if `chunk` contains any of the configured suspicious
|
/// Returns an alert if `chunk` contains any of the configured suspicious
|
||||||
/// patterns, after normalizing both sides to defeat the cheapest evasion
|
/// patterns (case-insensitive).
|
||||||
/// 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> {
|
pub fn check_pattern_anomaly(&self, chunk: &str) -> Option<AnomalyAlert> {
|
||||||
let normalized = normalize_for_pattern_match(chunk);
|
let lower = chunk.to_lowercase();
|
||||||
for pattern in &self.config.suspicious_patterns {
|
for pattern in &self.config.suspicious_patterns {
|
||||||
let normalized_pattern = normalize_for_pattern_match(pattern);
|
if lower.contains(pattern.as_str()) {
|
||||||
if normalized_pattern.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if normalized.contains(&normalized_pattern) {
|
|
||||||
let severity = if pattern.contains("ignore") || pattern.contains("override") {
|
let severity = if pattern.contains("ignore") || pattern.contains("override") {
|
||||||
Severity::Critical
|
Severity::Critical
|
||||||
} else if pattern.contains("system") || pattern.contains("jailbreak") {
|
} else if pattern.contains("system") || pattern.contains("jailbreak") {
|
||||||
@@ -449,57 +327,6 @@ mod tests {
|
|||||||
assert!(alert.unwrap().severity >= Severity::Medium);
|
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]
|
#[test]
|
||||||
fn rate_anomaly_critical_3x() {
|
fn rate_anomaly_critical_3x() {
|
||||||
let mut det = WriteAnomalyDetector::new(cfg());
|
let mut det = WriteAnomalyDetector::new(cfg());
|
||||||
@@ -568,71 +395,6 @@ mod tests {
|
|||||||
assert!(alert.is_some());
|
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]
|
#[test]
|
||||||
fn pattern_jailbreak() {
|
fn pattern_jailbreak() {
|
||||||
let det = WriteAnomalyDetector::new(cfg());
|
let det = WriteAnomalyDetector::new(cfg());
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
//! let mem = AsyncHDF5Memory::open_with(path, config).await?;
|
//! let mem = AsyncHDF5Memory::open_with(path, config).await?;
|
||||||
//! mem.save(entry).await?; // buffered → background writer
|
//! mem.save(entry).await?; // buffered → background writer
|
||||||
//! mem.save_batch(entries).await?; // also buffered
|
//! mem.save_batch(entries).await?; // also buffered
|
||||||
//! let results = mem.hybrid_search(emb, "query".into(), 0.4, 0.6, 5).await;
|
//! let results = mem.hybrid_search(emb, "query".into(), 0.7, 0.3, 5).await;
|
||||||
//! mem.shutdown().await?; // final flush + stop
|
//! mem.shutdown().await?; // final flush + stop
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
@@ -408,10 +408,6 @@ impl AsyncHDF5Memory {
|
|||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
let _ = self.write_tx.send(WriteCmd::Shutdown(tx)).await;
|
let _ = self.write_tx.send(WriteCmd::Shutdown(tx)).await;
|
||||||
let _ = rx.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-406
@@ -3,38 +3,12 @@
|
|||||||
//! Provides a standard BM25 (Okapi BM25) implementation with an in-memory
|
//! Provides a standard BM25 (Okapi BM25) implementation with an in-memory
|
||||||
//! inverted index. Tombstoned documents are excluded from indexing and search.
|
//! inverted index. Tombstoned documents are excluded from indexing and search.
|
||||||
//!
|
//!
|
||||||
//! The index is **incremental**: [`BM25Index::add_document`] and
|
//! Optimizations:
|
||||||
//! [`BM25Index::remove_document`] keep it exactly equivalent to one built from
|
//! - Cached IDF scores (don't recompute per query)
|
||||||
//! scratch over the same live documents, so a store can maintain one index for
|
//! - Sorted posting lists by doc_id for cache-friendly access
|
||||||
//! its lifetime instead of re-tokenising the whole corpus per query. To make
|
//! - Block-Max WAND early termination
|
||||||
//! that possible IDF is computed at query time (it depends on the live
|
|
||||||
//! document count) rather than cached at build time.
|
|
||||||
//!
|
|
||||||
//! - Posting lists sorted by doc id
|
|
||||||
//! - Bounded-heap top-k; results ordered by score, then doc id (deterministic)
|
|
||||||
|
|
||||||
use std::cmp::Reverse;
|
use std::collections::HashMap;
|
||||||
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.
|
/// Default BM25 term-frequency saturation parameter.
|
||||||
const DEFAULT_K1: f32 = 1.2;
|
const DEFAULT_K1: f32 = 1.2;
|
||||||
@@ -46,11 +20,10 @@ const DEFAULT_B: f32 = 0.75;
|
|||||||
pub struct BM25Index {
|
pub struct BM25Index {
|
||||||
/// Inverted index: token -> sorted list of (doc_id, term_frequency).
|
/// Inverted index: token -> sorted list of (doc_id, term_frequency).
|
||||||
inverted: HashMap<String, Vec<(usize, u32)>>,
|
inverted: HashMap<String, Vec<(usize, u32)>>,
|
||||||
|
/// Cached IDF scores per token.
|
||||||
|
idf_cache: HashMap<String, f32>,
|
||||||
/// Number of tokens in each document (0 for tombstoned docs).
|
/// Number of tokens in each document (0 for tombstoned docs).
|
||||||
doc_lengths: Vec<u32>,
|
doc_lengths: Vec<u32>,
|
||||||
/// Sum of `doc_lengths` over live documents (keeps `avg_dl` exact under
|
|
||||||
/// incremental updates).
|
|
||||||
total_length: u64,
|
|
||||||
/// Average document length across non-tombstoned docs.
|
/// Average document length across non-tombstoned docs.
|
||||||
avg_dl: f32,
|
avg_dl: f32,
|
||||||
/// Number of non-tombstoned documents.
|
/// Number of non-tombstoned documents.
|
||||||
@@ -59,27 +32,19 @@ pub struct BM25Index {
|
|||||||
k1: f32,
|
k1: f32,
|
||||||
/// BM25 b parameter.
|
/// BM25 b parameter.
|
||||||
b: f32,
|
b: f32,
|
||||||
/// Applied to every document and query token, so the two always agree.
|
|
||||||
filter: TokenFilter,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BM25Index {
|
impl BM25Index {
|
||||||
/// Build a BM25 index from a set of documents, excluding tombstoned entries.
|
/// Build a BM25 index from a set of documents, excluding tombstoned entries.
|
||||||
pub fn build(documents: &[String], tombstones: &[u8]) -> Self {
|
pub fn build(documents: &[String], tombstones: &[u8]) -> Self {
|
||||||
Self::build_with(documents, tombstones, TokenFilter::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`BM25Index::build`] with the token filter chosen explicitly.
|
|
||||||
pub fn build_with(documents: &[String], tombstones: &[u8], filter: TokenFilter) -> Self {
|
|
||||||
let mut index = Self {
|
let mut index = Self {
|
||||||
inverted: HashMap::new(),
|
inverted: HashMap::new(),
|
||||||
|
idf_cache: HashMap::new(),
|
||||||
doc_lengths: vec![0; documents.len()],
|
doc_lengths: vec![0; documents.len()],
|
||||||
total_length: 0,
|
|
||||||
avg_dl: 0.0,
|
avg_dl: 0.0,
|
||||||
num_docs: 0,
|
num_docs: 0,
|
||||||
k1: DEFAULT_K1,
|
k1: DEFAULT_K1,
|
||||||
b: DEFAULT_B,
|
b: DEFAULT_B,
|
||||||
filter,
|
|
||||||
};
|
};
|
||||||
index.index_documents(documents, tombstones);
|
index.index_documents(documents, tombstones);
|
||||||
index
|
index
|
||||||
@@ -91,165 +56,112 @@ impl BM25Index {
|
|||||||
/// Uses Block-Max WAND for early termination when remaining documents
|
/// Uses Block-Max WAND for early termination when remaining documents
|
||||||
/// cannot beat the current top-k threshold.
|
/// cannot beat the current top-k threshold.
|
||||||
pub fn search(&self, query: &str, k: usize) -> Vec<(usize, f32)> {
|
pub fn search(&self, query: &str, k: usize) -> Vec<(usize, f32)> {
|
||||||
if k == 0 {
|
if self.num_docs == 0 || k == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
// Top-k with a bounded min-heap: O(matches * log k) instead of sorting
|
|
||||||
// every match. Ties break towards the lower doc id so results are
|
|
||||||
// deterministic.
|
|
||||||
let mut heap: BinaryHeap<Reverse<(HeapScore, Reverse<usize>)>> =
|
|
||||||
BinaryHeap::with_capacity(k.min(1024) + 1);
|
|
||||||
for (doc_id, score) in self.scores(query) {
|
|
||||||
heap.push(Reverse((HeapScore(score), Reverse(doc_id))));
|
|
||||||
if heap.len() > k {
|
|
||||||
heap.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut results: Vec<(usize, f32)> = heap
|
|
||||||
.into_iter()
|
|
||||||
.map(|Reverse((HeapScore(score), Reverse(doc_id)))| (doc_id, score))
|
|
||||||
.collect();
|
|
||||||
results.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
|
||||||
results
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The BM25 score of **every** matching document, in doc-id order, unsorted
|
let tokens = tokenize(query);
|
||||||
/// by score. Score fusion normalises over the whole matching set, so it
|
if tokens.is_empty() {
|
||||||
/// needs all of these but not their ranking; producing a ranked list of
|
|
||||||
/// every match (`search(query, corpus_len)`) spent most of its time sorting.
|
|
||||||
pub fn scores(&self, query: &str) -> Vec<(usize, f32)> {
|
|
||||||
if self.num_docs == 0 {
|
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
// Term-at-a-time accumulation into a dense array: a common term has a
|
|
||||||
// posting per document, and hashing each one dominated query time.
|
// Collect posting lists and cached IDF scores for query tokens
|
||||||
// IDF is computed here rather than cached at build time: it depends on
|
type QueryTerm<'a> = (&'a str, f32, &'a [(usize, u32)]);
|
||||||
// the live document count, which changes with every incremental
|
let mut query_terms: Vec<QueryTerm<'_>> = Vec::new();
|
||||||
// add/remove, and costs one `ln` per query term.
|
for token in &tokens {
|
||||||
let mut acc = vec![0.0f32; self.doc_lengths.len()];
|
if let (Some(postings), Some(&idf)) = (
|
||||||
let mut matched = false;
|
self.inverted.get(token.as_str()),
|
||||||
for token in tokenize_with(query, self.filter) {
|
self.idf_cache.get(token.as_str()),
|
||||||
let Some(postings) = self.inverted.get(token.as_str()) else {
|
) {
|
||||||
continue;
|
query_terms.push((token, idf, postings));
|
||||||
};
|
}
|
||||||
matched = true;
|
}
|
||||||
let df = postings.len() as f32;
|
|
||||||
let idf = ((self.num_docs as f32 - df + 0.5) / (df + 0.5) + 1.0).ln();
|
if query_terms.is_empty() {
|
||||||
for &(doc_id, freq) in postings {
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulate BM25 scores per document using WAND-style scoring
|
||||||
|
let mut scores: HashMap<usize, f32> = HashMap::new();
|
||||||
|
|
||||||
|
// Compute maximum possible contribution per term for WAND
|
||||||
|
let max_tf_score: Vec<f32> = query_terms
|
||||||
|
.iter()
|
||||||
|
.map(|(_, idf, _)| {
|
||||||
|
// Upper bound: max TF contribution when tf is high and dl is short
|
||||||
|
let max_tf_num = 10.0 * (self.k1 + 1.0);
|
||||||
|
let max_tf_den = 10.0 + self.k1 * (1.0 - self.b);
|
||||||
|
idf * max_tf_num / max_tf_den
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total_max_contribution: f32 = max_tf_score.iter().sum();
|
||||||
|
|
||||||
|
// Threshold for WAND early termination
|
||||||
|
let mut threshold = 0.0f32;
|
||||||
|
let mut top_k_scores: Vec<f32> = Vec::with_capacity(k);
|
||||||
|
|
||||||
|
for (term_idx, (_, idf, postings)) in query_terms.iter().enumerate() {
|
||||||
|
for &(doc_id, freq) in *postings {
|
||||||
let dl = self.doc_lengths[doc_id] as f32;
|
let dl = self.doc_lengths[doc_id] as f32;
|
||||||
let freq_f = freq as f32;
|
let freq_f = freq as f32;
|
||||||
let tf = (freq_f * (self.k1 + 1.0))
|
let tf = (freq_f * (self.k1 + 1.0))
|
||||||
/ (freq_f + self.k1 * (1.0 - self.b + self.b * dl / self.avg_dl));
|
/ (freq_f + self.k1 * (1.0 - self.b + self.b * dl / self.avg_dl));
|
||||||
acc[doc_id] += idf * tf;
|
let contribution = idf * tf;
|
||||||
}
|
|
||||||
}
|
|
||||||
if !matched {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
// Every contribution is strictly positive (idf = ln(1 + x), x > 0), so
|
|
||||||
// a zero entry is a document no query term touched.
|
|
||||||
acc.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|&(_, score)| score > 0.0)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The token filter this index was built with.
|
let entry = scores.entry(doc_id).or_insert(0.0);
|
||||||
pub fn token_filter(&self) -> TokenFilter {
|
*entry += contribution;
|
||||||
self.filter
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of document slots (live or not) the index covers. Ids are
|
// WAND check: if this doc's current partial score + remaining
|
||||||
/// positions in the document list it mirrors.
|
// max terms can't beat threshold, we can skip (but we still
|
||||||
pub fn len(&self) -> usize {
|
// accumulate since we process term-at-a-time)
|
||||||
self.doc_lengths.len()
|
if term_idx == query_terms.len() - 1 {
|
||||||
|
// Last term: check if this doc beats threshold
|
||||||
|
let final_score = *entry;
|
||||||
|
if final_score > threshold && top_k_scores.len() >= k {
|
||||||
|
// Update threshold
|
||||||
|
top_k_scores
|
||||||
|
.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
if final_score > top_k_scores[k - 1] {
|
||||||
|
top_k_scores[k - 1] = final_score;
|
||||||
|
top_k_scores.sort_by(|a, b| {
|
||||||
|
b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
threshold = top_k_scores[k - 1];
|
||||||
}
|
}
|
||||||
|
} else if top_k_scores.len() < k {
|
||||||
/// `true` when the index covers no document slots.
|
top_k_scores.push(final_score);
|
||||||
pub fn is_empty(&self) -> bool {
|
if top_k_scores.len() == k {
|
||||||
self.doc_lengths.is_empty()
|
top_k_scores.sort_by(|a, b| {
|
||||||
}
|
b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
/// Index `text` as document `doc_id`, which must be the next free id
|
threshold = top_k_scores[k - 1];
|
||||||
/// (`self.len()`) or an existing slot that is currently empty (removed or
|
|
||||||
/// tombstoned). After any sequence of `add_document` / `remove_document`
|
|
||||||
/// calls the index scores exactly as one freshly built from the same live
|
|
||||||
/// documents.
|
|
||||||
pub fn add_document(&mut self, doc_id: usize, text: &str) {
|
|
||||||
if doc_id >= self.doc_lengths.len() {
|
|
||||||
self.doc_lengths.resize(doc_id + 1, 0);
|
|
||||||
}
|
|
||||||
debug_assert_eq!(self.doc_lengths[doc_id], 0, "slot {doc_id} is occupied");
|
|
||||||
|
|
||||||
let tokens = tokenize_with(text, self.filter);
|
|
||||||
let mut term_freqs: HashMap<&str, u32> = HashMap::new();
|
|
||||||
for token in &tokens {
|
|
||||||
*term_freqs.entry(token).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
for (token, freq) in term_freqs {
|
|
||||||
let postings = self.inverted.entry(token.to_string()).or_default();
|
|
||||||
// Posting lists stay sorted by doc id; appends are the common case.
|
|
||||||
match postings.last() {
|
|
||||||
Some(&(last, _)) if last >= doc_id => {
|
|
||||||
let at = postings.partition_point(|&(id, _)| id < doc_id);
|
|
||||||
postings.insert(at, (doc_id, freq));
|
|
||||||
}
|
|
||||||
_ => postings.push((doc_id, freq)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.doc_lengths[doc_id] = tokens.len() as u32;
|
|
||||||
self.total_length += tokens.len() as u64;
|
|
||||||
self.num_docs += 1;
|
|
||||||
self.refresh_avg_dl();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/// Extend the index to cover `len` document slots, leaving new ones empty.
|
// After processing each term, check if remaining terms can
|
||||||
/// Used for slots that hold no live document (tombstoned records).
|
// possibly produce results above threshold
|
||||||
pub fn pad_to(&mut self, len: usize) {
|
let remaining_max: f32 = max_tf_score[term_idx + 1..].iter().sum();
|
||||||
if len > self.doc_lengths.len() {
|
if remaining_max < threshold && total_max_contribution > 0.0 {
|
||||||
self.doc_lengths.resize(len, 0);
|
// Early termination: remaining terms can't produce new top-k
|
||||||
|
// entries on their own. But existing partial scores may still
|
||||||
|
// be updated, so we continue (WAND is approximate here).
|
||||||
|
let _ = remaining_max; // hint to compiler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove document `doc_id`, whose indexed text was `text`. The text is
|
let mut results: Vec<(usize, f32)> = scores.into_iter().collect();
|
||||||
/// needed to find its postings; pass exactly what was added.
|
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
pub fn remove_document(&mut self, doc_id: usize, text: &str) {
|
results.truncate(k);
|
||||||
let tokens = tokenize_with(text, self.filter);
|
results
|
||||||
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
|
||||||
for token in &tokens {
|
|
||||||
if !seen.insert(token) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(postings) = self.inverted.get_mut(token.as_str()) {
|
|
||||||
if let Ok(at) = postings.binary_search_by_key(&doc_id, |&(id, _)| id) {
|
|
||||||
postings.remove(at);
|
|
||||||
}
|
|
||||||
if postings.is_empty() {
|
|
||||||
self.inverted.remove(token.as_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(len) = self.doc_lengths.get_mut(doc_id) {
|
|
||||||
self.total_length = self.total_length.saturating_sub(u64::from(*len));
|
|
||||||
*len = 0;
|
|
||||||
}
|
|
||||||
self.num_docs = self.num_docs.saturating_sub(1);
|
|
||||||
self.refresh_avg_dl();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn refresh_avg_dl(&mut self) {
|
|
||||||
self.avg_dl = if self.num_docs > 0 {
|
|
||||||
self.total_length as f32 / self.num_docs as f32
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the index from scratch (e.g., after compaction).
|
/// Rebuild the index from scratch (e.g., after compaction).
|
||||||
pub fn rebuild(&mut self, documents: &[String], tombstones: &[u8]) {
|
pub fn rebuild(&mut self, documents: &[String], tombstones: &[u8]) {
|
||||||
self.inverted.clear();
|
self.inverted.clear();
|
||||||
|
self.idf_cache.clear();
|
||||||
self.doc_lengths = vec![0; documents.len()];
|
self.doc_lengths = vec![0; documents.len()];
|
||||||
self.total_length = 0;
|
|
||||||
self.avg_dl = 0.0;
|
self.avg_dl = 0.0;
|
||||||
self.num_docs = 0;
|
self.num_docs = 0;
|
||||||
self.index_documents(documents, tombstones);
|
self.index_documents(documents, tombstones);
|
||||||
@@ -265,7 +177,7 @@ impl BM25Index {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let tokens = tokenize_with(doc, self.filter);
|
let tokens = tokenize(doc);
|
||||||
let doc_len = tokens.len() as u32;
|
let doc_len = tokens.len() as u32;
|
||||||
self.doc_lengths[i] = doc_len;
|
self.doc_lengths[i] = doc_len;
|
||||||
total_length += doc_len as u64;
|
total_length += doc_len as u64;
|
||||||
@@ -286,98 +198,33 @@ impl BM25Index {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.num_docs = count;
|
self.num_docs = count;
|
||||||
self.total_length = total_length;
|
self.avg_dl = if count > 0 {
|
||||||
self.refresh_avg_dl();
|
total_length as f32 / count as f32
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
// Sort posting lists by doc_id for cache-friendly access
|
// Sort posting lists by doc_id for cache-friendly access
|
||||||
for postings in self.inverted.values_mut() {
|
for postings in self.inverted.values_mut() {
|
||||||
postings.sort_by_key(|&(doc_id, _)| doc_id);
|
postings.sort_by_key(|&(doc_id, _)| doc_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-compute and cache IDF scores
|
||||||
|
for (token, postings) in &self.inverted {
|
||||||
|
let df = postings.len() as f32;
|
||||||
|
let idf = ((self.num_docs as f32 - df + 0.5) / (df + 0.5) + 1.0).ln();
|
||||||
|
self.idf_cache.insert(token.clone(), idf);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tokenize a string: lowercase, split on non-alphanumeric characters,
|
/// Tokenize a string: lowercase, split on non-alphanumeric characters,
|
||||||
/// filter empty tokens.
|
/// filter empty tokens.
|
||||||
/// What [`tokenize_with`] does to each token after splitting.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum TokenFilter {
|
|
||||||
/// Lowercase and split only — the original behaviour.
|
|
||||||
#[default]
|
|
||||||
Plain,
|
|
||||||
/// Also strip common English inflections, so "running" and "runs" match
|
|
||||||
/// "run". Conservative on purpose: only plural and past/continuous verb
|
|
||||||
/// endings, and only on tokens long enough that stripping leaves a real
|
|
||||||
/// stem. A stemmer earns its keep by conflating *related* words; an
|
|
||||||
/// aggressive one also conflates unrelated ones ("universe"/"university"),
|
|
||||||
/// which costs precision.
|
|
||||||
Stemmed,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip common English inflections from an already-lowercased token.
|
|
||||||
///
|
|
||||||
/// Applied identically to documents and queries, so the pair only has to agree
|
|
||||||
/// with itself — the stem need not be a real word.
|
|
||||||
fn stem(token: &str) -> &str {
|
|
||||||
// Below this, stripping does more harm than good ("bed" -> "b").
|
|
||||||
const MIN_STEM: usize = 4;
|
|
||||||
let strip = |suffix: &str, min_len: usize| -> Option<&str> {
|
|
||||||
let stem = token.strip_suffix(suffix)?;
|
|
||||||
(stem.len() >= min_len).then_some(stem)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Plurals first: "studies" -> "studi", "classes" -> "class", "cats" -> "cat".
|
|
||||||
// "ies" keeps its "i" so the result meets "-ied" ("studied" -> "studi").
|
|
||||||
if let Some(stem) = strip("ies", 2) {
|
|
||||||
return &token[..stem.len() + 1];
|
|
||||||
}
|
|
||||||
for suffix in ["sses", "shes", "ches", "xes", "zes"] {
|
|
||||||
if let Some(stem) = strip(suffix, MIN_STEM - 1) {
|
|
||||||
// Keep the sibilant: "classes" -> "class", not "clas".
|
|
||||||
return &token[..stem.len() + 2];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Verb endings before the bare plural, so "raced" doesn't become "raced".
|
|
||||||
if let Some(stem) = strip("ing", MIN_STEM - 1).or_else(|| strip("ed", MIN_STEM - 1)) {
|
|
||||||
return undouble(stem);
|
|
||||||
}
|
|
||||||
if !token.ends_with("ss")
|
|
||||||
&& !token.ends_with("us")
|
|
||||||
&& !token.ends_with("is")
|
|
||||||
&& let Some(stem) = strip("s", MIN_STEM - 1)
|
|
||||||
{
|
|
||||||
return stem;
|
|
||||||
}
|
|
||||||
token
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "runn" -> "run": undo the consonant doubling that "-ing"/"-ed" introduce.
|
|
||||||
fn undouble(stem: &str) -> &str {
|
|
||||||
let mut chars = stem.chars().rev();
|
|
||||||
let (Some(last), Some(prev)) = (chars.next(), chars.next()) else {
|
|
||||||
return stem;
|
|
||||||
};
|
|
||||||
let doubled = last == prev && !"aeiou".contains(last) && last.is_ascii_alphabetic();
|
|
||||||
if doubled && stem.len() > 3 {
|
|
||||||
&stem[..stem.len() - 1]
|
|
||||||
} else {
|
|
||||||
stem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn tokenize(text: &str) -> Vec<String> {
|
fn tokenize(text: &str) -> Vec<String> {
|
||||||
tokenize_with(text, TokenFilter::Plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Split `text` into scoring tokens under `filter`.
|
|
||||||
pub fn tokenize_with(text: &str, filter: TokenFilter) -> Vec<String> {
|
|
||||||
text.to_lowercase()
|
text.to_lowercase()
|
||||||
.split(|c: char| !c.is_alphanumeric())
|
.split(|c: char| !c.is_alphanumeric())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|token| match filter {
|
.map(|s| s.to_string())
|
||||||
TokenFilter::Plain => token.to_string(),
|
|
||||||
TokenFilter::Stemmed => stem(token).to_string(),
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,21 +370,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn score_matches_the_bm25_formula() {
|
fn cached_idf_consistent_with_computed() {
|
||||||
let docs = vec![
|
let docs = vec![
|
||||||
"rust programming".to_string(),
|
"rust programming".to_string(),
|
||||||
"rust systems".to_string(),
|
"rust systems".to_string(),
|
||||||
"python scripting".to_string(),
|
"python scripting".to_string(),
|
||||||
];
|
];
|
||||||
let index = BM25Index::build(&docs, &[0, 0, 0]);
|
let tombstones = vec![0, 0, 0];
|
||||||
|
let index = BM25Index::build(&docs, &tombstones);
|
||||||
|
|
||||||
// "python": df = 1 of N = 3. Every doc has the average length (2) and
|
// IDF for "rust" (appears in 2 of 3 docs)
|
||||||
// tf = 1, so the tf factor is exactly 1 and the score is the IDF.
|
let idf_rust = index.idf_cache.get("rust").unwrap();
|
||||||
let results = index.search("python", 3);
|
let expected_idf = ((3.0f32 - 2.0 + 0.5) / (2.0 + 0.5) + 1.0).ln();
|
||||||
let expected_idf = ((3.0f32 - 1.0 + 0.5) / (1.0 + 0.5) + 1.0).ln();
|
assert!(
|
||||||
assert_eq!(results.len(), 1);
|
(idf_rust - expected_idf).abs() < 1e-6,
|
||||||
assert_eq!(results[0].0, 2);
|
"cached IDF mismatch: {} vs {}",
|
||||||
assert!((results[0].1 - expected_idf).abs() < 1e-6, "{results:?}");
|
idf_rust,
|
||||||
|
expected_idf
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -601,144 +451,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Documents drawn from a small vocabulary so terms collide heavily.
|
|
||||||
fn random_doc(state: &mut u64) -> String {
|
|
||||||
const VOCAB: &[&str] = &[
|
|
||||||
"alpha", "beta", "gamma", "delta", "eps", "zeta", "eta", "x1",
|
|
||||||
];
|
|
||||||
let mut next = || {
|
|
||||||
*state = state
|
|
||||||
.wrapping_mul(6364136223846793005)
|
|
||||||
.wrapping_add(1442695040888963407);
|
|
||||||
(*state >> 33) as usize
|
|
||||||
};
|
|
||||||
let len = 1 + next() % 9;
|
|
||||||
(0..len)
|
|
||||||
.map(|_| VOCAB[next() % VOCAB.len()])
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn incremental_updates_match_a_fresh_build_exactly() {
|
|
||||||
for seed in 0..60u64 {
|
|
||||||
let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
|
|
||||||
let mut docs: Vec<String> = Vec::new();
|
|
||||||
let mut tombstones: Vec<u8> = Vec::new();
|
|
||||||
let mut index = BM25Index::build(&docs, &tombstones);
|
|
||||||
|
|
||||||
for step in 0..80 {
|
|
||||||
state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
|
|
||||||
let live: Vec<usize> = (0..docs.len()).filter(|&i| tombstones[i] == 0).collect();
|
|
||||||
match (state >> 40) % 4 {
|
|
||||||
0 if !live.is_empty() => {
|
|
||||||
// delete
|
|
||||||
let id = live[(state >> 20) as usize % live.len()];
|
|
||||||
index.remove_document(id, &docs[id]);
|
|
||||||
tombstones[id] = 1;
|
|
||||||
}
|
|
||||||
1 if !live.is_empty() => {
|
|
||||||
// update in place
|
|
||||||
let id = live[(state >> 20) as usize % live.len()];
|
|
||||||
let new_text = random_doc(&mut state);
|
|
||||||
index.remove_document(id, &docs[id]);
|
|
||||||
index.add_document(id, &new_text);
|
|
||||||
docs[id] = new_text;
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let text = random_doc(&mut state);
|
|
||||||
index.add_document(docs.len(), &text);
|
|
||||||
docs.push(text);
|
|
||||||
tombstones.push(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let fresh = BM25Index::build(&docs, &tombstones);
|
|
||||||
for query in ["alpha", "beta gamma", "x1 zeta alpha delta", "missing"] {
|
|
||||||
let got = index.search(query, 5);
|
|
||||||
let want = fresh.search(query, 5);
|
|
||||||
assert_eq!(got.len(), want.len(), "seed {seed} step {step} {query:?}");
|
|
||||||
for (g, w) in got.iter().zip(&want) {
|
|
||||||
assert_eq!(
|
|
||||||
g.0, w.0,
|
|
||||||
"seed {seed} step {step} {query:?}: {got:?} vs {want:?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
(g.1 - w.1).abs() < 1e-5,
|
|
||||||
"seed {seed} step {step} {query:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scores_is_the_unranked_form_of_a_full_search() {
|
|
||||||
let mut state = 99u64;
|
|
||||||
let docs: Vec<String> = (0..200).map(|_| random_doc(&mut state)).collect();
|
|
||||||
let tombstones: Vec<u8> = (0..200).map(|i| u8::from(i % 7 == 0)).collect();
|
|
||||||
let index = BM25Index::build(&docs, &tombstones);
|
|
||||||
for query in ["alpha", "beta gamma x1", "missing", ""] {
|
|
||||||
let mut all = index.scores(query);
|
|
||||||
all.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
|
||||||
assert_eq!(all, index.search(query, docs.len()), "{query:?}");
|
|
||||||
assert!(all.iter().all(|(id, _)| tombstones[*id] == 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stemming_conflates_inflections_of_the_same_word() {
|
|
||||||
let stem_of = |w: &str| tokenize_with(w, TokenFilter::Stemmed).pop().unwrap();
|
|
||||||
// Pairs that should meet.
|
|
||||||
for (a, b) in [
|
|
||||||
("running", "runs"),
|
|
||||||
("trained", "training"),
|
|
||||||
("miles", "mile"),
|
|
||||||
("studies", "studied"),
|
|
||||||
("mentioned", "mentioning"),
|
|
||||||
("classes", "class"),
|
|
||||||
("planned", "planning"),
|
|
||||||
] {
|
|
||||||
assert_eq!(stem_of(a), stem_of(b), "{a} / {b} should share a stem");
|
|
||||||
}
|
|
||||||
// Pairs that must stay apart. Note which pairs are deliberately absent:
|
|
||||||
// "bed"/"bedding" and "gas"/"gassed" both collapse to one stem, which
|
|
||||||
// is what Porter does too and is right — they are related words.
|
|
||||||
for (a, b) in [
|
|
||||||
("universe", "university"),
|
|
||||||
("business", "busy"),
|
|
||||||
("this", "thing"),
|
|
||||||
] {
|
|
||||||
assert_ne!(stem_of(a), stem_of(b), "{a} / {b} must not be conflated");
|
|
||||||
}
|
|
||||||
// Short words and non-inflections are left alone.
|
|
||||||
for word in ["run", "bus", "is", "his", "data", "gas"] {
|
|
||||||
assert_eq!(stem_of(word), word, "{word} should be untouched");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stemming_is_off_by_default_and_applied_consistently() {
|
|
||||||
assert_eq!(tokenize("Running miles"), ["running", "miles"]);
|
|
||||||
assert_eq!(
|
|
||||||
tokenize_with("Running miles", TokenFilter::Stemmed),
|
|
||||||
["run", "mile"]
|
|
||||||
);
|
|
||||||
|
|
||||||
// A query inflected differently from the document still matches.
|
|
||||||
let docs = vec!["I ran while training for the marathon".to_string()];
|
|
||||||
let plain = BM25Index::build_with(&docs, &[0], TokenFilter::Plain);
|
|
||||||
let stemmed = BM25Index::build_with(&docs, &[0], TokenFilter::Stemmed);
|
|
||||||
assert!(plain.search("trains", 1).is_empty());
|
|
||||||
assert_eq!(stemmed.search("trains", 1).len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ties_break_towards_the_lower_doc_id() {
|
|
||||||
let docs: Vec<String> = (0..6).map(|_| "same text".to_string()).collect();
|
|
||||||
let index = BM25Index::build(&docs, &[0; 6]);
|
|
||||||
let ids: Vec<usize> = index.search("same", 3).into_iter().map(|r| r.0).collect();
|
|
||||||
assert_eq!(ids, [0, 1, 2]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,143 +2,11 @@
|
|||||||
|
|
||||||
use crate::vector_search;
|
use crate::vector_search;
|
||||||
|
|
||||||
/// Every entry's embedding, in one contiguous `[N x dim]` buffer.
|
|
||||||
///
|
|
||||||
/// Rows are always exactly `dim` long: a shorter one is zero-padded, a longer
|
|
||||||
/// one truncated. The previous `Vec<Vec<f32>>` allowed ragged rows, which
|
|
||||||
/// silently misaligned the flattened copy that the batched kernels read — a
|
|
||||||
/// single wrong-length embedding shifted every row after it. Padding makes
|
|
||||||
/// that unrepresentable. A record stored without an embedding therefore holds
|
|
||||||
/// a zero row, and is told apart by its norm being zero rather than by length.
|
|
||||||
///
|
|
||||||
/// This used to be two fields — a `Vec<Vec<f32>>` and a flattened copy kept in
|
|
||||||
/// lock-step — which stored the whole corpus twice and cost one heap
|
|
||||||
/// allocation per entry on top. At 100k 384-dim entries that duplicate was
|
|
||||||
/// ~150 MiB. Indexing yields a `&[f32]` row, so `embeddings[i]` still reads
|
|
||||||
/// the same way.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct Embeddings {
|
|
||||||
flat: Vec<f32>,
|
|
||||||
dim: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Embeddings {
|
|
||||||
pub fn new(dim: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
flat: Vec::new(),
|
|
||||||
dim,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of embeddings.
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.flat.len().checked_div(self.dim).unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.len() == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The whole buffer, `[N x dim]` row-major — what batched kernels read.
|
|
||||||
pub fn as_flat(&self) -> &[f32] {
|
|
||||||
&self.flat
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dim(&self) -> usize {
|
|
||||||
self.dim
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Row `i`, or `None` if out of range.
|
|
||||||
pub fn get(&self, i: usize) -> Option<&[f32]> {
|
|
||||||
let start = i.checked_mul(self.dim)?;
|
|
||||||
self.flat.get(start..start.checked_add(self.dim)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn iter(&self) -> impl ExactSizeIterator<Item = &[f32]> {
|
|
||||||
self.flat.chunks_exact(self.dim.max(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append one embedding. A row whose length doesn't match `dim` is padded
|
|
||||||
/// or truncated, so the buffer stays rectangular whatever a caller passes.
|
|
||||||
pub fn push(&mut self, embedding: &[f32]) {
|
|
||||||
if self.dim == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let take = embedding.len().min(self.dim);
|
|
||||||
self.flat.extend_from_slice(&embedding[..take]);
|
|
||||||
self.flat.resize(self.flat.len() + (self.dim - take), 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace row `i`. Out-of-range indices are ignored.
|
|
||||||
pub fn set(&mut self, i: usize, embedding: &[f32]) {
|
|
||||||
let Some(start) = i.checked_mul(self.dim) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if start + self.dim > self.flat.len() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let take = embedding.len().min(self.dim);
|
|
||||||
self.flat[start..start + take].copy_from_slice(&embedding[..take]);
|
|
||||||
self.flat[start + take..start + self.dim].fill(0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keep only the rows `keep` returns true for, preserving order.
|
|
||||||
pub fn retain(&mut self, mut keep: impl FnMut(usize) -> bool) {
|
|
||||||
if self.dim == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut write = 0usize;
|
|
||||||
for read in 0..self.len() {
|
|
||||||
if keep(read) {
|
|
||||||
if write != read {
|
|
||||||
let (dst, src) = (write * self.dim, read * self.dim);
|
|
||||||
self.flat.copy_within(src..src + self.dim, dst);
|
|
||||||
}
|
|
||||||
write += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.flat.truncate(write * self.dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace the contents with `rows`.
|
|
||||||
pub fn reset_from(&mut self, dim: usize, rows: impl IntoIterator<Item = Vec<f32>>) {
|
|
||||||
self.dim = dim;
|
|
||||||
self.flat.clear();
|
|
||||||
for row in rows {
|
|
||||||
self.push(&row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adopt an already-flat buffer, trimming any partial trailing row.
|
|
||||||
pub fn set_flat(&mut self, dim: usize, mut flat: Vec<f32>) {
|
|
||||||
self.dim = dim;
|
|
||||||
match flat.len().checked_div(dim) {
|
|
||||||
Some(rows) => flat.truncate(rows * dim),
|
|
||||||
None => flat.clear(),
|
|
||||||
}
|
|
||||||
self.flat = flat;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PartialEq for Embeddings {
|
|
||||||
fn eq(&self, other: &Self) -> bool {
|
|
||||||
self.dim == other.dim && self.flat == other.flat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::ops::Index<usize> for Embeddings {
|
|
||||||
type Output = [f32];
|
|
||||||
|
|
||||||
fn index(&self, i: usize) -> &[f32] {
|
|
||||||
self.get(i).expect("embedding index out of range")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// In-memory cache for the /memory group data.
|
/// In-memory cache for the /memory group data.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MemoryCache {
|
pub struct MemoryCache {
|
||||||
pub chunks: Vec<String>,
|
pub chunks: Vec<String>,
|
||||||
pub embeddings: Embeddings,
|
pub embeddings: Vec<Vec<f32>>,
|
||||||
pub source_channels: Vec<String>,
|
pub source_channels: Vec<String>,
|
||||||
pub timestamps: Vec<f64>,
|
pub timestamps: Vec<f64>,
|
||||||
pub session_ids: Vec<String>,
|
pub session_ids: Vec<String>,
|
||||||
@@ -155,7 +23,7 @@ impl MemoryCache {
|
|||||||
pub fn new(embedding_dim: usize) -> Self {
|
pub fn new(embedding_dim: usize) -> Self {
|
||||||
Self {
|
Self {
|
||||||
chunks: Vec::new(),
|
chunks: Vec::new(),
|
||||||
embeddings: Embeddings::new(embedding_dim),
|
embeddings: Vec::new(),
|
||||||
source_channels: Vec::new(),
|
source_channels: Vec::new(),
|
||||||
timestamps: Vec::new(),
|
timestamps: Vec::new(),
|
||||||
session_ids: Vec::new(),
|
session_ids: Vec::new(),
|
||||||
@@ -167,16 +35,6 @@ impl MemoryCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kept for callers that used to have to re-flatten after a bulk load.
|
|
||||||
/// The buffer is always flat now, so there is nothing to rebuild.
|
|
||||||
#[deprecated(note = "embeddings are stored flat; this is a no-op")]
|
|
||||||
pub fn rebuild_flat(&mut self) {}
|
|
||||||
|
|
||||||
/// The embeddings as one contiguous `[N x dim]` buffer.
|
|
||||||
pub fn flat_embeddings(&self) -> &[f32] {
|
|
||||||
self.embeddings.as_flat()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Total number of entries (including tombstoned).
|
/// Total number of entries (including tombstoned).
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.chunks.len()
|
self.chunks.len()
|
||||||
@@ -204,7 +62,7 @@ impl MemoryCache {
|
|||||||
let idx = self.chunks.len();
|
let idx = self.chunks.len();
|
||||||
let norm = vector_search::compute_norm(&embedding);
|
let norm = vector_search::compute_norm(&embedding);
|
||||||
self.chunks.push(chunk);
|
self.chunks.push(chunk);
|
||||||
self.embeddings.push(&embedding);
|
self.embeddings.push(embedding);
|
||||||
self.source_channels.push(source_channel);
|
self.source_channels.push(source_channel);
|
||||||
self.timestamps.push(timestamp);
|
self.timestamps.push(timestamp);
|
||||||
self.session_ids.push(session_id);
|
self.session_ids.push(session_id);
|
||||||
@@ -242,7 +100,7 @@ impl MemoryCache {
|
|||||||
if idx < self.chunks.len() {
|
if idx < self.chunks.len() {
|
||||||
let norm = vector_search::compute_norm(&embedding);
|
let norm = vector_search::compute_norm(&embedding);
|
||||||
self.chunks[idx] = chunk;
|
self.chunks[idx] = chunk;
|
||||||
self.embeddings.set(idx, &embedding);
|
self.embeddings[idx] = embedding;
|
||||||
self.source_channels[idx] = source_channel;
|
self.source_channels[idx] = source_channel;
|
||||||
self.timestamps[idx] = timestamp;
|
self.timestamps[idx] = timestamp;
|
||||||
self.session_ids[idx] = session_id;
|
self.session_ids[idx] = session_id;
|
||||||
@@ -294,7 +152,7 @@ impl MemoryCache {
|
|||||||
new_idx += 1;
|
new_idx += 1;
|
||||||
let norm = vector_search::compute_norm(&self.embeddings[i]);
|
let norm = vector_search::compute_norm(&self.embeddings[i]);
|
||||||
new_chunks.push(self.chunks[i].clone());
|
new_chunks.push(self.chunks[i].clone());
|
||||||
new_embeddings.push(self.embeddings[i].to_vec());
|
new_embeddings.push(self.embeddings[i].clone());
|
||||||
new_source_channels.push(self.source_channels[i].clone());
|
new_source_channels.push(self.source_channels[i].clone());
|
||||||
new_timestamps.push(self.timestamps[i]);
|
new_timestamps.push(self.timestamps[i]);
|
||||||
new_session_ids.push(self.session_ids[i].clone());
|
new_session_ids.push(self.session_ids[i].clone());
|
||||||
@@ -307,8 +165,7 @@ impl MemoryCache {
|
|||||||
|
|
||||||
let removed = old_len - new_chunks.len();
|
let removed = old_len - new_chunks.len();
|
||||||
self.chunks = new_chunks;
|
self.chunks = new_chunks;
|
||||||
self.embeddings
|
self.embeddings = new_embeddings;
|
||||||
.reset_from(self.embedding_dim, new_embeddings);
|
|
||||||
self.source_channels = new_source_channels;
|
self.source_channels = new_source_channels;
|
||||||
self.timestamps = new_timestamps;
|
self.timestamps = new_timestamps;
|
||||||
self.session_ids = new_session_ids;
|
self.session_ids = new_session_ids;
|
||||||
@@ -320,123 +177,12 @@ impl MemoryCache {
|
|||||||
(removed, index_map)
|
(removed, index_map)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All embeddings as one owned `[N x dim]` buffer, for HDF5 storage.
|
/// Flatten all embeddings into a single Vec<f32> for HDF5 storage.
|
||||||
/// Prefer [`MemoryCache::flat_embeddings`] where a borrow will do.
|
pub fn flat_embeddings(&self) -> Vec<f32> {
|
||||||
pub fn flat_embeddings_owned(&self) -> Vec<f32> {
|
let mut flat = Vec::with_capacity(self.embeddings.len() * self.embedding_dim);
|
||||||
self.embeddings.as_flat().to_vec()
|
for emb in &self.embeddings {
|
||||||
|
flat.extend_from_slice(emb);
|
||||||
}
|
}
|
||||||
}
|
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.as_flat(), expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn push_keeps_flat_buffer_in_sync() {
|
|
||||||
let mut cache = MemoryCache::new(3);
|
|
||||||
cache.push(
|
|
||||||
"a".into(),
|
|
||||||
vec![1.0, 2.0, 3.0],
|
|
||||||
"chan".into(),
|
|
||||||
0.0,
|
|
||||||
"s1".into(),
|
|
||||||
String::new(),
|
|
||||||
);
|
|
||||||
cache.push(
|
|
||||||
"b".into(),
|
|
||||||
vec![4.0, 5.0, 6.0],
|
|
||||||
"chan".into(),
|
|
||||||
1.0,
|
|
||||||
"s1".into(),
|
|
||||||
String::new(),
|
|
||||||
);
|
|
||||||
assert_flat_in_sync(&cache);
|
|
||||||
assert_eq!(
|
|
||||||
cache.embeddings.as_flat(),
|
|
||||||
vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn update_keeps_flat_buffer_in_sync() {
|
|
||||||
let mut cache = MemoryCache::new(3);
|
|
||||||
cache.push(
|
|
||||||
"a".into(),
|
|
||||||
vec![1.0, 2.0, 3.0],
|
|
||||||
"chan".into(),
|
|
||||||
0.0,
|
|
||||||
"s1".into(),
|
|
||||||
String::new(),
|
|
||||||
);
|
|
||||||
cache.push(
|
|
||||||
"b".into(),
|
|
||||||
vec![4.0, 5.0, 6.0],
|
|
||||||
"chan".into(),
|
|
||||||
1.0,
|
|
||||||
"s1".into(),
|
|
||||||
String::new(),
|
|
||||||
);
|
|
||||||
cache.update(
|
|
||||||
0,
|
|
||||||
"a2".into(),
|
|
||||||
vec![7.0, 8.0, 9.0],
|
|
||||||
"chan".into(),
|
|
||||||
2.0,
|
|
||||||
"s1".into(),
|
|
||||||
);
|
|
||||||
assert_flat_in_sync(&cache);
|
|
||||||
assert_eq!(
|
|
||||||
cache.embeddings.as_flat(),
|
|
||||||
vec![7.0, 8.0, 9.0, 4.0, 5.0, 6.0],
|
|
||||||
"update must overwrite the correct flat slice, not just append"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compact_keeps_flat_buffer_in_sync() {
|
|
||||||
let mut cache = MemoryCache::new(2);
|
|
||||||
cache.push(
|
|
||||||
"a".into(),
|
|
||||||
vec![1.0, 1.0],
|
|
||||||
"chan".into(),
|
|
||||||
0.0,
|
|
||||||
"s1".into(),
|
|
||||||
String::new(),
|
|
||||||
);
|
|
||||||
cache.push(
|
|
||||||
"b".into(),
|
|
||||||
vec![2.0, 2.0],
|
|
||||||
"chan".into(),
|
|
||||||
1.0,
|
|
||||||
"s1".into(),
|
|
||||||
String::new(),
|
|
||||||
);
|
|
||||||
cache.push(
|
|
||||||
"c".into(),
|
|
||||||
vec![3.0, 3.0],
|
|
||||||
"chan".into(),
|
|
||||||
2.0,
|
|
||||||
"s1".into(),
|
|
||||||
String::new(),
|
|
||||||
);
|
|
||||||
cache.mark_deleted(1);
|
|
||||||
cache.compact();
|
|
||||||
assert_flat_in_sync(&cache);
|
|
||||||
assert_eq!(cache.embeddings.as_flat(), vec![1.0, 1.0, 3.0, 3.0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rebuild_flat_matches_manual_flatten() {
|
|
||||||
let mut cache = MemoryCache::new(2);
|
|
||||||
cache
|
|
||||||
.embeddings
|
|
||||||
.reset_from(2, vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
|
|
||||||
assert_eq!(cache.embeddings.as_flat(), vec![1.0, 2.0, 3.0, 4.0]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,55 +16,6 @@ pub enum MemorySource {
|
|||||||
Correction,
|
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)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub enum MemoryTier {
|
pub enum MemoryTier {
|
||||||
Working,
|
Working,
|
||||||
@@ -167,7 +118,7 @@ impl ImportanceScorer {
|
|||||||
|
|
||||||
/// Novelty score: 1.0 − max cosine similarity against all existing records.
|
/// Novelty score: 1.0 − max cosine similarity against all existing records.
|
||||||
/// Returns 1.0 when there are no existing memories.
|
/// 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() {
|
if existing_memories.is_empty() {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
@@ -248,51 +199,21 @@ impl ConsolidationEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a new memory to the Working tier from an untrusted/ordinary origin
|
/// Add a new memory to the Working tier.
|
||||||
/// (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.
|
/// Importance is scored against existing Working-tier records only.
|
||||||
pub fn add_memory(
|
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,
|
&mut self,
|
||||||
chunk: String,
|
chunk: String,
|
||||||
embedding: Vec<f32>,
|
embedding: Vec<f32>,
|
||||||
source: MemorySource,
|
source: MemorySource,
|
||||||
now: f64,
|
now: f64,
|
||||||
) -> u64 {
|
) -> u64 {
|
||||||
let working: Vec<&MemoryRecord> = self
|
let working: Vec<MemoryRecord> = self
|
||||||
.records
|
.records
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| r.tier == MemoryTier::Working)
|
.filter(|r| r.tier == MemoryTier::Working)
|
||||||
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let surprise = ImportanceScorer::score_surprise(&embedding, &working);
|
let surprise = ImportanceScorer::score_surprise(&embedding, &working);
|
||||||
@@ -360,7 +281,7 @@ impl ConsolidationEngine {
|
|||||||
if working_count > capacity {
|
if working_count > capacity {
|
||||||
let evict_n = working_count - capacity;
|
let evict_n = working_count - capacity;
|
||||||
// Collect the ids of the records to evict (lowest decay = first in sorted list).
|
// Collect the ids of the records to evict (lowest decay = first in sorted list).
|
||||||
let evict_ids: std::collections::HashSet<u64> = working_indices[..evict_n]
|
let evict_ids: Vec<u64> = working_indices[..evict_n]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&i| self.records[i].id)
|
.map(|&i| self.records[i].id)
|
||||||
.collect();
|
.collect();
|
||||||
@@ -421,7 +342,7 @@ impl ConsolidationEngine {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let evict_n = episodic_count - episodic_capacity;
|
let evict_n = episodic_count - episodic_capacity;
|
||||||
let evict_ids: std::collections::HashSet<u64> = episodic_indices[..evict_n]
|
let evict_ids: Vec<u64> = episodic_indices[..evict_n]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&i| self.records[i].id)
|
.map(|&i| self.records[i].id)
|
||||||
.collect();
|
.collect();
|
||||||
@@ -498,44 +419,13 @@ mod tests {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 2. Add memory — basic
|
// 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]
|
#[test]
|
||||||
fn test_add_memory_basic() {
|
fn test_add_memory_basic() {
|
||||||
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
|
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
|
||||||
let id = engine.add_memory(
|
let id = engine.add_memory(
|
||||||
"Hello world".to_string(),
|
"Hello world".to_string(),
|
||||||
unit_vec(4, 0),
|
unit_vec(4, 0),
|
||||||
UntrustedSource::User,
|
MemorySource::User,
|
||||||
1_000_000.0,
|
1_000_000.0,
|
||||||
);
|
);
|
||||||
assert_eq!(id, 0);
|
assert_eq!(id, 0);
|
||||||
@@ -563,7 +453,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_importance_scorer_surprise_identical() {
|
fn test_importance_scorer_surprise_identical() {
|
||||||
let emb = unit_vec(4, 0);
|
let emb = unit_vec(4, 0);
|
||||||
let existing = [MemoryRecord {
|
let existing = vec![MemoryRecord {
|
||||||
id: 0,
|
id: 0,
|
||||||
chunk: "existing".to_string(),
|
chunk: "existing".to_string(),
|
||||||
embedding: emb.clone(),
|
embedding: emb.clone(),
|
||||||
@@ -574,8 +464,7 @@ mod tests {
|
|||||||
created_at: 0.0,
|
created_at: 0.0,
|
||||||
source: MemorySource::User,
|
source: MemorySource::User,
|
||||||
}];
|
}];
|
||||||
let existing_refs: Vec<&MemoryRecord> = existing.iter().collect();
|
let score = ImportanceScorer::score_surprise(&emb, &existing);
|
||||||
let score = ImportanceScorer::score_surprise(&emb, &existing_refs);
|
|
||||||
assert!(score < 0.01, "expected ~0.0, got {score}");
|
assert!(score < 0.01, "expected ~0.0, got {score}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,20 +492,23 @@ mod tests {
|
|||||||
fn test_importance_scorer_length() {
|
fn test_importance_scorer_length() {
|
||||||
assert!((ImportanceScorer::score_length("")).abs() < f32::EPSILON);
|
assert!((ImportanceScorer::score_length("")).abs() < f32::EPSILON);
|
||||||
// 50 words → 0.5
|
// 50 words → 0.5
|
||||||
let fifty_words = std::iter::repeat_n("word", 50)
|
let fifty_words = std::iter::repeat("word")
|
||||||
|
.take(50)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ");
|
.join(" ");
|
||||||
let s50 = ImportanceScorer::score_length(&fifty_words);
|
let s50 = ImportanceScorer::score_length(&fifty_words);
|
||||||
assert!((s50 - 0.5).abs() < 1e-5, "expected 0.5, got {s50}");
|
assert!((s50 - 0.5).abs() < 1e-5, "expected 0.5, got {s50}");
|
||||||
|
|
||||||
// 100 words → 1.0
|
// 100 words → 1.0
|
||||||
let hundred_words = std::iter::repeat_n("word", 100)
|
let hundred_words = std::iter::repeat("word")
|
||||||
|
.take(100)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ");
|
.join(" ");
|
||||||
assert_eq!(ImportanceScorer::score_length(&hundred_words), 1.0);
|
assert_eq!(ImportanceScorer::score_length(&hundred_words), 1.0);
|
||||||
|
|
||||||
// 200 words → still 1.0 (clamped)
|
// 200 words → still 1.0 (clamped)
|
||||||
let two_hundred = std::iter::repeat_n("word", 200)
|
let two_hundred = std::iter::repeat("word")
|
||||||
|
.take(200)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ");
|
.join(" ");
|
||||||
assert_eq!(ImportanceScorer::score_length(&two_hundred), 1.0);
|
assert_eq!(ImportanceScorer::score_length(&two_hundred), 1.0);
|
||||||
@@ -690,11 +582,9 @@ mod tests {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
#[test]
|
#[test]
|
||||||
fn test_consolidate_eviction_working() {
|
fn test_consolidate_eviction_working() {
|
||||||
let cfg = ConsolidationConfig {
|
let mut cfg = ConsolidationConfig::default();
|
||||||
working_capacity: 3,
|
cfg.working_capacity = 3;
|
||||||
working_to_episodic_threshold: 2.0, // never promote in this test
|
cfg.working_to_episodic_threshold = 2.0; // never promote in this test
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut engine = ConsolidationEngine::new(cfg);
|
let mut engine = ConsolidationEngine::new(cfg);
|
||||||
|
|
||||||
// Add 5 records; all have very low importance so none get promoted.
|
// Add 5 records; all have very low importance so none get promoted.
|
||||||
@@ -702,7 +592,7 @@ mod tests {
|
|||||||
let id = engine.add_memory(
|
let id = engine.add_memory(
|
||||||
"x".to_string(),
|
"x".to_string(),
|
||||||
unit_vec(4, i as usize),
|
unit_vec(4, i as usize),
|
||||||
UntrustedSource::User,
|
MemorySource::User,
|
||||||
i as f64,
|
i as f64,
|
||||||
);
|
);
|
||||||
// Force low importance so promotion threshold is not crossed.
|
// Force low importance so promotion threshold is not crossed.
|
||||||
@@ -735,10 +625,10 @@ mod tests {
|
|||||||
let cfg = ConsolidationConfig::default();
|
let cfg = ConsolidationConfig::default();
|
||||||
let mut engine = ConsolidationEngine::new(cfg);
|
let mut engine = ConsolidationEngine::new(cfg);
|
||||||
|
|
||||||
let id = engine.add_trusted_memory(
|
let id = engine.add_memory(
|
||||||
"important memory".to_string(),
|
"important memory".to_string(),
|
||||||
unit_vec(4, 0),
|
unit_vec(4, 0),
|
||||||
TrustedSource::Correction,
|
MemorySource::Correction,
|
||||||
0.0,
|
0.0,
|
||||||
);
|
);
|
||||||
// Force importance above threshold.
|
// Force importance above threshold.
|
||||||
@@ -771,7 +661,7 @@ mod tests {
|
|||||||
let id = engine.add_memory(
|
let id = engine.add_memory(
|
||||||
"frequently accessed".to_string(),
|
"frequently accessed".to_string(),
|
||||||
unit_vec(4, 0),
|
unit_vec(4, 0),
|
||||||
UntrustedSource::User,
|
MemorySource::User,
|
||||||
0.0,
|
0.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -799,12 +689,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_access_memory_reactivation() {
|
fn test_access_memory_reactivation() {
|
||||||
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
|
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
|
||||||
let id = engine.add_memory(
|
let id = engine.add_memory("chunk".to_string(), unit_vec(4, 0), MemorySource::User, 0.0);
|
||||||
"chunk".to_string(),
|
|
||||||
unit_vec(4, 0),
|
|
||||||
UntrustedSource::User,
|
|
||||||
0.0,
|
|
||||||
);
|
|
||||||
|
|
||||||
engine.access_memory(id, 5000.0);
|
engine.access_memory(id, 5000.0);
|
||||||
let rec = engine.get_by_id(id).unwrap();
|
let rec = engine.get_by_id(id).unwrap();
|
||||||
@@ -825,11 +710,11 @@ mod tests {
|
|||||||
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
|
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
|
||||||
|
|
||||||
// 2 Working
|
// 2 Working
|
||||||
engine.add_memory("w1".to_string(), unit_vec(4, 0), UntrustedSource::User, 0.0);
|
engine.add_memory("w1".to_string(), unit_vec(4, 0), MemorySource::User, 0.0);
|
||||||
engine.add_memory("w2".to_string(), unit_vec(4, 1), UntrustedSource::User, 0.0);
|
engine.add_memory("w2".to_string(), unit_vec(4, 1), MemorySource::User, 0.0);
|
||||||
|
|
||||||
// 1 Episodic (manually set)
|
// 1 Episodic (manually set)
|
||||||
let id_e = engine.add_memory("e1".to_string(), unit_vec(4, 2), UntrustedSource::User, 0.0);
|
let id_e = engine.add_memory("e1".to_string(), unit_vec(4, 2), MemorySource::User, 0.0);
|
||||||
engine
|
engine
|
||||||
.records
|
.records
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
@@ -838,7 +723,7 @@ mod tests {
|
|||||||
.tier = MemoryTier::Episodic;
|
.tier = MemoryTier::Episodic;
|
||||||
|
|
||||||
// 1 Semantic (manually set)
|
// 1 Semantic (manually set)
|
||||||
let id_s = engine.add_memory("s1".to_string(), unit_vec(4, 3), UntrustedSource::User, 0.0);
|
let id_s = engine.add_memory("s1".to_string(), unit_vec(4, 3), MemorySource::User, 0.0);
|
||||||
engine
|
engine
|
||||||
.records
|
.records
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
@@ -857,11 +742,9 @@ mod tests {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
#[test]
|
#[test]
|
||||||
fn test_consolidate_episodic_eviction() {
|
fn test_consolidate_episodic_eviction() {
|
||||||
let cfg = ConsolidationConfig {
|
let mut cfg = ConsolidationConfig::default();
|
||||||
episodic_capacity: 3,
|
cfg.episodic_capacity = 3;
|
||||||
working_to_episodic_threshold: 2.0, // never auto-promote from Working
|
cfg.working_to_episodic_threshold = 2.0; // never auto-promote from Working
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut engine = ConsolidationEngine::new(cfg);
|
let mut engine = ConsolidationEngine::new(cfg);
|
||||||
|
|
||||||
// Seed 5 records directly in Episodic.
|
// Seed 5 records directly in Episodic.
|
||||||
@@ -869,7 +752,7 @@ mod tests {
|
|||||||
let id = engine.add_memory(
|
let id = engine.add_memory(
|
||||||
"episodic chunk".to_string(),
|
"episodic chunk".to_string(),
|
||||||
unit_vec(4, i as usize),
|
unit_vec(4, i as usize),
|
||||||
UntrustedSource::User,
|
MemorySource::User,
|
||||||
i as f64,
|
i as f64,
|
||||||
);
|
);
|
||||||
let rec = engine.records.iter_mut().find(|r| r.id == id).unwrap();
|
let rec = engine.records.iter_mut().find(|r| r.id == id).unwrap();
|
||||||
|
|||||||
@@ -777,10 +777,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tech_disabled() {
|
fn test_tech_disabled() {
|
||||||
let config = ExtractorConfig {
|
let mut config = ExtractorConfig::default();
|
||||||
extract_technology: false,
|
config.extract_technology = false;
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let e = EntityExtractor::new(config);
|
let e = EntityExtractor::new(config);
|
||||||
let entities = e.extract("We use Rust and Docker.");
|
let entities = e.extract("We use Rust and Docker.");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -849,10 +847,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_date_disabled() {
|
fn test_date_disabled() {
|
||||||
let config = ExtractorConfig {
|
let mut config = ExtractorConfig::default();
|
||||||
extract_dates: false,
|
config.extract_dates = false;
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let e = EntityExtractor::new(config);
|
let e = EntityExtractor::new(config);
|
||||||
let entities = e.extract("Released on 2024-03-19.");
|
let entities = e.extract("Released on 2024-03-19.");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -985,10 +981,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_confidence_filter() {
|
fn test_confidence_filter() {
|
||||||
let config = ExtractorConfig {
|
let mut config = ExtractorConfig::default();
|
||||||
min_confidence: 0.95,
|
config.min_confidence = 0.95;
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let e = EntityExtractor::new(config);
|
let e = EntityExtractor::new(config);
|
||||||
// Only dates (0.95) and techs (0.9) should survive; 0.9 < 0.95 filters techs.
|
// 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.");
|
let entities = e.extract("We use Rust since 2024-01-01.");
|
||||||
@@ -1008,7 +1002,7 @@ mod tests {
|
|||||||
fn test_batch_dedup() {
|
fn test_batch_dedup() {
|
||||||
let e = default_extractor();
|
let e = default_extractor();
|
||||||
let texts = ["We use Rust.", "Rust is fast.", "Also Rust for safety."];
|
let texts = ["We use Rust.", "Rust is fast.", "Also Rust for safety."];
|
||||||
let entities = e.extract_batch(&texts);
|
let entities = e.extract_batch(&texts.iter().map(|s| *s).collect::<Vec<_>>());
|
||||||
let rust_count = entities.iter().filter(|x| x.text == "Rust").count();
|
let rust_count = entities.iter().filter(|x| x.text == "Rust").count();
|
||||||
assert_eq!(rust_count, 1, "Rust should appear exactly once after dedup");
|
assert_eq!(rust_count, 1, "Rust should appear exactly once after dedup");
|
||||||
}
|
}
|
||||||
@@ -1017,7 +1011,7 @@ mod tests {
|
|||||||
fn test_batch_multiple_types() {
|
fn test_batch_multiple_types() {
|
||||||
let e = default_extractor();
|
let e = default_extractor();
|
||||||
let texts = ["Deploy with Docker.", "We merged last week."];
|
let texts = ["Deploy with Docker.", "We merged last week."];
|
||||||
let entities = e.extract_batch(&texts);
|
let entities = e.extract_batch(&texts.iter().map(|s| *s).collect::<Vec<_>>());
|
||||||
assert!(
|
assert!(
|
||||||
entities
|
entities
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -28,40 +28,13 @@ use crate::vector_search;
|
|||||||
pub fn hybrid_search(
|
pub fn hybrid_search(
|
||||||
query_embedding: &[f32],
|
query_embedding: &[f32],
|
||||||
query_text: &str,
|
query_text: &str,
|
||||||
vectors: &(impl crate::vector_search::VectorSet + Sync + ?Sized),
|
vectors: &[Vec<f32>],
|
||||||
chunks: &[String],
|
_chunks: &[String],
|
||||||
tombstones: &[u8],
|
tombstones: &[u8],
|
||||||
bm25_index: &BM25Index,
|
bm25_index: &BM25Index,
|
||||||
vector_weight: f32,
|
vector_weight: f32,
|
||||||
keyword_weight: f32,
|
keyword_weight: f32,
|
||||||
k: usize,
|
k: usize,
|
||||||
) -> Vec<(usize, f32)> {
|
|
||||||
hybrid_search_fused(
|
|
||||||
query_embedding,
|
|
||||||
query_text,
|
|
||||||
vectors,
|
|
||||||
chunks,
|
|
||||||
tombstones,
|
|
||||||
bm25_index,
|
|
||||||
Fusion::Weighted {
|
|
||||||
vector: vector_weight,
|
|
||||||
keyword: keyword_weight,
|
|
||||||
},
|
|
||||||
k,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`hybrid_search`] with the fusion method chosen explicitly.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn hybrid_search_fused(
|
|
||||||
query_embedding: &[f32],
|
|
||||||
query_text: &str,
|
|
||||||
vectors: &(impl crate::vector_search::VectorSet + Sync + ?Sized),
|
|
||||||
_chunks: &[String],
|
|
||||||
tombstones: &[u8],
|
|
||||||
bm25_index: &BM25Index,
|
|
||||||
fusion: Fusion,
|
|
||||||
k: usize,
|
|
||||||
) -> Vec<(usize, f32)> {
|
) -> Vec<(usize, f32)> {
|
||||||
// Get raw scores from both systems. Request all results so normalization
|
// Get raw scores from both systems. Request all results so normalization
|
||||||
// covers the full distribution.
|
// covers the full distribution.
|
||||||
@@ -69,12 +42,12 @@ pub fn hybrid_search_fused(
|
|||||||
let vec_scores = {
|
let vec_scores = {
|
||||||
#[cfg(feature = "parallel")]
|
#[cfg(feature = "parallel")]
|
||||||
{
|
{
|
||||||
if vectors.count() > 10_000 {
|
if vectors.len() > 10_000 {
|
||||||
vector_search::parallel_cosine_batch(
|
vector_search::parallel_cosine_batch(
|
||||||
query_embedding,
|
query_embedding,
|
||||||
vectors,
|
vectors,
|
||||||
tombstones,
|
tombstones,
|
||||||
vectors.count(),
|
vectors.len(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
|
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
|
||||||
@@ -85,9 +58,9 @@ pub fn hybrid_search_fused(
|
|||||||
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
|
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let kw_scores = bm25_index.scores(query_text);
|
let kw_scores = bm25_index.search(query_text, vectors.len());
|
||||||
|
|
||||||
fuse(vec_scores, kw_scores, fusion, k)
|
merge_vector_keyword(vec_scores, kw_scores, vector_weight, keyword_weight, k)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge pre-computed vector-similarity and keyword scores into a single ranking.
|
/// Merge pre-computed vector-similarity and keyword scores into a single ranking.
|
||||||
@@ -103,120 +76,29 @@ pub fn merge_vector_keyword(
|
|||||||
keyword_weight: f32,
|
keyword_weight: f32,
|
||||||
k: usize,
|
k: usize,
|
||||||
) -> Vec<(usize, f32)> {
|
) -> Vec<(usize, f32)> {
|
||||||
fuse(
|
|
||||||
vec_scores,
|
|
||||||
kw_scores,
|
|
||||||
Fusion::Weighted {
|
|
||||||
vector: vector_weight,
|
|
||||||
keyword: keyword_weight,
|
|
||||||
},
|
|
||||||
k,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How the vector and keyword stages are combined into one ranking.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub enum Fusion {
|
|
||||||
/// Min-max normalise each stage over its own candidates, then take a
|
|
||||||
/// weighted sum. Uses the *scores*, so a stage that separates its
|
|
||||||
/// candidates sharply keeps that separation — and a stage whose candidates
|
|
||||||
/// are all near-identical contributes little.
|
|
||||||
Weighted {
|
|
||||||
/// Weight on the vector stage.
|
|
||||||
vector: f32,
|
|
||||||
/// Weight on the keyword stage.
|
|
||||||
keyword: f32,
|
|
||||||
},
|
|
||||||
/// Reciprocal rank fusion: each stage contributes `1 / (k + rank)`,
|
|
||||||
/// ignoring score magnitudes entirely. Robust when the two stages'
|
|
||||||
/// scores aren't comparable, at the cost of discarding confidence.
|
|
||||||
Rrf {
|
|
||||||
/// The rank-damping constant; 60 is the value from the original paper.
|
|
||||||
k: f32,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Fusion {
|
|
||||||
fn default() -> Self {
|
|
||||||
DEFAULT_FUSION
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The fusion `hybrid_search` uses unless told otherwise.
|
|
||||||
///
|
|
||||||
/// The weights are not a guess: a sweep of every 0.1 step over the full
|
|
||||||
/// LongMemEval haystack (500 questions, real MiniLM embeddings) found the
|
|
||||||
/// long-standing 0.7/0.3 default *strictly dominated* — 0.4/0.6 is better at
|
|
||||||
/// Hit@1, Hit@5, Hit@10 and MRR, at both turn and session granularity. See
|
|
||||||
/// `BENCHMARKS.md`, "Weight sweep".
|
|
||||||
pub const DEFAULT_FUSION: Fusion = Fusion::Weighted {
|
|
||||||
vector: 0.4,
|
|
||||||
keyword: 0.6,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Combine one ranked candidate list from each stage into a single top-`k`.
|
|
||||||
///
|
|
||||||
/// Neither list need be sorted; both are consumed.
|
|
||||||
pub fn fuse(
|
|
||||||
vec_scores: Vec<(usize, f32)>,
|
|
||||||
kw_scores: Vec<(usize, f32)>,
|
|
||||||
fusion: Fusion,
|
|
||||||
k: usize,
|
|
||||||
) -> Vec<(usize, f32)> {
|
|
||||||
let mut merged: HashMap<usize, f32> = HashMap::new();
|
|
||||||
match fusion {
|
|
||||||
Fusion::Weighted { vector, keyword } => {
|
|
||||||
// Normalize each set to [0, 1].
|
// Normalize each set to [0, 1].
|
||||||
for (idx, score) in &normalize_scores(&vec_scores) {
|
let vec_normalized = normalize_scores(&vec_scores);
|
||||||
*merged.entry(*idx).or_insert(0.0) += vector * score;
|
let kw_normalized = normalize_scores(&kw_scores);
|
||||||
}
|
|
||||||
for (idx, score) in &normalize_scores(&kw_scores) {
|
// Merge scores with weights.
|
||||||
*merged.entry(*idx).or_insert(0.0) += keyword * score;
|
let mut merged: HashMap<usize, f32> = HashMap::new();
|
||||||
}
|
|
||||||
}
|
for (idx, score) in &vec_normalized {
|
||||||
Fusion::Rrf { k: damping } => {
|
*merged.entry(*idx).or_insert(0.0) += vector_weight * score;
|
||||||
for mut stage in [vec_scores, kw_scores] {
|
|
||||||
// Rank 1 is the best score. Ties break by index so a stage's
|
|
||||||
// contribution doesn't depend on the candidate order it
|
|
||||||
// happened to be produced in.
|
|
||||||
stage.sort_by(|a, b| {
|
|
||||||
b.1.partial_cmp(&a.1)
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
.then(a.0.cmp(&b.0))
|
|
||||||
});
|
|
||||||
for (rank, (idx, _)) in stage.iter().enumerate() {
|
|
||||||
*merged.entry(*idx).or_insert(0.0) += 1.0 / (damping + (rank + 1) as f32);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
for (idx, score) in &kw_normalized {
|
||||||
|
*merged.entry(*idx).or_insert(0.0) += keyword_weight * score;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut results: Vec<(usize, f32)> = merged.into_iter().collect();
|
let mut results: Vec<(usize, f32)> = merged.into_iter().collect();
|
||||||
// Index tie-break: `merged` is a HashMap, so without it the ties that
|
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
// survive differ from run to run.
|
|
||||||
let by_score_then_id = |a: &(usize, f32), b: &(usize, f32)| {
|
|
||||||
b.1.partial_cmp(&a.1)
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
.then(a.0.cmp(&b.0))
|
|
||||||
};
|
|
||||||
// Only the top k are wanted: partition them out, then order just those,
|
|
||||||
// instead of sorting every candidate (the keyword side can be the corpus).
|
|
||||||
if k == 0 {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
if results.len() > k {
|
|
||||||
results.select_nth_unstable_by(k - 1, by_score_then_id);
|
|
||||||
results.truncate(k);
|
results.truncate(k);
|
||||||
}
|
|
||||||
results.sort_by(by_score_then_id);
|
|
||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalize a set of scores to the [0, 1] range using min-max normalization.
|
/// Normalize a set of scores to the [0, 1] range using min-max normalization.
|
||||||
///
|
///
|
||||||
/// If all scores are identical there is no spread to normalise: each entry
|
/// If all scores are identical, returns 0.0 for 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)> {
|
fn normalize_scores(scores: &[(usize, f32)]) -> Vec<(usize, f32)> {
|
||||||
if scores.is_empty() {
|
if scores.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -230,13 +112,7 @@ fn normalize_scores(scores: &[(usize, f32)]) -> Vec<(usize, f32)> {
|
|||||||
|
|
||||||
let range = max - min;
|
let range = max - min;
|
||||||
if range == 0.0 {
|
if range == 0.0 {
|
||||||
// All candidates scored the same (including the single-candidate
|
return scores.iter().map(|(idx, _)| (*idx, 0.0)).collect();
|
||||||
// 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
|
scores
|
||||||
@@ -270,7 +146,7 @@ fn normalize_scores(scores: &[(usize, f32)]) -> Vec<(usize, f32)> {
|
|||||||
pub fn rrf_hybrid_search(
|
pub fn rrf_hybrid_search(
|
||||||
query_embedding: &[f32],
|
query_embedding: &[f32],
|
||||||
query_text: &str,
|
query_text: &str,
|
||||||
vectors: &(impl crate::vector_search::VectorSet + Sync + ?Sized),
|
vectors: &[Vec<f32>],
|
||||||
_chunks: &[String],
|
_chunks: &[String],
|
||||||
tombstones: &[u8],
|
tombstones: &[u8],
|
||||||
bm25_index: &BM25Index,
|
bm25_index: &BM25Index,
|
||||||
@@ -282,12 +158,12 @@ pub fn rrf_hybrid_search(
|
|||||||
let mut vec_scores = {
|
let mut vec_scores = {
|
||||||
#[cfg(feature = "parallel")]
|
#[cfg(feature = "parallel")]
|
||||||
{
|
{
|
||||||
if vectors.count() > 10_000 {
|
if vectors.len() > 10_000 {
|
||||||
vector_search::parallel_cosine_batch(
|
vector_search::parallel_cosine_batch(
|
||||||
query_embedding,
|
query_embedding,
|
||||||
vectors,
|
vectors,
|
||||||
tombstones,
|
tombstones,
|
||||||
vectors.count(),
|
vectors.len(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
|
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
|
||||||
@@ -298,7 +174,7 @@ pub fn rrf_hybrid_search(
|
|||||||
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
|
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut kw_scores = bm25_index.search(query_text, vectors.count());
|
let mut kw_scores = bm25_index.search(query_text, vectors.len());
|
||||||
|
|
||||||
// Sort both lists descending so rank 1 = best.
|
// Sort both lists descending so rank 1 = best.
|
||||||
vec_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
vec_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
@@ -448,80 +324,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalize_scores_single() {
|
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)]);
|
let result = normalize_scores(&[(0, 5.0)]);
|
||||||
assert_eq!(result.len(), 1);
|
assert_eq!(result.len(), 1);
|
||||||
assert_eq!(result[0].1, 1.0);
|
// Single score normalizes to 0.0 (range is 0)
|
||||||
}
|
assert_eq!(result[0].1, 0.0);
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_fusion_is_the_tuned_operating_point() {
|
|
||||||
// A sweep over the full LongMemEval haystack found 0.7/0.3 strictly
|
|
||||||
// dominated by 0.4/0.6 (BENCHMARKS.md). This guards the finding
|
|
||||||
// against being quietly undone.
|
|
||||||
assert_eq!(
|
|
||||||
DEFAULT_FUSION,
|
|
||||||
Fusion::Weighted {
|
|
||||||
vector: 0.4,
|
|
||||||
keyword: 0.6
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rrf_rewards_agreement_between_the_stages_and_ignores_magnitudes() {
|
|
||||||
// Doc 1 is second-best in both stages; doc 0 is best in one and absent
|
|
||||||
// from the other. RRF prefers the doc both stages liked.
|
|
||||||
let vec_scores = vec![(0, 100.0), (1, 0.9)];
|
|
||||||
let kw_scores = vec![(2, 5.0), (1, 4.9)];
|
|
||||||
let ranked = fuse(vec_scores, kw_scores, Fusion::Rrf { k: 60.0 }, 3);
|
|
||||||
assert_eq!(ranked[0].0, 1, "{ranked:?}");
|
|
||||||
|
|
||||||
// Scaling one stage's scores cannot change an RRF ranking, only the
|
|
||||||
// order within that stage can.
|
|
||||||
let a = fuse(
|
|
||||||
vec![(0, 1.0), (1, 0.5)],
|
|
||||||
vec![(1, 2.0), (0, 1.0)],
|
|
||||||
Fusion::Rrf { k: 60.0 },
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
let b = fuse(
|
|
||||||
vec![(0, 1e6), (1, -3.0)],
|
|
||||||
vec![(1, 0.002), (0, 0.001)],
|
|
||||||
Fusion::Rrf { k: 60.0 },
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
a.iter().map(|r| r.0).collect::<Vec<_>>(),
|
|
||||||
b.iter().map(|r| r.0).collect::<Vec<_>>()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_top_k_matches_a_full_sort() {
|
|
||||||
// Many ties (scores repeat) so the index tie-break is exercised.
|
|
||||||
let vec_scores: Vec<(usize, f32)> = (0..300).map(|i| (i, ((i * 7) % 13) as f32)).collect();
|
|
||||||
let kw_scores: Vec<(usize, f32)> = (100..500).map(|i| (i, ((i * 5) % 11) as f32)).collect();
|
|
||||||
let everything =
|
|
||||||
merge_vector_keyword(vec_scores.clone(), kw_scores.clone(), 0.7, 0.3, 10_000);
|
|
||||||
assert_eq!(everything.len(), 500);
|
|
||||||
assert!(
|
|
||||||
everything
|
|
||||||
.windows(2)
|
|
||||||
.all(|w| { w[0].1 > w[1].1 || (w[0].1 == w[1].1 && w[0].0 < w[1].0) })
|
|
||||||
);
|
|
||||||
for k in [0, 1, 7, 50, 499, 500, 501] {
|
|
||||||
let top = merge_vector_keyword(vec_scores.clone(), kw_scores.clone(), 0.7, 0.3, k);
|
|
||||||
assert_eq!(top, everything[..k.min(500)], "k = {k}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_scores_all_equal() {
|
|
||||||
let matched = normalize_scores(&[(0, 0.4), (1, 0.4)]);
|
|
||||||
assert!(matched.iter().all(|(_, s)| *s == 1.0));
|
|
||||||
let unmatched = normalize_scores(&[(0, 0.0), (1, 0.0)]);
|
|
||||||
assert!(unmatched.iter().all(|(_, s)| *s == 0.0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -50,9 +50,6 @@ impl RelationType {
|
|||||||
pub struct Entity {
|
pub struct Entity {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub name: String,
|
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,
|
pub entity_type: String,
|
||||||
/// Index into the memory embeddings array, or -1 if none.
|
/// Index into the memory embeddings array, or -1 if none.
|
||||||
pub embedding_idx: i64,
|
pub embedding_idx: i64,
|
||||||
@@ -72,7 +69,6 @@ impl Default for Entity {
|
|||||||
Self {
|
Self {
|
||||||
id: 0,
|
id: 0,
|
||||||
name: String::new(),
|
name: String::new(),
|
||||||
name_lower: String::new(),
|
|
||||||
entity_type: String::new(),
|
entity_type: String::new(),
|
||||||
embedding_idx: -1,
|
embedding_idx: -1,
|
||||||
properties: HashMap::new(),
|
properties: HashMap::new(),
|
||||||
@@ -155,55 +151,6 @@ fn levenshtein(a: &str, b: &str) -> usize {
|
|||||||
prev[nb]
|
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
|
// KnowledgeCache
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -251,7 +198,6 @@ impl KnowledgeCache {
|
|||||||
self.entities.push(Entity {
|
self.entities.push(Entity {
|
||||||
id,
|
id,
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
name_lower: name.to_lowercase(),
|
|
||||||
entity_type: entity_type.to_owned(),
|
entity_type: entity_type.to_owned(),
|
||||||
embedding_idx,
|
embedding_idx,
|
||||||
properties: HashMap::new(),
|
properties: HashMap::new(),
|
||||||
@@ -364,22 +310,16 @@ impl KnowledgeCache {
|
|||||||
) -> (u64, bool) {
|
) -> (u64, bool) {
|
||||||
let lower_name = name.to_lowercase();
|
let lower_name = name.to_lowercase();
|
||||||
|
|
||||||
// Search for the closest existing entity, short-circuiting on an
|
// Search for the closest existing entity.
|
||||||
// exact match since no closer candidate can exist.
|
let best = self
|
||||||
let mut best: Option<(u64, usize)> = None;
|
.entities
|
||||||
for e in &self.entities {
|
.iter()
|
||||||
let dist = levenshtein(&lower_name, &e.name_lower);
|
.map(|e| {
|
||||||
if dist > max_distance {
|
let dist = levenshtein(&lower_name, &e.name.to_lowercase());
|
||||||
continue;
|
(e.id, dist)
|
||||||
}
|
})
|
||||||
if dist == 0 {
|
.filter(|&(_, dist)| dist <= max_distance)
|
||||||
best = Some((e.id, dist));
|
.min_by_key(|&(_, dist)| dist);
|
||||||
break;
|
|
||||||
}
|
|
||||||
if best.is_none_or(|(_, best_dist)| dist < best_dist) {
|
|
||||||
best = Some((e.id, dist));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((id, _)) = best {
|
if let Some((id, _)) = best {
|
||||||
return (id, false);
|
return (id, false);
|
||||||
@@ -389,6 +329,47 @@ impl KnowledgeCache {
|
|||||||
(id, true)
|
(id, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Adjacency index (built fresh per traversal call — see doc comment)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Build an O(V+R) adjacency index for one traversal call: an entity-id →
|
||||||
|
/// vec-index map for O(1) entity lookups, and an entity-id →
|
||||||
|
/// `(neighbour_id, relation_weight)` map (covering both outgoing and
|
||||||
|
/// incoming edges) for O(1) neighbour expansion. The weight is carried
|
||||||
|
/// alongside each neighbour so callers like `spreading_activation` that
|
||||||
|
/// need per-edge weight don't have to re-scan `relations`.
|
||||||
|
///
|
||||||
|
/// This is rebuilt at the start of every `bfs_neighbors`/
|
||||||
|
/// `spreading_activation` call rather than cached on the struct: `entities`
|
||||||
|
/// and `relations` are public fields, and `schema.rs`'s deserialization
|
||||||
|
/// path pushes into them directly (bypassing `add_entity`/`add_relation`),
|
||||||
|
/// so a struct-cached index could go stale. Building it once per call
|
||||||
|
/// still turns an O(V·R) (or O(steps·V·R)) traversal into O(V+R) (or
|
||||||
|
/// O(steps·(V+E))), since the old code repeated the O(R) relation scan
|
||||||
|
/// once per visited node instead of once per call.
|
||||||
|
fn build_adjacency(&self) -> (HashMap<u64, usize>, HashMap<u64, Vec<(u64, f32)>>) {
|
||||||
|
let mut entity_index: HashMap<u64, usize> = HashMap::with_capacity(self.entities.len());
|
||||||
|
for (i, e) in self.entities.iter().enumerate() {
|
||||||
|
entity_index.insert(e.id, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: a self-loop relation (src == tgt) contributes a single
|
||||||
|
// neighbour entry, not two, matching the if/else-if (not two
|
||||||
|
// independent ifs) structure this replaces — otherwise a self-loop
|
||||||
|
// would be double-counted by `spreading_activation`.
|
||||||
|
let mut adjacency: HashMap<u64, Vec<(u64, f32)>> =
|
||||||
|
HashMap::with_capacity(self.relations.len());
|
||||||
|
for r in &self.relations {
|
||||||
|
adjacency.entry(r.src).or_default().push((r.tgt, r.weight));
|
||||||
|
if r.tgt != r.src {
|
||||||
|
adjacency.entry(r.tgt).or_default().push((r.src, r.weight));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(entity_index, adjacency)
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Graph traversal: BFS neighbors
|
// Graph traversal: BFS neighbors
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -397,7 +378,8 @@ impl KnowledgeCache {
|
|||||||
/// together with their discovered depth. The seed entity itself is NOT
|
/// together with their discovered depth. The seed entity itself is NOT
|
||||||
/// included. Traversal follows both outgoing and incoming relation edges.
|
/// included. Traversal follows both outgoing and incoming relation edges.
|
||||||
pub fn bfs_neighbors(&self, entity_id: u64, max_depth: usize) -> Vec<(Entity, usize)> {
|
pub fn bfs_neighbors(&self, entity_id: u64, max_depth: usize) -> Vec<(Entity, usize)> {
|
||||||
let idx = AdjacencyIndex::build(&self.entities, &self.relations);
|
let (entity_index, adjacency) = self.build_adjacency();
|
||||||
|
|
||||||
let mut visited: HashSet<u64> = HashSet::new();
|
let mut visited: HashSet<u64> = HashSet::new();
|
||||||
let mut queue: VecDeque<(u64, usize)> = VecDeque::new();
|
let mut queue: VecDeque<(u64, usize)> = VecDeque::new();
|
||||||
let mut results: Vec<(Entity, usize)> = Vec::new();
|
let mut results: Vec<(Entity, usize)> = Vec::new();
|
||||||
@@ -410,28 +392,16 @@ impl KnowledgeCache {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect neighbour IDs from outgoing and incoming edges touching
|
let Some(neighbours) = adjacency.get(¤t_id) else {
|
||||||
// this node only, instead of scanning every relation in the graph.
|
continue;
|
||||||
let neighbours: Vec<u64> = idx
|
};
|
||||||
.relations_touching(current_id)
|
|
||||||
.iter()
|
|
||||||
.filter_map(|&i| {
|
|
||||||
let r = &self.relations[i];
|
|
||||||
if r.src == current_id {
|
|
||||||
Some(r.tgt)
|
|
||||||
} else if r.tgt == current_id {
|
|
||||||
Some(r.src)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for neighbour_id in neighbours {
|
for &(neighbour_id, _weight) in neighbours {
|
||||||
if visited.insert(neighbour_id)
|
if visited.insert(neighbour_id)
|
||||||
&& let Some(&entity_idx) = idx.entity_index.get(&neighbour_id)
|
&& let Some(&idx) = entity_index.get(&neighbour_id)
|
||||||
{
|
{
|
||||||
results.push((self.entities[entity_idx].clone(), depth + 1));
|
let entity = &self.entities[idx];
|
||||||
|
results.push((entity.clone(), depth + 1));
|
||||||
queue.push_back((neighbour_id, depth + 1));
|
queue.push_back((neighbour_id, depth + 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,7 +472,8 @@ impl KnowledgeCache {
|
|||||||
min_activation: f32,
|
min_activation: f32,
|
||||||
max_steps: usize,
|
max_steps: usize,
|
||||||
) -> Vec<(u64, f32)> {
|
) -> Vec<(u64, f32)> {
|
||||||
let idx = AdjacencyIndex::build(&self.entities, &self.relations);
|
let (_entity_index, adjacency) = self.build_adjacency();
|
||||||
|
|
||||||
let mut activation: HashMap<u64, f32> = HashMap::new();
|
let mut activation: HashMap<u64, f32> = HashMap::new();
|
||||||
|
|
||||||
// Initialise seeds with activation 1.0.
|
// Initialise seeds with activation 1.0.
|
||||||
@@ -525,19 +496,12 @@ impl KnowledgeCache {
|
|||||||
let mut any_spread = false;
|
let mut any_spread = false;
|
||||||
|
|
||||||
for (source_id, source_score) in current {
|
for (source_id, source_score) in current {
|
||||||
// Spread only to edges touching this node, instead of
|
// Spread to all neighbours via outgoing and incoming edges.
|
||||||
// scanning every relation in the graph per active node.
|
let Some(neighbours) = adjacency.get(&source_id) else {
|
||||||
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 {
|
|
||||||
rel.src
|
|
||||||
} else {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
for &(neighbour_id, weight) in neighbours {
|
||||||
let delta = source_score * rel.weight * decay_factor;
|
let delta = source_score * weight * decay_factor;
|
||||||
if delta >= min_activation {
|
if delta >= min_activation {
|
||||||
*activation.entry(neighbour_id).or_insert(0.0) += delta;
|
*activation.entry(neighbour_id).or_insert(0.0) += delta;
|
||||||
any_spread = true;
|
any_spread = true;
|
||||||
@@ -921,19 +885,6 @@ mod tests {
|
|||||||
assert_eq!(id, orig_id);
|
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]
|
#[test]
|
||||||
fn test_resolve_or_create_no_match_beyond_threshold() {
|
fn test_resolve_or_create_no_match_beyond_threshold() {
|
||||||
let mut cache = KnowledgeCache::new();
|
let mut cache = KnowledgeCache::new();
|
||||||
@@ -1114,30 +1065,6 @@ mod tests {
|
|||||||
assert!(b_score.unwrap() > 0.0);
|
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]
|
#[test]
|
||||||
fn test_spreading_activation_decay_reduces_signal() {
|
fn test_spreading_activation_decay_reduces_signal() {
|
||||||
let mut cache = KnowledgeCache::new();
|
let mut cache = KnowledgeCache::new();
|
||||||
|
|||||||
+217
-1260
File diff suppressed because it is too large
Load Diff
@@ -466,7 +466,7 @@ impl ClawhdfBackend {
|
|||||||
let record = MemoryRecord {
|
let record = MemoryRecord {
|
||||||
id: i as u64,
|
id: i as u64,
|
||||||
chunk: cache.chunks[i].clone(),
|
chunk: cache.chunks[i].clone(),
|
||||||
embedding: cache.embeddings[i].to_vec(),
|
embedding: cache.embeddings[i].clone(),
|
||||||
tier: MemoryTier::Working,
|
tier: MemoryTier::Working,
|
||||||
importance: cache.activation_weights[i],
|
importance: cache.activation_weights[i],
|
||||||
access_count: 0,
|
access_count: 0,
|
||||||
@@ -531,14 +531,11 @@ impl MemoryBackend for ClawhdfBackend {
|
|||||||
query_embedding: &[f32],
|
query_embedding: &[f32],
|
||||||
k: usize,
|
k: usize,
|
||||||
) -> Vec<MemorySearchResult> {
|
) -> Vec<MemorySearchResult> {
|
||||||
// 1. Hybrid retrieval (vector + BM25, fused by score).
|
// 1. Hybrid retrieval (RRF-blended vector + BM25).
|
||||||
let candidates = k.saturating_mul(3).max(10);
|
let candidates = k.saturating_mul(3).max(10);
|
||||||
let raw = self.memory.hybrid_search_with(
|
let raw = self
|
||||||
query_embedding,
|
.memory
|
||||||
query_text,
|
.hybrid_search(query_embedding, query_text, 0.7, 0.3, candidates);
|
||||||
crate::hybrid::DEFAULT_FUSION,
|
|
||||||
candidates,
|
|
||||||
);
|
|
||||||
|
|
||||||
if raw.is_empty() {
|
if raw.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -554,7 +551,6 @@ impl MemoryBackend for ClawhdfBackend {
|
|||||||
timestamp: r.timestamp,
|
timestamp: r.timestamp,
|
||||||
source_channel: r.source_channel.clone(),
|
source_channel: r.source_channel.clone(),
|
||||||
raw_activation: r.activation,
|
raw_activation: r.activation,
|
||||||
relevance: r.score,
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -717,13 +713,11 @@ impl MemoryBackend for ClawhdfBackend {
|
|||||||
|
|
||||||
let total_records = cache.count_active();
|
let total_records = cache.count_active();
|
||||||
|
|
||||||
// A record saved without an embedding occupies a zero row, so "has an
|
|
||||||
// embedding" is "has a non-zero norm" rather than "row is non-empty".
|
|
||||||
let total_embeddings = cache
|
let total_embeddings = cache
|
||||||
.norms
|
.embeddings
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(i, norm)| cache.tombstones[*i] == 0 && **norm > 0.0)
|
.filter(|(i, emb)| cache.tombstones[*i] == 0 && !emb.is_empty())
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
let file_size_bytes = std::fs::metadata(&self.hdf5_path)
|
let file_size_bytes = std::fs::metadata(&self.hdf5_path)
|
||||||
@@ -754,69 +748,6 @@ 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
|
// Tests
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -1402,3 +1333,66 @@ mod tests {
|
|||||||
assert!(out.starts_with("# Title"));
|
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,31 +1,31 @@
|
|||||||
//! Memory provenance tracking and integrity verification.
|
//! Memory provenance tracking and integrity verification.
|
||||||
//!
|
//!
|
||||||
//! Records the origin, authorship, and a content hash of every memory chunk
|
//! Records the origin, authorship, and a content hash of every memory chunk
|
||||||
//! so the system can detect *accidental* corruption and trace data lineage.
|
//! so the system can detect content corruption and trace data lineage. The
|
||||||
//! The hash is unkeyed (see [`fnv1a_64`]) — this is not a tamper-evidence or
|
//! hash is a SHA-256 digest (see [`hash_content`]), computed via
|
||||||
//! authenticity guarantee.
|
//! [`clawhdf5_format::provenance::sha256_hex`]. It is still **unkeyed** — an
|
||||||
|
//! actor able to overwrite the stored chunk can also recompute and overwrite
|
||||||
|
//! the stored hash alongside it, so this is not an authenticity guarantee
|
||||||
|
//! against that threat. What SHA-256 does provide over a fast non-cryptographic
|
||||||
|
//! hash (the previous FNV-1a implementation) is collision resistance: an
|
||||||
|
//! adversary cannot cheaply craft *different* poisoned content that matches
|
||||||
|
//! an already-recorded legitimate hash.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub use crate::consolidation::MemorySource;
|
pub use crate::consolidation::MemorySource;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Hash helper (std-only FNV-1a 64-bit)
|
// Hash helper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Unkeyed, non-cryptographic FNV-1a hash for detecting accidental content
|
/// SHA-256 hex digest of `text`, used to detect content corruption/tampering.
|
||||||
/// corruption. It is trivially forgeable by anyone able to modify the stored
|
///
|
||||||
/// data, since they can recompute and overwrite the stored hash alongside
|
/// Unkeyed: an actor able to modify the stored chunk can also recompute and
|
||||||
/// it — do not rely on this as a tamper-evidence or authenticity control.
|
/// overwrite the stored hash, so a match is not proof of authenticity — only
|
||||||
fn fnv1a_64(text: &str) -> u64 {
|
/// that the stored chunk and stored hash are mutually consistent.
|
||||||
const OFFSET: u64 = 14_695_981_039_346_656_037;
|
fn hash_content(text: &str) -> String {
|
||||||
const PRIME: u64 = 1_099_511_628_211;
|
clawhdf5_format::provenance::sha256_hex(text.as_bytes())
|
||||||
let mut hash = OFFSET;
|
|
||||||
for byte in text.bytes() {
|
|
||||||
hash ^= byte as u64;
|
|
||||||
hash = hash.wrapping_mul(PRIME);
|
|
||||||
}
|
|
||||||
hash
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -57,8 +57,8 @@ pub struct MemoryProvenance {
|
|||||||
pub created_by: String,
|
pub created_by: String,
|
||||||
/// Unix timestamp (seconds) of creation.
|
/// Unix timestamp (seconds) of creation.
|
||||||
pub created_at: f64,
|
pub created_at: f64,
|
||||||
/// FNV-1a 64-bit hash of the chunk text for integrity checking.
|
/// SHA-256 hex digest of the chunk text for integrity checking.
|
||||||
pub content_hash: u64,
|
pub content_hash: String,
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
pub verified: bool,
|
pub verified: bool,
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ impl MemoryProvenance {
|
|||||||
source,
|
source,
|
||||||
created_by: created_by.into(),
|
created_by: created_by.into(),
|
||||||
created_at,
|
created_at,
|
||||||
content_hash: fnv1a_64(chunk),
|
content_hash: hash_content(chunk),
|
||||||
session_id: session_id.into(),
|
session_id: session_id.into(),
|
||||||
verified: false,
|
verified: false,
|
||||||
}
|
}
|
||||||
@@ -105,23 +105,6 @@ impl ProvenanceStore {
|
|||||||
self.records.insert(provenance.record_id, provenance);
|
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.
|
/// Retrieve by record ID.
|
||||||
pub fn get(&self, record_id: u64) -> Option<&MemoryProvenance> {
|
pub fn get(&self, record_id: u64) -> Option<&MemoryProvenance> {
|
||||||
self.records.get(&record_id)
|
self.records.get(&record_id)
|
||||||
@@ -138,13 +121,16 @@ impl ProvenanceStore {
|
|||||||
/// Re-hash `current_chunk` and compare against the stored hash.
|
/// Re-hash `current_chunk` and compare against the stored hash.
|
||||||
/// Returns `true` if the content matches (integrity intact).
|
/// Returns `true` if the content matches (integrity intact).
|
||||||
///
|
///
|
||||||
/// This only detects accidental corruption: the hash is unkeyed, so an
|
/// The hash is unkeyed, so an actor able to modify the stored chunk can
|
||||||
/// actor able to modify the stored chunk can also recompute and
|
/// also recompute and overwrite the stored hash. Do not treat a `true`
|
||||||
/// overwrite the stored hash. Do not treat a `true` result as proof the
|
/// result as proof of authenticity against that threat — but unlike a
|
||||||
/// data hasn't been tampered with.
|
/// non-cryptographic hash, a `false` result reliably indicates that the
|
||||||
|
/// content does not match what was recorded, since SHA-256 makes it
|
||||||
|
/// computationally infeasible to craft different content that collides
|
||||||
|
/// with a specific existing digest.
|
||||||
pub fn verify_integrity(&self, record_id: u64, current_chunk: &str) -> bool {
|
pub fn verify_integrity(&self, record_id: u64, current_chunk: &str) -> bool {
|
||||||
match self.records.get(&record_id) {
|
match self.records.get(&record_id) {
|
||||||
Some(p) => p.content_hash == fnv1a_64(current_chunk),
|
Some(p) => p.content_hash == hash_content(current_chunk),
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,22 +244,32 @@ mod tests {
|
|||||||
1_700_000_000.0
|
1_700_000_000.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- fnv1a_64 ---
|
// --- hash_content ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hash_deterministic() {
|
fn hash_deterministic() {
|
||||||
assert_eq!(fnv1a_64("hello"), fnv1a_64("hello"));
|
assert_eq!(hash_content("hello"), hash_content("hello"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hash_different_inputs() {
|
fn hash_different_inputs() {
|
||||||
assert_ne!(fnv1a_64("hello"), fnv1a_64("world"));
|
assert_ne!(hash_content("hello"), hash_content("world"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hash_empty() {
|
fn hash_empty() {
|
||||||
// Should not panic
|
// Should not panic, and should match the well-known SHA-256 of the empty string.
|
||||||
let _ = fnv1a_64("");
|
assert_eq!(
|
||||||
|
hash_content(""),
|
||||||
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_is_sha256_hex() {
|
||||||
|
let h = hash_content("clawhdf5");
|
||||||
|
assert_eq!(h.len(), 64);
|
||||||
|
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- MemorySource Display ---
|
// --- MemorySource Display ---
|
||||||
@@ -292,7 +288,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn provenance_new_hashes_chunk() {
|
fn provenance_new_hashes_chunk() {
|
||||||
let p = MemoryProvenance::new(1, MemorySource::User, "agent-1", ts(), "hello", "s1");
|
let p = MemoryProvenance::new(1, MemorySource::User, "agent-1", ts(), "hello", "s1");
|
||||||
assert_eq!(p.content_hash, fnv1a_64("hello"));
|
assert_eq!(p.content_hash, hash_content("hello"));
|
||||||
assert!(!p.verified);
|
assert!(!p.verified);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,6 @@
|
|||||||
//! - Temporal expansion (time-related rewrites)
|
//! - Temporal expansion (time-related rewrites)
|
||||||
//! - Morphological variants (stemming-like transforms)
|
//! - Morphological variants (stemming-like transforms)
|
||||||
//! - Knowledge graph expansion (entity aliases and neighbors)
|
//! - Knowledge graph expansion (entity aliases and neighbors)
|
||||||
//!
|
|
||||||
//! The morphological rules are crude suffix swaps, so some variants are not
|
|
||||||
//! words ("during" -> "dured"). That is tolerable for a BM25 stage, which
|
|
||||||
//! simply finds no postings for a nonsense term, but it means expansion is not
|
|
||||||
//! free: measure before enabling it on a retrieval path.
|
|
||||||
|
|
||||||
use crate::knowledge::KnowledgeCache;
|
use crate::knowledge::KnowledgeCache;
|
||||||
|
|
||||||
@@ -345,85 +340,18 @@ fn contains_phrase(text: &str, phrase: &str) -> bool {
|
|||||||
|
|
||||||
/// Replace a phrase in `text` case-insensitively, preserving surrounding case.
|
/// Replace a phrase in `text` case-insensitively, preserving surrounding case.
|
||||||
fn replace_word_case_insensitive(text: &str, from: &str, to: &str) -> String {
|
fn replace_word_case_insensitive(text: &str, from: &str, to: &str) -> String {
|
||||||
replace_first(text, from, to, MatchKind::WholeWord)
|
case_insensitive_replace(text, from, to)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn case_insensitive_replace(text: &str, from: &str, to: &str) -> String {
|
fn case_insensitive_replace(text: &str, from: &str, to: &str) -> String {
|
||||||
replace_first(text, from, to, MatchKind::Substring)
|
let lower = text.to_lowercase();
|
||||||
}
|
let lower_from = from.to_lowercase();
|
||||||
|
if let Some(pos) = lower.find(&lower_from) {
|
||||||
/// Whether a match may fall inside a larger word.
|
let end = pos + from.len();
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
format!("{}{}{}", &text[..pos], to, &text[end..])
|
||||||
enum MatchKind {
|
} else {
|
||||||
/// Match anywhere, including inside another word.
|
text.to_string()
|
||||||
Substring,
|
|
||||||
/// Match only when both ends sit on a word boundary.
|
|
||||||
WholeWord,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace the first case-insensitive match of `from` in `text` with `to`.
|
|
||||||
///
|
|
||||||
/// Matching walks the *original* string rather than a lowercased copy. The
|
|
||||||
/// previous implementation searched `text.to_lowercase()` and then sliced
|
|
||||||
/// `text` with the offsets it found, which only holds while lowercasing
|
|
||||||
/// preserves byte length. It does not: Turkish `İ` (2 bytes) lowercases to
|
|
||||||
/// `i` + U+0307 (3 bytes), so every later offset was wrong — silently
|
|
||||||
/// corrupting the output, or panicking when an offset landed inside a
|
|
||||||
/// character or past the end. `"İ AI"` was enough to panic.
|
|
||||||
fn replace_first(text: &str, from: &str, to: &str, kind: MatchKind) -> String {
|
|
||||||
match find_case_insensitive(text, from, kind) {
|
|
||||||
Some((start, end)) => {
|
|
||||||
let mut out = String::with_capacity(text.len() - (end - start) + to.len());
|
|
||||||
out.push_str(&text[..start]);
|
|
||||||
out.push_str(to);
|
|
||||||
out.push_str(&text[end..]);
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
None => text.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Byte range of the first case-insensitive match of `needle` in `haystack`.
|
|
||||||
fn find_case_insensitive(haystack: &str, needle: &str, kind: MatchKind) -> Option<(usize, usize)> {
|
|
||||||
if needle.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let lowered: Vec<char> = needle.chars().flat_map(char::to_lowercase).collect();
|
|
||||||
let is_word = |c: char| c.is_alphanumeric() || c == '_';
|
|
||||||
|
|
||||||
for (start, _) in haystack.char_indices() {
|
|
||||||
if kind == MatchKind::WholeWord
|
|
||||||
&& haystack[..start].chars().next_back().is_some_and(is_word)
|
|
||||||
{
|
|
||||||
continue; // mid-word: "ai" inside "training"
|
|
||||||
}
|
|
||||||
let mut matched = 0usize;
|
|
||||||
let mut end = start;
|
|
||||||
for (offset, ch) in haystack[start..].char_indices() {
|
|
||||||
if matched == lowered.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let mut consumed_all = true;
|
|
||||||
for lc in ch.to_lowercase() {
|
|
||||||
if lowered.get(matched) != Some(&lc) {
|
|
||||||
consumed_all = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
matched += 1;
|
|
||||||
}
|
|
||||||
if !consumed_all {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
end = start + offset + ch.len_utf8();
|
|
||||||
}
|
|
||||||
if matched == lowered.len()
|
|
||||||
&& !(kind == MatchKind::WholeWord
|
|
||||||
&& haystack[end..].chars().next().is_some_and(is_word))
|
|
||||||
{
|
|
||||||
return Some((start, end));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple whitespace/punctuation tokenizer.
|
/// Simple whitespace/punctuation tokenizer.
|
||||||
@@ -709,86 +637,4 @@ mod tests {
|
|||||||
expanded.iter().map(|x| &x.text).collect::<Vec<_>>()
|
expanded.iter().map(|x| &x.text).collect::<Vec<_>>()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
|
||||||
fn acronyms_only_match_whole_words() {
|
|
||||||
let ex = QueryExpander::new(QueryExpansionConfig::default());
|
|
||||||
// "training" contains "ai", "programming" contains "pr". These used to
|
|
||||||
// be rewritten to "trArtificial Intelligencening" and
|
|
||||||
// "Pull Requestogramming".
|
|
||||||
for query in [
|
|
||||||
"How many miles during my marathon training?",
|
|
||||||
"Which programming language did I pick?",
|
|
||||||
"I updated the maintainer list",
|
|
||||||
] {
|
|
||||||
for expansion in ex.expand(query) {
|
|
||||||
assert!(
|
|
||||||
expansion.expansion_type != "acronym",
|
|
||||||
"{query:?} produced {expansion:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A real acronym still expands, in both directions.
|
|
||||||
let texts: Vec<String> = ex
|
|
||||||
.expand("What about the API and the database?")
|
|
||||||
.into_iter()
|
|
||||||
.filter(|e| e.expansion_type == "acronym")
|
|
||||||
.map(|e| e.text)
|
|
||||||
.collect();
|
|
||||||
assert!(
|
|
||||||
texts
|
|
||||||
.iter()
|
|
||||||
.any(|t| t.contains("Application Programming Interface")),
|
|
||||||
"{texts:?}"
|
|
||||||
);
|
|
||||||
assert!(texts.iter().any(|t| t.contains("DB")), "{texts:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_ascii_queries_do_not_panic_or_corrupt() {
|
|
||||||
let ex = QueryExpander::new(QueryExpansionConfig::default());
|
|
||||||
// Turkish 'İ' is 2 bytes but lowercases to 3, so offsets taken from a
|
|
||||||
// lowercased copy no longer line up with the original. `"İ AI"` used
|
|
||||||
// to panic; `"İstanbul AI trip"` used to silently eat a character.
|
|
||||||
for query in ["İ AI", "İé AI", "İİ ML", "İstanbul AI trip", "ǰ ML notes"] {
|
|
||||||
for expansion in ex.expand(query) {
|
|
||||||
assert!(
|
|
||||||
expansion.text.contains('İ') || expansion.text.contains('ǰ'),
|
|
||||||
"{query:?} lost its leading character: {expansion:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let expanded = ex.expand("İstanbul AI trip");
|
|
||||||
assert!(
|
|
||||||
expanded
|
|
||||||
.iter()
|
|
||||||
.any(|e| e.text == "İstanbul Artificial Intelligence trip"),
|
|
||||||
"{expanded:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn whole_word_matching_handles_string_edges_and_case() {
|
|
||||||
assert_eq!(
|
|
||||||
replace_word_case_insensitive("ai tools", "AI", "Artificial Intelligence"),
|
|
||||||
"Artificial Intelligence tools"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
replace_word_case_insensitive("tools for ai", "AI", "Artificial Intelligence"),
|
|
||||||
"tools for Artificial Intelligence"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
replace_word_case_insensitive("the aim", "AI", "Artificial Intelligence"),
|
|
||||||
"the aim",
|
|
||||||
"must not match inside a word"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
replace_word_case_insensitive("no match here", "xyz", "abc"),
|
|
||||||
"no match here"
|
|
||||||
);
|
|
||||||
// Only the first occurrence is replaced, as before.
|
|
||||||
assert_eq!(
|
|
||||||
replace_word_case_insensitive("ai and ai", "ai", "ML"),
|
|
||||||
"ML and ai"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,8 @@
|
|||||||
//! into a single composite score for each retrieved result.
|
//! into a single composite score for each retrieved result.
|
||||||
|
|
||||||
/// Configuration for the multi-factor re-ranker.
|
/// Configuration for the multi-factor re-ranker.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ReRankConfig {
|
pub struct ReRankConfig {
|
||||||
/// Weight applied to the retrieval score the candidate arrived with.
|
|
||||||
pub relevance_weight: f32,
|
|
||||||
/// Weight applied to the temporal decay score (0.0–1.0).
|
/// Weight applied to the temporal decay score (0.0–1.0).
|
||||||
pub temporal_weight: f32,
|
pub temporal_weight: f32,
|
||||||
/// Weight applied to the source authority score (0.0–1.0).
|
/// Weight applied to the source authority score (0.0–1.0).
|
||||||
@@ -22,9 +20,6 @@ pub struct ReRankConfig {
|
|||||||
impl Default for ReRankConfig {
|
impl Default for ReRankConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
// Relevance leads: the metadata signals break ties and nudge, they
|
|
||||||
// do not decide. See `BENCHMARKS.md`, "Recency discrimination".
|
|
||||||
relevance_weight: 1.0,
|
|
||||||
temporal_weight: 0.3,
|
temporal_weight: 0.3,
|
||||||
authority_weight: 0.2,
|
authority_weight: 0.2,
|
||||||
activation_weight: 0.5,
|
activation_weight: 0.5,
|
||||||
@@ -46,8 +41,6 @@ pub struct ReRankResult {
|
|||||||
pub authority_score: f32,
|
pub authority_score: f32,
|
||||||
/// Normalised Hebbian activation score in [0, 1].
|
/// Normalised Hebbian activation score in [0, 1].
|
||||||
pub activation_score: f32,
|
pub activation_score: f32,
|
||||||
/// The retrieval score carried through from the input.
|
|
||||||
pub relevance_score: f32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute an exponential decay temporal score.
|
/// Compute an exponential decay temporal score.
|
||||||
@@ -112,15 +105,6 @@ pub struct RerankInput {
|
|||||||
pub source_channel: String,
|
pub source_channel: String,
|
||||||
/// Raw Hebbian activation weight for this entry.
|
/// Raw Hebbian activation weight for this entry.
|
||||||
pub raw_activation: f32,
|
pub raw_activation: f32,
|
||||||
/// The retrieval score that put this entry in the candidate list.
|
|
||||||
///
|
|
||||||
/// Re-ranking is meant to *adjust* the retriever's ordering with signals
|
|
||||||
/// it does not have, not to replace it. Without this the combined score
|
|
||||||
/// was made of recency, authority and activation alone, so a candidate
|
|
||||||
/// pool came back ordered by age with its relevance ordering discarded.
|
|
||||||
/// Callers with no meaningful score can pass the same value for every
|
|
||||||
/// entry, which reduces to the old behaviour.
|
|
||||||
pub relevance: f32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-rank a list of retrieval results using multi-factor scoring.
|
/// Re-rank a list of retrieval results using multi-factor scoring.
|
||||||
@@ -154,8 +138,7 @@ pub fn rerank(
|
|||||||
let auth = source_authority_score(&inp.source_channel);
|
let auth = source_authority_score(&inp.source_channel);
|
||||||
let act = activation_score(inp.raw_activation);
|
let act = activation_score(inp.raw_activation);
|
||||||
|
|
||||||
let combined = config.relevance_weight * inp.relevance
|
let combined = config.temporal_weight * ts
|
||||||
+ config.temporal_weight * ts
|
|
||||||
+ config.authority_weight * auth
|
+ config.authority_weight * auth
|
||||||
+ config.activation_weight * act;
|
+ config.activation_weight * act;
|
||||||
|
|
||||||
@@ -165,7 +148,6 @@ pub fn rerank(
|
|||||||
temporal_score: ts,
|
temporal_score: ts,
|
||||||
authority_score: auth,
|
authority_score: auth,
|
||||||
activation_score: act,
|
activation_score: act,
|
||||||
relevance_score: inp.relevance,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -271,51 +253,22 @@ mod tests {
|
|||||||
timestamp: 0.0, // very old
|
timestamp: 0.0, // very old
|
||||||
source_channel: "other".to_string(),
|
source_channel: "other".to_string(),
|
||||||
raw_activation: 0.1,
|
raw_activation: 0.1,
|
||||||
relevance: 0.0,
|
|
||||||
},
|
},
|
||||||
RerankInput {
|
RerankInput {
|
||||||
index: 1,
|
index: 1,
|
||||||
timestamp: 86_400.0, // one day ago
|
timestamp: 86_400.0, // one day ago
|
||||||
source_channel: "conversation".to_string(),
|
source_channel: "conversation".to_string(),
|
||||||
raw_activation: 0.5,
|
raw_activation: 0.5,
|
||||||
relevance: 0.0,
|
|
||||||
},
|
},
|
||||||
RerankInput {
|
RerankInput {
|
||||||
index: 2,
|
index: 2,
|
||||||
timestamp: 172_800.0, // "now"
|
timestamp: 172_800.0, // "now"
|
||||||
source_channel: "user_correction".to_string(),
|
source_channel: "user_correction".to_string(),
|
||||||
raw_activation: 1.0,
|
raw_activation: 1.0,
|
||||||
relevance: 0.0,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relevance_leads_but_recency_breaks_near_ties() {
|
|
||||||
let entry = |index, timestamp, relevance| RerankInput {
|
|
||||||
index,
|
|
||||||
timestamp,
|
|
||||||
source_channel: "conversation".to_string(),
|
|
||||||
raw_activation: 1.0,
|
|
||||||
relevance,
|
|
||||||
};
|
|
||||||
let now = 10.0 * 86_400.0;
|
|
||||||
let config = ReRankConfig::default();
|
|
||||||
|
|
||||||
// A clearly better match wins despite being much older. Before
|
|
||||||
// `relevance` existed the combined score ignored it entirely, so this
|
|
||||||
// returned the newer, irrelevant entry.
|
|
||||||
let ranked = rerank(&[entry(0, 0.0, 1.0), entry(1, now, 0.1)], &config, now);
|
|
||||||
assert_eq!(ranked[0].index, 0, "{ranked:?}");
|
|
||||||
|
|
||||||
// Between near-equal matches, the newer one wins.
|
|
||||||
let ranked = rerank(&[entry(0, 0.0, 0.80), entry(1, now, 0.79)], &config, now);
|
|
||||||
assert_eq!(ranked[0].index, 1, "{ranked:?}");
|
|
||||||
|
|
||||||
// The breakdown carries the relevance through.
|
|
||||||
assert_eq!(ranked[0].relevance_score, 0.79);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rerank_returns_all_entries() {
|
fn rerank_returns_all_entries() {
|
||||||
let inputs = make_inputs();
|
let inputs = make_inputs();
|
||||||
@@ -349,7 +302,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn rerank_score_breakdown_matches_manual_calculation() {
|
fn rerank_score_breakdown_matches_manual_calculation() {
|
||||||
let config = ReRankConfig {
|
let config = ReRankConfig {
|
||||||
relevance_weight: 0.0,
|
|
||||||
temporal_weight: 1.0,
|
temporal_weight: 1.0,
|
||||||
authority_weight: 0.0,
|
authority_weight: 0.0,
|
||||||
activation_weight: 0.0,
|
activation_weight: 0.0,
|
||||||
@@ -360,7 +312,6 @@ mod tests {
|
|||||||
timestamp: 0.0,
|
timestamp: 0.0,
|
||||||
source_channel: "other".to_string(),
|
source_channel: "other".to_string(),
|
||||||
raw_activation: 0.5,
|
raw_activation: 0.5,
|
||||||
relevance: 0.0,
|
|
||||||
}];
|
}];
|
||||||
let now = 3600.0_f64; // exactly one half-life later
|
let now = 3600.0_f64; // exactly one half-life later
|
||||||
let results = rerank(&inputs, &config, now);
|
let results = rerank(&inputs, &config, now);
|
||||||
|
|||||||
@@ -12,18 +12,10 @@ use crate::MemoryError;
|
|||||||
use crate::cache::MemoryCache;
|
use crate::cache::MemoryCache;
|
||||||
use crate::knowledge::KnowledgeCache;
|
use crate::knowledge::KnowledgeCache;
|
||||||
use crate::session::SessionCache;
|
use crate::session::SessionCache;
|
||||||
use crate::wal::WalMark;
|
|
||||||
|
|
||||||
pub const SCHEMA_VERSION: &str = "1.0";
|
pub const SCHEMA_VERSION: &str = "1.0";
|
||||||
pub const ZEROCLAW_VERSION: &str = "0.8.0";
|
pub const ZEROCLAW_VERSION: &str = "0.8.0";
|
||||||
|
|
||||||
/// `/meta` attributes holding the [`WalMark`] of the WAL prefix already folded
|
|
||||||
/// into this file. Absent on files written before the mark existed, and when
|
|
||||||
/// the checkpoint was taken with an empty WAL.
|
|
||||||
const WAL_APPLIED_LEN_ATTR: &str = "wal_applied_len";
|
|
||||||
const WAL_APPLIED_CRC_ATTR: &str = "wal_applied_crc";
|
|
||||||
const ANN_GENERATION_ATTR: &str = "ann_generation";
|
|
||||||
|
|
||||||
/// Build a complete HDF5 file from the in-memory state.
|
/// Build a complete HDF5 file from the in-memory state.
|
||||||
pub fn build_hdf5_file(
|
pub fn build_hdf5_file(
|
||||||
config: &MemoryConfig,
|
config: &MemoryConfig,
|
||||||
@@ -31,47 +23,6 @@ pub fn build_hdf5_file(
|
|||||||
sessions: &SessionCache,
|
sessions: &SessionCache,
|
||||||
knowledge: &KnowledgeCache,
|
knowledge: &KnowledgeCache,
|
||||||
) -> Result<Vec<u8>, MemoryError> {
|
) -> Result<Vec<u8>, MemoryError> {
|
||||||
build_hdf5_file_with_mark(config, cache, sessions, knowledge, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`build_hdf5_file`], recording which WAL prefix this state already
|
|
||||||
/// contains (see [`WalMark`]) so a crash before the WAL is truncated doesn't
|
|
||||||
/// replay those entries a second time.
|
|
||||||
pub fn build_hdf5_file_with_mark(
|
|
||||||
config: &MemoryConfig,
|
|
||||||
cache: &MemoryCache,
|
|
||||||
sessions: &SessionCache,
|
|
||||||
knowledge: &KnowledgeCache,
|
|
||||||
wal_applied: Option<WalMark>,
|
|
||||||
) -> Result<Vec<u8>, MemoryError> {
|
|
||||||
let meta = CheckpointMeta {
|
|
||||||
wal_applied,
|
|
||||||
ann_generation: None,
|
|
||||||
};
|
|
||||||
build_hdf5_file_with_meta(config, cache, sessions, knowledge, &meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bookkeeping a checkpoint records in `/meta` beside the store's contents.
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
||||||
pub struct CheckpointMeta {
|
|
||||||
/// The WAL prefix this checkpoint already contains; see [`WalMark`].
|
|
||||||
pub wal_applied: Option<WalMark>,
|
|
||||||
/// Identifies the vector-index sidecar (`<store>.h5.ann`) written with this
|
|
||||||
/// checkpoint. A sidecar is loaded only if it carries the same value, so
|
|
||||||
/// one left over from another checkpoint can never be attached to records
|
|
||||||
/// it wasn't built from.
|
|
||||||
pub ann_generation: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`build_hdf5_file`] with checkpoint bookkeeping.
|
|
||||||
pub fn build_hdf5_file_with_meta(
|
|
||||||
config: &MemoryConfig,
|
|
||||||
cache: &MemoryCache,
|
|
||||||
sessions: &SessionCache,
|
|
||||||
knowledge: &KnowledgeCache,
|
|
||||||
checkpoint: &CheckpointMeta,
|
|
||||||
) -> Result<Vec<u8>, MemoryError> {
|
|
||||||
let wal_applied = checkpoint.wal_applied;
|
|
||||||
let mut builder = clawhdf5::FileBuilder::new();
|
let mut builder = clawhdf5::FileBuilder::new();
|
||||||
|
|
||||||
// /meta group with schema attributes
|
// /meta group with schema attributes
|
||||||
@@ -83,44 +34,10 @@ pub fn build_hdf5_file_with_meta(
|
|||||||
meta.set_attr("embedding_dim", AttrValue::I64(config.embedding_dim as i64));
|
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("chunk_size", AttrValue::I64(config.chunk_size as i64));
|
||||||
meta.set_attr("overlap", AttrValue::I64(config.overlap as i64));
|
meta.set_attr("overlap", AttrValue::I64(config.overlap as i64));
|
||||||
// Behavioural settings. These used to live only in memory, so reopening a
|
|
||||||
// store silently reset them to defaults — e.g. a compressed store was
|
|
||||||
// rewritten uncompressed by the first checkpoint after a reopen. Loaders
|
|
||||||
// treat each one as optional so older files keep opening.
|
|
||||||
meta.set_attr("float16", AttrValue::I64(config.float16.into()));
|
|
||||||
meta.set_attr("compression", AttrValue::I64(config.compression.into()));
|
|
||||||
meta.set_attr(
|
|
||||||
"compression_level",
|
|
||||||
AttrValue::I64(config.compression_level.into()),
|
|
||||||
);
|
|
||||||
meta.set_attr(
|
|
||||||
"compact_threshold",
|
|
||||||
AttrValue::F64(config.compact_threshold.into()),
|
|
||||||
);
|
|
||||||
meta.set_attr("hebbian_boost", AttrValue::F64(config.hebbian_boost.into()));
|
|
||||||
meta.set_attr("decay_factor", AttrValue::F64(config.decay_factor.into()));
|
|
||||||
meta.set_attr("wal_enabled", AttrValue::I64(config.wal_enabled.into()));
|
|
||||||
meta.set_attr(
|
|
||||||
"wal_max_entries",
|
|
||||||
AttrValue::I64(config.wal_max_entries as i64),
|
|
||||||
);
|
|
||||||
meta.set_attr(
|
|
||||||
"quantized_index",
|
|
||||||
AttrValue::I64(config.quantized_index.into()),
|
|
||||||
);
|
|
||||||
meta.set_attr(
|
meta.set_attr(
|
||||||
"edgehdf5_version",
|
"edgehdf5_version",
|
||||||
AttrValue::String(ZEROCLAW_VERSION.into()),
|
AttrValue::String(ZEROCLAW_VERSION.into()),
|
||||||
);
|
);
|
||||||
if let Some(mark) = wal_applied.filter(|m| m.len > 0) {
|
|
||||||
meta.set_attr(WAL_APPLIED_LEN_ATTR, AttrValue::I64(mark.len as i64));
|
|
||||||
meta.set_attr(WAL_APPLIED_CRC_ATTR, AttrValue::I64(i64::from(mark.crc)));
|
|
||||||
}
|
|
||||||
if let Some(generation) = checkpoint.ann_generation {
|
|
||||||
// Stored as the i64 with the same bits; attributes have no u64 scalar
|
|
||||||
// round trip through every reader.
|
|
||||||
meta.set_attr(ANN_GENERATION_ATTR, AttrValue::I64(generation as i64));
|
|
||||||
}
|
|
||||||
// Need at least one dataset in the group for it to be a proper group
|
// Need at least one dataset in the group for it to be a proper group
|
||||||
meta.create_dataset("_marker").with_u8_data(&[1]).compact();
|
meta.create_dataset("_marker").with_u8_data(&[1]).compact();
|
||||||
let finished_meta = meta.finish();
|
let finished_meta = meta.finish();
|
||||||
@@ -157,7 +74,7 @@ fn build_memory_group(
|
|||||||
{
|
{
|
||||||
let ds = group
|
let ds = group
|
||||||
.create_dataset("embeddings")
|
.create_dataset("embeddings")
|
||||||
.with_f32_data(flat)
|
.with_f32_data(&flat)
|
||||||
.with_shape(&[n, d]);
|
.with_shape(&[n, d]);
|
||||||
|
|
||||||
// Chunk size tuning: target ~256KB per chunk for optimal I/O
|
// Chunk size tuning: target ~256KB per chunk for optimal I/O
|
||||||
@@ -166,34 +83,16 @@ fn build_memory_group(
|
|||||||
let rows_per_chunk = (target_chunk_bytes / (d * 4)).max(1).min(n);
|
let rows_per_chunk = (target_chunk_bytes / (d * 4)).max(1).min(n);
|
||||||
ds.with_chunks(&[rows_per_chunk, d]);
|
ds.with_chunks(&[rows_per_chunk, d]);
|
||||||
|
|
||||||
// Compression. Shuffle is applied automatically (auto-shuffle
|
// Compression: Zstd for embeddings — faster than deflate at same ratio.
|
||||||
// pre-filter). Zstd is faster than deflate at the same ratio but
|
// Shuffle is applied automatically (auto-shuffle pre-filter).
|
||||||
// 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 {
|
if config.compression {
|
||||||
#[cfg(feature = "zstd")]
|
|
||||||
{
|
|
||||||
let level = if config.compression_level > 0 {
|
let level = if config.compression_level > 0 {
|
||||||
config.compression_level.min(22)
|
config.compression_level.min(22)
|
||||||
} else {
|
} else {
|
||||||
3 // fast + good ratio for f32 embeddings
|
3 // Zstd level 3: fast + good ratio for f32 embeddings
|
||||||
};
|
};
|
||||||
ds.with_zstd(level);
|
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
|
// Skip fill-value initialization — embeddings are fully written
|
||||||
@@ -410,36 +309,6 @@ fn write_string_dataset(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Validate an HDF5 file has the correct schema and load all data.
|
/// Validate an HDF5 file has the correct schema and load all data.
|
||||||
/// Read the checkpoint's [`WalMark`] from `/meta`, if it has one.
|
|
||||||
pub fn read_wal_mark(file: &clawhdf5::File) -> Option<WalMark> {
|
|
||||||
let attrs = file.group("meta").ok()?.attrs().ok()?;
|
|
||||||
let len = match attrs.get(WAL_APPLIED_LEN_ATTR)? {
|
|
||||||
AttrValue::I64(v) => u64::try_from(*v).ok()?,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
let crc = match attrs.get(WAL_APPLIED_CRC_ATTR)? {
|
|
||||||
AttrValue::I64(v) => u32::try_from(*v).ok()?,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
Some(WalMark { len, crc })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the checkpoint bookkeeping from `/meta`.
|
|
||||||
pub fn read_checkpoint_meta(file: &clawhdf5::File) -> CheckpointMeta {
|
|
||||||
let ann_generation = file
|
|
||||||
.group("meta")
|
|
||||||
.ok()
|
|
||||||
.and_then(|g| g.attrs().ok())
|
|
||||||
.and_then(|attrs| match attrs.get(ANN_GENERATION_ATTR) {
|
|
||||||
Some(AttrValue::I64(v)) => Some(*v as u64),
|
|
||||||
_ => None,
|
|
||||||
});
|
|
||||||
CheckpointMeta {
|
|
||||||
wal_applied: read_wal_mark(file),
|
|
||||||
ann_generation,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate_and_load(
|
pub fn validate_and_load(
|
||||||
file: &clawhdf5::File,
|
file: &clawhdf5::File,
|
||||||
) -> Result<(MemoryConfig, MemoryCache, SessionCache, KnowledgeCache), MemoryError> {
|
) -> Result<(MemoryConfig, MemoryCache, SessionCache, KnowledgeCache), MemoryError> {
|
||||||
@@ -475,20 +344,15 @@ pub fn validate_and_load(
|
|||||||
embedding_dim,
|
embedding_dim,
|
||||||
chunk_size,
|
chunk_size,
|
||||||
overlap,
|
overlap,
|
||||||
float16: optional_bool_attr(&attrs, "float16", false),
|
float16: false,
|
||||||
compression: optional_bool_attr(&attrs, "compression", false),
|
compression: false,
|
||||||
compression_level: optional_i64_attr(&attrs, "compression_level")
|
compression_level: 0,
|
||||||
.and_then(|v| u32::try_from(v).ok())
|
compact_threshold: 0.3,
|
||||||
.unwrap_or(0),
|
hebbian_boost: 0.15,
|
||||||
compact_threshold: optional_f32_attr(&attrs, "compact_threshold", 0.3),
|
decay_factor: 0.98,
|
||||||
hebbian_boost: optional_f32_attr(&attrs, "hebbian_boost", 0.15),
|
|
||||||
decay_factor: optional_f32_attr(&attrs, "decay_factor", 0.98),
|
|
||||||
created_at,
|
created_at,
|
||||||
wal_enabled: optional_bool_attr(&attrs, "wal_enabled", true),
|
wal_enabled: true,
|
||||||
wal_max_entries: optional_i64_attr(&attrs, "wal_max_entries")
|
wal_max_entries: 500,
|
||||||
.and_then(|v| usize::try_from(v).ok())
|
|
||||||
.unwrap_or(500),
|
|
||||||
quantized_index: optional_bool_attr(&attrs, "quantized_index", false),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load /memory group
|
// Load /memory group
|
||||||
@@ -527,48 +391,27 @@ fn load_memory_group(
|
|||||||
let tags = read_string_dataset_from_group(&group, "tags")?;
|
let tags = read_string_dataset_from_group(&group, "tags")?;
|
||||||
let tombstones = read_u8_dataset(&group, "tombstones")?;
|
let tombstones = read_u8_dataset(&group, "tombstones")?;
|
||||||
|
|
||||||
// Every per-record dataset must describe exactly `n` records. Without
|
// Read norms if present, otherwise compute from embeddings
|
||||||
// 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") {
|
let norms = match read_f32_dataset(&group, "norms") {
|
||||||
Ok(stored) if stored.len() == n => stored,
|
Ok(n) if n.len() == n.len() => n,
|
||||||
_ => flat_embeddings
|
_ => {
|
||||||
|
// Compute norms from flat embeddings
|
||||||
|
flat_embeddings
|
||||||
.chunks(embedding_dim)
|
.chunks(embedding_dim)
|
||||||
.map(|chunk| {
|
.map(|chunk| {
|
||||||
let sq_sum: f32 = chunk.iter().map(|x| x * x).sum();
|
let sq_sum: f32 = chunk.iter().map(|x| x * x).sum();
|
||||||
sq_sum.sqrt()
|
sq_sum.sqrt()
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// No unflattening: the cache stores the buffer as it is on disk.
|
// Unflatten embeddings
|
||||||
|
let embeddings: Vec<Vec<f32>> = flat_embeddings
|
||||||
|
.chunks(embedding_dim)
|
||||||
|
.map(|c| c.to_vec())
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Read activation_weights if present, default to vec![1.0; N] for backward compat
|
// Read activation_weights if present, default to vec![1.0; N] for backward compat
|
||||||
let activation_weights = match read_f32_dataset(&group, "activation_weights") {
|
let activation_weights = match read_f32_dataset(&group, "activation_weights") {
|
||||||
Ok(w) if w.len() == n => w,
|
Ok(w) if w.len() == n => w,
|
||||||
@@ -576,7 +419,7 @@ fn load_memory_group(
|
|||||||
};
|
};
|
||||||
|
|
||||||
cache.chunks = chunks;
|
cache.chunks = chunks;
|
||||||
cache.embeddings.set_flat(embedding_dim, flat_embeddings);
|
cache.embeddings = embeddings;
|
||||||
cache.source_channels = source_channels;
|
cache.source_channels = source_channels;
|
||||||
cache.timestamps = timestamps;
|
cache.timestamps = timestamps;
|
||||||
cache.session_ids = session_ids;
|
cache.session_ids = session_ids;
|
||||||
@@ -637,7 +480,6 @@ fn load_knowledge_group(file: &clawhdf5::File) -> Result<KnowledgeCache, MemoryE
|
|||||||
cache.entities.push(crate::knowledge::Entity {
|
cache.entities.push(crate::knowledge::Entity {
|
||||||
id: entity_ids[i] as u64,
|
id: entity_ids[i] as u64,
|
||||||
name: entity_names[i].clone(),
|
name: entity_names[i].clone(),
|
||||||
name_lower: entity_names[i].to_lowercase(),
|
|
||||||
entity_type: entity_types[i].clone(),
|
entity_type: entity_types[i].clone(),
|
||||||
embedding_idx: emb_idxs[i],
|
embedding_idx: emb_idxs[i],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -687,27 +529,6 @@ 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(
|
fn extract_i64_attr(
|
||||||
attrs: &std::collections::HashMap<String, AttrValue>,
|
attrs: &std::collections::HashMap<String, AttrValue>,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -793,108 +614,3 @@ 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}")))?;
|
.map_err(|e| MemoryError::Hdf5(format!("cannot read u8 from {name}: {e}")))?;
|
||||||
Ok(data.into_iter().map(|v| v as u8).collect())
|
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(|_| ())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::path::Path;
|
|||||||
|
|
||||||
use crate::bm25;
|
use crate::bm25;
|
||||||
use crate::hybrid;
|
use crate::hybrid;
|
||||||
use crate::{HDF5Memory, MAX_ACTIVATION_WEIGHT, MemoryError, Result, SearchResult};
|
use crate::{HDF5Memory, MemoryError, Result, SearchResult};
|
||||||
|
|
||||||
impl HDF5Memory {
|
impl HDF5Memory {
|
||||||
/// Vector + keyword scoring stage of [`HDF5Memory::hybrid_search`].
|
/// Vector + keyword scoring stage of [`HDF5Memory::hybrid_search`].
|
||||||
@@ -20,7 +20,8 @@ impl HDF5Memory {
|
|||||||
query_embedding: &[f32],
|
query_embedding: &[f32],
|
||||||
query_text: &str,
|
query_text: &str,
|
||||||
bm25: &bm25::BM25Index,
|
bm25: &bm25::BM25Index,
|
||||||
fusion: hybrid::Fusion,
|
vector_weight: f32,
|
||||||
|
keyword_weight: f32,
|
||||||
k: usize,
|
k: usize,
|
||||||
) -> Vec<(usize, f32)> {
|
) -> Vec<(usize, f32)> {
|
||||||
self.ensure_hnsw_fresh();
|
self.ensure_hnsw_fresh();
|
||||||
@@ -29,40 +30,29 @@ impl HDF5Memory {
|
|||||||
// Over-fetch so the merge sees a useful vector pool; cosine
|
// Over-fetch so the merge sees a useful vector pool; cosine
|
||||||
// distance from the index converts back to similarity (1 - d).
|
// distance from the index converts back to similarity (1 - d).
|
||||||
let pool = (k * 8).max(64);
|
let pool = (k * 8).max(64);
|
||||||
let candidates = index.search(query_embedding, pool, pool);
|
let vec_scores: Vec<(usize, f32)> = index
|
||||||
// A quantised index returns approximate distances, and no
|
.search(query_embedding, pool, pool)
|
||||||
// amount of `ef` fixes that — the loss is in the distances,
|
|
||||||
// not the graph. Re-score the pool against the cache's exact
|
|
||||||
// embeddings, which cost nothing extra to keep: recall then
|
|
||||||
// matches an f32 index. See `BENCHMARKS.md`.
|
|
||||||
let exact = index.storage() == clawhdf5_ann::Storage::Int8;
|
|
||||||
let vec_scores: Vec<(usize, f32)> = candidates
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, dist)| {
|
.map(|(id, dist)| (id, 1.0 - dist))
|
||||||
let score = if exact {
|
|
||||||
crate::vector_search::cosine_similarity(
|
|
||||||
query_embedding,
|
|
||||||
&self.cache.embeddings[id],
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
1.0 - dist
|
|
||||||
};
|
|
||||||
(id, score)
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
// Fusion normalises over every keyword match, so it needs all
|
let kw_scores = bm25.search(query_text, self.cache.len());
|
||||||
// the scores — but not ranked.
|
hybrid::merge_vector_keyword(
|
||||||
let kw_scores = bm25.scores(query_text);
|
vec_scores,
|
||||||
hybrid::fuse(vec_scores, kw_scores, fusion, k)
|
kw_scores,
|
||||||
|
vector_weight,
|
||||||
|
keyword_weight,
|
||||||
|
k,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
_ => hybrid::hybrid_search_fused(
|
_ => hybrid::hybrid_search(
|
||||||
query_embedding,
|
query_embedding,
|
||||||
query_text,
|
query_text,
|
||||||
&self.cache.embeddings,
|
&self.cache.embeddings,
|
||||||
&self.cache.chunks,
|
&self.cache.chunks,
|
||||||
&self.cache.tombstones,
|
&self.cache.tombstones,
|
||||||
bm25,
|
bm25,
|
||||||
fusion,
|
vector_weight,
|
||||||
|
keyword_weight,
|
||||||
k,
|
k,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -74,17 +64,19 @@ impl HDF5Memory {
|
|||||||
query_embedding: &[f32],
|
query_embedding: &[f32],
|
||||||
query_text: &str,
|
query_text: &str,
|
||||||
bm25: &bm25::BM25Index,
|
bm25: &bm25::BM25Index,
|
||||||
fusion: hybrid::Fusion,
|
vector_weight: f32,
|
||||||
|
keyword_weight: f32,
|
||||||
k: usize,
|
k: usize,
|
||||||
) -> Vec<(usize, f32)> {
|
) -> Vec<(usize, f32)> {
|
||||||
hybrid::hybrid_search_fused(
|
hybrid::hybrid_search(
|
||||||
query_embedding,
|
query_embedding,
|
||||||
query_text,
|
query_text,
|
||||||
&self.cache.embeddings,
|
&self.cache.embeddings,
|
||||||
&self.cache.chunks,
|
&self.cache.chunks,
|
||||||
&self.cache.tombstones,
|
&self.cache.tombstones,
|
||||||
bm25,
|
bm25,
|
||||||
fusion,
|
vector_weight,
|
||||||
|
keyword_weight,
|
||||||
k,
|
k,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -98,35 +90,15 @@ impl HDF5Memory {
|
|||||||
keyword_weight: f32,
|
keyword_weight: f32,
|
||||||
k: usize,
|
k: usize,
|
||||||
) -> Vec<SearchResult> {
|
) -> Vec<SearchResult> {
|
||||||
self.hybrid_search_with(
|
let bm25 = bm25::BM25Index::build(&self.cache.chunks, &self.cache.tombstones);
|
||||||
|
let scored = self.vector_keyword_search(
|
||||||
query_embedding,
|
query_embedding,
|
||||||
query_text,
|
query_text,
|
||||||
hybrid::Fusion::Weighted {
|
&bm25,
|
||||||
vector: vector_weight,
|
vector_weight,
|
||||||
keyword: keyword_weight,
|
keyword_weight,
|
||||||
},
|
|
||||||
k,
|
k,
|
||||||
)
|
);
|
||||||
}
|
|
||||||
|
|
||||||
/// [`HDF5Memory::hybrid_search`] with the fusion method chosen explicitly.
|
|
||||||
///
|
|
||||||
/// [`hybrid::DEFAULT_FUSION`] is what the weighted form defaults to;
|
|
||||||
/// [`hybrid::Fusion::Rrf`] combines the two stages by rank instead of by
|
|
||||||
/// score.
|
|
||||||
pub fn hybrid_search_with(
|
|
||||||
&mut self,
|
|
||||||
query_embedding: &[f32],
|
|
||||||
query_text: &str,
|
|
||||||
fusion: hybrid::Fusion,
|
|
||||||
k: usize,
|
|
||||||
) -> Vec<SearchResult> {
|
|
||||||
// The keyword index lives for the life of the store and is updated
|
|
||||||
// incrementally. Take it out for the duration of the call so the
|
|
||||||
// vector stage can borrow `self` mutably, then put it back.
|
|
||||||
self.ensure_bm25_fresh();
|
|
||||||
let bm25 = self.bm25.take().expect("ensure_bm25_fresh leaves an index");
|
|
||||||
let scored = self.vector_keyword_search(query_embedding, query_text, &bm25, fusion, k);
|
|
||||||
let mut results: Vec<SearchResult> = scored
|
let mut results: Vec<SearchResult> = scored
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(idx, score)| {
|
.map(|(idx, score)| {
|
||||||
@@ -141,45 +113,23 @@ impl HDF5Memory {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.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| {
|
results.sort_by(|a, b| {
|
||||||
b.score
|
b.score
|
||||||
.partial_cmp(&a.score)
|
.partial_cmp(&a.score)
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
.then(a.index.cmp(&b.index))
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only reinforce records that actually matched. When fewer than `k`
|
let hit_indices: Vec<usize> = results.iter().map(|r| r.index).collect();
|
||||||
// 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.apply_hebbian_boost(&hit_indices);
|
||||||
self.bm25 = Some(bm25);
|
self.flush().ok();
|
||||||
|
|
||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reinforce the records a query returned. The new weights are persisted by
|
|
||||||
/// the next checkpoint (any write that flushes, `flush_wal`, or drop) — not
|
|
||||||
/// by rewriting the whole store inside the query, which is what made
|
|
||||||
/// `hybrid_search` cost O(store size) in disk I/O. They are a ranking hint,
|
|
||||||
/// not user data: a crash before the next checkpoint only forgets the
|
|
||||||
/// boosts since the last one.
|
|
||||||
fn apply_hebbian_boost(&mut self, hit_indices: &[usize]) {
|
fn apply_hebbian_boost(&mut self, hit_indices: &[usize]) {
|
||||||
if hit_indices.is_empty() || self.config.hebbian_boost == 0.0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for &idx in hit_indices {
|
for &idx in hit_indices {
|
||||||
let w = &mut self.cache.activation_weights[idx];
|
self.cache.activation_weights[idx] += self.config.hebbian_boost;
|
||||||
*w = (*w + self.config.hebbian_boost).min(MAX_ACTIVATION_WEIGHT);
|
|
||||||
}
|
}
|
||||||
self.activations_dirty = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the chunk text for a memory entry by index.
|
/// Get the chunk text for a memory entry by index.
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use crate::cache::MemoryCache;
|
|||||||
use crate::knowledge::KnowledgeCache;
|
use crate::knowledge::KnowledgeCache;
|
||||||
use crate::schema;
|
use crate::schema;
|
||||||
use crate::session::SessionCache;
|
use crate::session::SessionCache;
|
||||||
use crate::wal::WalMark;
|
|
||||||
|
|
||||||
/// Write all in-memory state to an HDF5 file on disk.
|
/// Write all in-memory state to an HDF5 file on disk.
|
||||||
pub fn write_to_disk(
|
pub fn write_to_disk(
|
||||||
@@ -21,36 +20,7 @@ pub fn write_to_disk(
|
|||||||
sessions: &SessionCache,
|
sessions: &SessionCache,
|
||||||
knowledge: &KnowledgeCache,
|
knowledge: &KnowledgeCache,
|
||||||
) -> Result<(), MemoryError> {
|
) -> Result<(), MemoryError> {
|
||||||
write_to_disk_with_mark(path, config, cache, sessions, knowledge, None)
|
let bytes = schema::build_hdf5_file(config, cache, sessions, knowledge)?;
|
||||||
}
|
|
||||||
|
|
||||||
/// [`write_to_disk`] for a checkpoint: `wal_applied` is the mark of the WAL
|
|
||||||
/// prefix whose entries `cache` already contains.
|
|
||||||
pub fn write_to_disk_with_mark(
|
|
||||||
path: &Path,
|
|
||||||
config: &MemoryConfig,
|
|
||||||
cache: &MemoryCache,
|
|
||||||
sessions: &SessionCache,
|
|
||||||
knowledge: &KnowledgeCache,
|
|
||||||
wal_applied: Option<WalMark>,
|
|
||||||
) -> Result<(), MemoryError> {
|
|
||||||
let meta = schema::CheckpointMeta {
|
|
||||||
wal_applied,
|
|
||||||
ann_generation: None,
|
|
||||||
};
|
|
||||||
write_to_disk_with_meta(path, config, cache, sessions, knowledge, &meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`write_to_disk`] with full checkpoint bookkeeping.
|
|
||||||
pub fn write_to_disk_with_meta(
|
|
||||||
path: &Path,
|
|
||||||
config: &MemoryConfig,
|
|
||||||
cache: &MemoryCache,
|
|
||||||
sessions: &SessionCache,
|
|
||||||
knowledge: &KnowledgeCache,
|
|
||||||
checkpoint: &schema::CheckpointMeta,
|
|
||||||
) -> Result<(), MemoryError> {
|
|
||||||
let bytes = schema::build_hdf5_file_with_meta(config, cache, sessions, knowledge, checkpoint)?;
|
|
||||||
|
|
||||||
if bytes.is_empty() {
|
if bytes.is_empty() {
|
||||||
return Err(MemoryError::Hdf5("build_hdf5_file produced 0 bytes".into()));
|
return Err(MemoryError::Hdf5("build_hdf5_file produced 0 bytes".into()));
|
||||||
@@ -58,41 +28,9 @@ pub fn write_to_disk_with_meta(
|
|||||||
|
|
||||||
// Write to a temp file first, then rename for atomicity
|
// Write to a temp file first, then rename for atomicity
|
||||||
let tmp_path = path.with_extension("h5.tmp");
|
let tmp_path = path.with_extension("h5.tmp");
|
||||||
write_synced(&tmp_path, &bytes)?;
|
std::fs::write(&tmp_path, &bytes).map_err(MemoryError::Io)?;
|
||||||
rename_synced(&tmp_path, path)
|
std::fs::rename(&tmp_path, path).map_err(MemoryError::Io)?;
|
||||||
}
|
|
||||||
|
|
||||||
/// Write `bytes` to `path` and flush them to stable storage.
|
|
||||||
pub(crate) fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), MemoryError> {
|
|
||||||
use std::io::Write;
|
|
||||||
let mut f = std::fs::File::create(path).map_err(MemoryError::Io)?;
|
|
||||||
f.write_all(bytes).map_err(MemoryError::Io)?;
|
|
||||||
f.sync_all().map_err(MemoryError::Io)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rename `from` over `to`, then sync the parent directory so the rename
|
|
||||||
/// itself survives a power loss. `from` must already be synced: without that,
|
|
||||||
/// the rename can reach disk before the data and leave an empty or partial
|
|
||||||
/// file under the final name.
|
|
||||||
///
|
|
||||||
/// This is per-checkpoint/snapshot cost only (each is already a full file
|
|
||||||
/// write). Individual WAL appends are deliberately not synced — see the
|
|
||||||
/// durability notes in the crate docs.
|
|
||||||
pub(crate) fn rename_synced(from: &Path, to: &Path) -> Result<(), MemoryError> {
|
|
||||||
std::fs::rename(from, to).map_err(MemoryError::Io)?;
|
|
||||||
#[cfg(unix)]
|
|
||||||
if let Some(dir) = to.parent() {
|
|
||||||
let dir = if dir.as_os_str().is_empty() {
|
|
||||||
Path::new(".")
|
|
||||||
} else {
|
|
||||||
dir
|
|
||||||
};
|
|
||||||
// Directory fsync is best-effort: some filesystems refuse it, and the
|
|
||||||
// rename has already happened.
|
|
||||||
if let Ok(d) = std::fs::File::open(dir) {
|
|
||||||
let _ = d.sync_all();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,15 +42,6 @@ pub(crate) fn rename_synced(from: &Path, to: &Path) -> Result<(), MemoryError> {
|
|||||||
pub fn read_from_disk(
|
pub fn read_from_disk(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
) -> Result<(MemoryConfig, MemoryCache, SessionCache, KnowledgeCache), MemoryError> {
|
) -> 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)?;
|
let mmap = clawhdf5_io::MmapReader::open(path).map_err(MemoryError::Io)?;
|
||||||
|
|
||||||
// Advise the OS we'll need the whole file for parsing
|
// Advise the OS we'll need the whole file for parsing
|
||||||
@@ -124,23 +53,8 @@ pub fn read_from_disk_with_mark(path: &Path) -> Result<(StoreState, Option<WalMa
|
|||||||
|
|
||||||
let (mut config, cache, sessions, knowledge) = schema::validate_and_load(&file)?;
|
let (mut config, cache, sessions, knowledge) = schema::validate_and_load(&file)?;
|
||||||
config.path = path.to_path_buf();
|
config.path = path.to_path_buf();
|
||||||
let wal_applied = schema::read_wal_mark(&file);
|
|
||||||
|
|
||||||
Ok(((config, cache, sessions, knowledge), wal_applied))
|
Ok((config, cache, sessions, knowledge))
|
||||||
}
|
|
||||||
|
|
||||||
/// [`read_from_disk`], plus all checkpoint bookkeeping.
|
|
||||||
pub fn read_from_disk_with_meta(
|
|
||||||
path: &Path,
|
|
||||||
) -> Result<(StoreState, schema::CheckpointMeta), MemoryError> {
|
|
||||||
let mmap = clawhdf5_io::MmapReader::open(path).map_err(MemoryError::Io)?;
|
|
||||||
mmap.advise_willneed(0, mmap.len());
|
|
||||||
let file = clawhdf5::File::from_bytes(mmap.as_bytes().to_vec())
|
|
||||||
.map_err(|e| MemoryError::Hdf5(format!("cannot open {}: {e}", path.display())))?;
|
|
||||||
let (mut config, cache, sessions, knowledge) = schema::validate_and_load(&file)?;
|
|
||||||
config.path = path.to_path_buf();
|
|
||||||
let meta = schema::read_checkpoint_meta(&file);
|
|
||||||
Ok(((config, cache, sessions, knowledge), meta))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy an HDF5 file atomically to a destination.
|
/// Copy an HDF5 file atomically to a destination.
|
||||||
@@ -164,10 +78,7 @@ pub fn snapshot_file(src: &Path, dest: &Path) -> Result<std::path::PathBuf, Memo
|
|||||||
// Atomic copy: write to temp, then rename
|
// Atomic copy: write to temp, then rename
|
||||||
let tmp_path = dest_file.with_extension("h5.tmp");
|
let tmp_path = dest_file.with_extension("h5.tmp");
|
||||||
std::fs::copy(src, &tmp_path).map_err(MemoryError::Io)?;
|
std::fs::copy(src, &tmp_path).map_err(MemoryError::Io)?;
|
||||||
std::fs::File::open(&tmp_path)
|
std::fs::rename(&tmp_path, &dest_file).map_err(MemoryError::Io)?;
|
||||||
.and_then(|f| f.sync_all())
|
|
||||||
.map_err(MemoryError::Io)?;
|
|
||||||
rename_synced(&tmp_path, &dest_file)?;
|
|
||||||
|
|
||||||
Ok(dest_file)
|
Ok(dest_file)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
//! 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,17 +167,10 @@ pub fn auto_select_strategy(num_vectors: usize, hw: &HardwareCapabilities) -> Se
|
|||||||
/// This dispatches to the appropriate search implementation based on the
|
/// This dispatches to the appropriate search implementation based on the
|
||||||
/// selected strategy. For IVF-PQ, an index must be provided externally
|
/// selected strategy. For IVF-PQ, an index must be provided externally
|
||||||
/// (this function uses brute-force fallback if no IVF-PQ index is available).
|
/// (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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn search_with_metrics(
|
pub fn search_with_metrics(
|
||||||
query: &[f32],
|
query: &[f32],
|
||||||
vectors: &[Vec<f32>],
|
vectors: &[Vec<f32>],
|
||||||
vectors_flat: &[f32],
|
|
||||||
norms: &[f32],
|
norms: &[f32],
|
||||||
tombstones: &[u8],
|
tombstones: &[u8],
|
||||||
k: usize,
|
k: usize,
|
||||||
@@ -185,10 +178,6 @@ pub fn search_with_metrics(
|
|||||||
#[cfg(feature = "gpu")] gpu_backend: Option<&crate::gpu_search::GpuSearchBackend>,
|
#[cfg(feature = "gpu")] gpu_backend: Option<&crate::gpu_search::GpuSearchBackend>,
|
||||||
#[cfg(not(feature = "gpu"))] _gpu_backend: Option<&()>,
|
#[cfg(not(feature = "gpu"))] _gpu_backend: Option<&()>,
|
||||||
) -> (Vec<(usize, f32)>, SearchMetrics) {
|
) -> (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 start = Instant::now();
|
||||||
let active_count = tombstones.iter().filter(|&&t| t == 0).count();
|
let active_count = tombstones.iter().filter(|&&t| t == 0).count();
|
||||||
|
|
||||||
@@ -208,14 +197,7 @@ pub fn search_with_metrics(
|
|||||||
gpu_active = false;
|
gpu_active = false;
|
||||||
#[cfg(feature = "fast-math")]
|
#[cfg(feature = "fast-math")]
|
||||||
{
|
{
|
||||||
crate::blas_search::blas_cosine_batch_flat(
|
crate::blas_search::blas_cosine_batch(query, vectors, norms, tombstones, k)
|
||||||
query,
|
|
||||||
vectors_flat,
|
|
||||||
norms,
|
|
||||||
tombstones,
|
|
||||||
query.len(),
|
|
||||||
k,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "fast-math"))]
|
#[cfg(not(feature = "fast-math"))]
|
||||||
{
|
{
|
||||||
@@ -229,13 +211,8 @@ pub fn search_with_metrics(
|
|||||||
gpu_active = false;
|
gpu_active = false;
|
||||||
#[cfg(any(feature = "accelerate", feature = "openblas"))]
|
#[cfg(any(feature = "accelerate", feature = "openblas"))]
|
||||||
{
|
{
|
||||||
crate::accelerate_search::accelerate_cosine_batch(
|
crate::accelerate_search::accelerate_cosine_batch_vecs(
|
||||||
query,
|
query, vectors, norms, tombstones, k,
|
||||||
vectors_flat,
|
|
||||||
norms,
|
|
||||||
tombstones,
|
|
||||||
query.len(),
|
|
||||||
k,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
#[cfg(not(any(feature = "accelerate", feature = "openblas")))]
|
#[cfg(not(any(feature = "accelerate", feature = "openblas")))]
|
||||||
@@ -348,10 +325,6 @@ mod tests {
|
|||||||
(0..n).map(|_| (0..dim).map(|_| next()).collect()).collect()
|
(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 ---
|
// --- auto_select_strategy tests ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -517,7 +490,6 @@ mod tests {
|
|||||||
let (results, metrics) = search_with_metrics(
|
let (results, metrics) = search_with_metrics(
|
||||||
&query,
|
&query,
|
||||||
&vectors,
|
&vectors,
|
||||||
&flatten(&vectors),
|
|
||||||
&norms,
|
&norms,
|
||||||
&tombstones,
|
&tombstones,
|
||||||
5,
|
5,
|
||||||
@@ -548,7 +520,6 @@ mod tests {
|
|||||||
let (results, metrics) = search_with_metrics(
|
let (results, metrics) = search_with_metrics(
|
||||||
&query,
|
&query,
|
||||||
&vectors,
|
&vectors,
|
||||||
&flatten(&vectors),
|
|
||||||
&norms,
|
&norms,
|
||||||
&tombstones,
|
&tombstones,
|
||||||
10,
|
10,
|
||||||
@@ -574,7 +545,6 @@ mod tests {
|
|||||||
let (_, metrics) = search_with_metrics(
|
let (_, metrics) = search_with_metrics(
|
||||||
&query,
|
&query,
|
||||||
&vectors,
|
&vectors,
|
||||||
&flatten(&vectors),
|
|
||||||
&norms,
|
&norms,
|
||||||
&tombstones,
|
&tombstones,
|
||||||
10,
|
10,
|
||||||
@@ -600,7 +570,6 @@ mod tests {
|
|||||||
let (results, _) = search_with_metrics(
|
let (results, _) = search_with_metrics(
|
||||||
&query,
|
&query,
|
||||||
&vectors,
|
&vectors,
|
||||||
&flatten(&vectors),
|
|
||||||
&norms,
|
&norms,
|
||||||
&tombstones,
|
&tombstones,
|
||||||
10,
|
10,
|
||||||
@@ -634,7 +603,6 @@ mod tests {
|
|||||||
let (results, metrics) = search_with_metrics(
|
let (results, metrics) = search_with_metrics(
|
||||||
&query,
|
&query,
|
||||||
&vectors,
|
&vectors,
|
||||||
&flatten(&vectors),
|
|
||||||
&norms,
|
&norms,
|
||||||
&tombstones,
|
&tombstones,
|
||||||
100,
|
100,
|
||||||
@@ -679,7 +647,6 @@ mod tests {
|
|||||||
let (_, metrics) = search_with_metrics(
|
let (_, metrics) = search_with_metrics(
|
||||||
&query,
|
&query,
|
||||||
&vectors,
|
&vectors,
|
||||||
&flatten(&vectors),
|
|
||||||
&norms,
|
&norms,
|
||||||
&tombstones,
|
&tombstones,
|
||||||
5,
|
5,
|
||||||
@@ -751,7 +718,6 @@ mod tests {
|
|||||||
let (results, metrics) = search_with_metrics(
|
let (results, metrics) = search_with_metrics(
|
||||||
&query,
|
&query,
|
||||||
&vectors,
|
&vectors,
|
||||||
&flatten(&vectors),
|
|
||||||
&norms,
|
&norms,
|
||||||
&tombstones,
|
&tombstones,
|
||||||
10,
|
10,
|
||||||
@@ -778,7 +744,6 @@ mod tests {
|
|||||||
let (results, metrics) = search_with_metrics(
|
let (results, metrics) = search_with_metrics(
|
||||||
&query,
|
&query,
|
||||||
&vectors,
|
&vectors,
|
||||||
&flatten(&vectors),
|
|
||||||
&norms,
|
&norms,
|
||||||
&tombstones,
|
&tombstones,
|
||||||
10,
|
10,
|
||||||
@@ -857,7 +822,6 @@ mod tests {
|
|||||||
let (results, metrics) = search_with_metrics(
|
let (results, metrics) = search_with_metrics(
|
||||||
&query,
|
&query,
|
||||||
&vectors,
|
&vectors,
|
||||||
&flatten(&vectors),
|
|
||||||
&norms,
|
&norms,
|
||||||
&tombstones,
|
&tombstones,
|
||||||
10,
|
10,
|
||||||
|
|||||||
@@ -4,44 +4,6 @@
|
|||||||
//! `clawhdf5_accel`, with optional float16 support via the `half` crate.
|
//! `clawhdf5_accel`, with optional float16 support via the `half` crate.
|
||||||
//! Supports pre-computed norms for eliminating redundant norm computations.
|
//! Supports pre-computed norms for eliminating redundant norm computations.
|
||||||
|
|
||||||
/// A corpus of equal-length embeddings addressable by index.
|
|
||||||
///
|
|
||||||
/// Lets the batch kernels read either the cache's flat `[N x dim]` buffer or a
|
|
||||||
/// plain `Vec<Vec<f32>>` without either side owning a second copy.
|
|
||||||
pub trait VectorSet {
|
|
||||||
/// Number of embeddings.
|
|
||||||
fn count(&self) -> usize;
|
|
||||||
/// Embedding `i`; callers only index below [`VectorSet::count`].
|
|
||||||
fn row(&self, i: usize) -> &[f32];
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VectorSet for [Vec<f32>] {
|
|
||||||
fn count(&self) -> usize {
|
|
||||||
self.len()
|
|
||||||
}
|
|
||||||
fn row(&self, i: usize) -> &[f32] {
|
|
||||||
&self[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VectorSet for Vec<Vec<f32>> {
|
|
||||||
fn count(&self) -> usize {
|
|
||||||
self.len()
|
|
||||||
}
|
|
||||||
fn row(&self, i: usize) -> &[f32] {
|
|
||||||
&self[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VectorSet for crate::cache::Embeddings {
|
|
||||||
fn count(&self) -> usize {
|
|
||||||
self.len()
|
|
||||||
}
|
|
||||||
fn row(&self, i: usize) -> &[f32] {
|
|
||||||
&self[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute cosine similarity between two f32 slices.
|
/// Compute cosine similarity between two f32 slices.
|
||||||
///
|
///
|
||||||
/// Returns 0.0 if either vector has zero magnitude.
|
/// Returns 0.0 if either vector has zero magnitude.
|
||||||
@@ -60,7 +22,7 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
|||||||
/// Returns `(index, score)` pairs sorted by score descending.
|
/// Returns `(index, score)` pairs sorted by score descending.
|
||||||
pub fn cosine_similarity_batch(
|
pub fn cosine_similarity_batch(
|
||||||
query: &[f32],
|
query: &[f32],
|
||||||
vectors: &(impl VectorSet + ?Sized),
|
vectors: &[Vec<f32>],
|
||||||
tombstones: &[u8],
|
tombstones: &[u8],
|
||||||
) -> Vec<(usize, f32)> {
|
) -> Vec<(usize, f32)> {
|
||||||
let query_norm = clawhdf5_accel::vector_norm(query);
|
let query_norm = clawhdf5_accel::vector_norm(query);
|
||||||
@@ -68,7 +30,7 @@ pub fn cosine_similarity_batch(
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let n = vectors.count();
|
let n = vectors.len();
|
||||||
let mut results: Vec<(usize, f32)> = Vec::with_capacity(n);
|
let mut results: Vec<(usize, f32)> = Vec::with_capacity(n);
|
||||||
|
|
||||||
// Process 4 vectors at a time where possible
|
// Process 4 vectors at a time where possible
|
||||||
@@ -80,9 +42,8 @@ pub fn cosine_similarity_batch(
|
|||||||
if i < tombstones.len() && tombstones[i] != 0 {
|
if i < tombstones.len() && tombstones[i] != 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let vec_norm = clawhdf5_accel::vector_norm(vectors.row(i));
|
let vec_norm = clawhdf5_accel::vector_norm(&vectors[i]);
|
||||||
let score =
|
let score = crate::cosine_similarity_prenorm(query, query_norm, &vectors[i], vec_norm);
|
||||||
crate::cosine_similarity_prenorm(query, query_norm, vectors.row(i), vec_norm);
|
|
||||||
results.push((i, score));
|
results.push((i, score));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,8 +53,8 @@ pub fn cosine_similarity_batch(
|
|||||||
if i < tombstones.len() && tombstones[i] != 0 {
|
if i < tombstones.len() && tombstones[i] != 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let vec_norm = clawhdf5_accel::vector_norm(vectors.row(i));
|
let vec_norm = clawhdf5_accel::vector_norm(&vectors[i]);
|
||||||
let score = crate::cosine_similarity_prenorm(query, query_norm, vectors.row(i), vec_norm);
|
let score = crate::cosine_similarity_prenorm(query, query_norm, &vectors[i], vec_norm);
|
||||||
results.push((i, score));
|
results.push((i, score));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +68,7 @@ pub fn cosine_similarity_batch(
|
|||||||
/// collections. Uses `score = dot(query, vec) / (query_norm * stored_norm)`.
|
/// collections. Uses `score = dot(query, vec) / (query_norm * stored_norm)`.
|
||||||
pub fn cosine_similarity_batch_prenorm(
|
pub fn cosine_similarity_batch_prenorm(
|
||||||
query: &[f32],
|
query: &[f32],
|
||||||
vectors: &(impl VectorSet + ?Sized),
|
vectors: &[Vec<f32>],
|
||||||
norms: &[f32],
|
norms: &[f32],
|
||||||
tombstones: &[u8],
|
tombstones: &[u8],
|
||||||
) -> Vec<(usize, f32)> {
|
) -> Vec<(usize, f32)> {
|
||||||
@@ -116,7 +77,7 @@ pub fn cosine_similarity_batch_prenorm(
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let n = vectors.count();
|
let n = vectors.len();
|
||||||
let mut results: Vec<(usize, f32)> = Vec::with_capacity(n);
|
let mut results: Vec<(usize, f32)> = Vec::with_capacity(n);
|
||||||
|
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
@@ -124,7 +85,7 @@ pub fn cosine_similarity_batch_prenorm(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let vec_norm = norms[i];
|
let vec_norm = norms[i];
|
||||||
let score = crate::cosine_similarity_prenorm(query, query_norm, vectors.row(i), vec_norm);
|
let score = crate::cosine_similarity_prenorm(query, query_norm, &vectors[i], vec_norm);
|
||||||
results.push((i, score));
|
results.push((i, score));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +162,7 @@ pub fn cosine_similarity_f16(
|
|||||||
#[cfg(feature = "parallel")]
|
#[cfg(feature = "parallel")]
|
||||||
pub fn parallel_cosine_batch(
|
pub fn parallel_cosine_batch(
|
||||||
query: &[f32],
|
query: &[f32],
|
||||||
vectors: &(impl VectorSet + Sync + ?Sized),
|
vectors: &[Vec<f32>],
|
||||||
tombstones: &[u8],
|
tombstones: &[u8],
|
||||||
k: usize,
|
k: usize,
|
||||||
) -> Vec<(usize, f32)> {
|
) -> Vec<(usize, f32)> {
|
||||||
@@ -213,27 +174,24 @@ pub fn parallel_cosine_batch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let num_cores = rayon::current_num_threads().max(1);
|
let num_cores = rayon::current_num_threads().max(1);
|
||||||
let chunk_size = vectors.count().div_ceil(num_cores);
|
let chunk_size = vectors.len().div_ceil(num_cores);
|
||||||
if chunk_size == 0 {
|
if chunk_size == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chunk over index ranges: the corpus may be one flat buffer rather than
|
let mut all_results: Vec<(usize, f32)> = vectors
|
||||||
// a slice of rows, so there is nothing to `par_chunks` over.
|
.par_chunks(chunk_size)
|
||||||
let n = vectors.count();
|
.enumerate()
|
||||||
let mut all_results: Vec<(usize, f32)> = (0..n.div_ceil(chunk_size))
|
.flat_map(|(chunk_idx, chunk)| {
|
||||||
.into_par_iter()
|
|
||||||
.flat_map(|chunk_idx| {
|
|
||||||
let base = chunk_idx * chunk_size;
|
let base = chunk_idx * chunk_size;
|
||||||
let end = (base + chunk_size).min(n);
|
let mut local: Vec<(usize, f32)> = Vec::with_capacity(chunk.len());
|
||||||
let mut local: Vec<(usize, f32)> = Vec::with_capacity(end - base);
|
for (j, vec) in chunk.iter().enumerate() {
|
||||||
for i in base..end {
|
let i = base + j;
|
||||||
if i < tombstones.len() && tombstones[i] != 0 {
|
if i < tombstones.len() && tombstones[i] != 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let vec_norm = clawhdf5_accel::vector_norm(vectors.row(i));
|
let vec_norm = clawhdf5_accel::vector_norm(vec);
|
||||||
let score =
|
let score = crate::cosine_similarity_prenorm(query, query_norm, vec, vec_norm);
|
||||||
crate::cosine_similarity_prenorm(query, query_norm, vectors.row(i), vec_norm);
|
|
||||||
local.push((i, score));
|
local.push((i, score));
|
||||||
}
|
}
|
||||||
local.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
local.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
@@ -251,7 +209,7 @@ pub fn parallel_cosine_batch(
|
|||||||
#[cfg(feature = "parallel")]
|
#[cfg(feature = "parallel")]
|
||||||
pub fn parallel_cosine_batch_prenorm(
|
pub fn parallel_cosine_batch_prenorm(
|
||||||
query: &[f32],
|
query: &[f32],
|
||||||
vectors: &(impl VectorSet + Sync + ?Sized),
|
vectors: &[Vec<f32>],
|
||||||
norms: &[f32],
|
norms: &[f32],
|
||||||
tombstones: &[u8],
|
tombstones: &[u8],
|
||||||
k: usize,
|
k: usize,
|
||||||
@@ -264,26 +222,23 @@ pub fn parallel_cosine_batch_prenorm(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let num_cores = rayon::current_num_threads().max(1);
|
let num_cores = rayon::current_num_threads().max(1);
|
||||||
let chunk_size = vectors.count().div_ceil(num_cores);
|
let chunk_size = vectors.len().div_ceil(num_cores);
|
||||||
if chunk_size == 0 {
|
if chunk_size == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chunk over index ranges: the corpus may be one flat buffer rather than
|
let mut all_results: Vec<(usize, f32)> = vectors
|
||||||
// a slice of rows, so there is nothing to `par_chunks` over.
|
.par_chunks(chunk_size)
|
||||||
let n = vectors.count();
|
.enumerate()
|
||||||
let mut all_results: Vec<(usize, f32)> = (0..n.div_ceil(chunk_size))
|
.flat_map(|(chunk_idx, chunk)| {
|
||||||
.into_par_iter()
|
|
||||||
.flat_map(|chunk_idx| {
|
|
||||||
let base = chunk_idx * chunk_size;
|
let base = chunk_idx * chunk_size;
|
||||||
let end = (base + chunk_size).min(n);
|
let mut local: Vec<(usize, f32)> = Vec::with_capacity(chunk.len());
|
||||||
let mut local: Vec<(usize, f32)> = Vec::with_capacity(end - base);
|
for (j, vec) in chunk.iter().enumerate() {
|
||||||
for i in base..end {
|
let i = base + j;
|
||||||
if i < tombstones.len() && tombstones[i] != 0 {
|
if i < tombstones.len() && tombstones[i] != 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let score =
|
let score = crate::cosine_similarity_prenorm(query, query_norm, vec, norms[i]);
|
||||||
crate::cosine_similarity_prenorm(query, query_norm, vectors.row(i), norms[i]);
|
|
||||||
local.push((i, score));
|
local.push((i, score));
|
||||||
}
|
}
|
||||||
local.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
local.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|||||||
@@ -13,57 +13,16 @@ use crate::MemoryError;
|
|||||||
|
|
||||||
const WAL_MAGIC: [u8; 4] = [0x45, 0x48, 0x57, 0x4C]; // "EHWL"
|
const WAL_MAGIC: [u8; 4] = [0x45, 0x48, 0x57, 0x4C]; // "EHWL"
|
||||||
|
|
||||||
/// Bytes before the first entry: [`WAL_MAGIC`] (4) + version (1) + entry
|
/// Current WAL format version: every entry ends with a 4-byte CRC32 trailer
|
||||||
/// count (4). Named so the offset arithmetic in `open()` — which decides
|
/// (see [`TeeReader`]) so a bit-flip is detected and replay stops there
|
||||||
/// where an append lands, and therefore whether it is replayable — reads as
|
/// instead of silently accepting corrupted data.
|
||||||
/// a header length rather than a bare 9.
|
const WAL_VERSION: u8 = 2;
|
||||||
const WAL_HEADER_LEN: u64 = WAL_MAGIC.len() as u64 + 1 + 4;
|
|
||||||
|
|
||||||
/// Current WAL format version: every entry's CRC32 trailer is computed over
|
/// The only other WAL version this crate still knows how to *read*: no
|
||||||
/// its own bytes *chained with the previous entry's stored CRC*
|
/// per-entry CRC trailer. Written by versions of this crate before the CRC32
|
||||||
/// (`crc32(entry_bytes ++ prev_crc.to_le_bytes())`, seeded with 0 for the
|
/// hardening. `WalFile::open` migrates a legacy file to [`WAL_VERSION`] by
|
||||||
/// first entry after a truncation). A per-entry CRC alone only detects a
|
/// recreating it fresh — safe because every real call site reads existing
|
||||||
/// bit-flip within that entry; chaining additionally detects entries being
|
/// entries via [`WalFile::read_entries`] before calling `open` (see
|
||||||
/// reordered, duplicated, or spliced (e.g. a Tombstone moved before/after
|
|
||||||
/// its target Save) — the moved/inserted entry's stored CRC was computed
|
|
||||||
/// against a different predecessor than the one now in front of it on disk,
|
|
||||||
/// so the chain breaks at that point and replay stops there.
|
|
||||||
const WAL_VERSION: u8 = 4;
|
|
||||||
|
|
||||||
/// The chained-CRC format before [`WalEntryType::Update`] records existed.
|
|
||||||
/// Byte-for-byte the same framing as [`WAL_VERSION`], so it is read by the
|
|
||||||
/// same code, and `WalFile::open` upgrades it in place by rewriting the
|
|
||||||
/// header's version byte (the header is not covered by the CRC chain).
|
|
||||||
///
|
|
||||||
/// The bump exists for *older binaries*: they don't know record type 0x04,
|
|
||||||
/// would treat it as a torn tail, and would truncate it — and everything
|
|
||||||
/// after it — away. An unknown header version makes them refuse the file
|
|
||||||
/// with a clear error instead.
|
|
||||||
const WAL_VERSION_CHAINED_NO_UPDATE: u8 = 3;
|
|
||||||
|
|
||||||
/// The previous WAL format version: still a CRC32 per entry (so a bit-flip
|
|
||||||
/// within one entry is caught), but not chained to the previous entry's CRC
|
|
||||||
/// (so reordering/splicing whole entries is not detected). Written by
|
|
||||||
/// versions of this crate before the chaining hardening. Fully supported for
|
|
||||||
/// reading via [`WalFile::read_entries`] — not restricted like
|
|
||||||
/// [`WAL_VERSION_LEGACY_NO_CRC`], since it still verifies each entry
|
|
||||||
/// individually. `WalFile::open` migrates it to [`WAL_VERSION`] by
|
|
||||||
/// recreating the file fresh, the same as the legacy-no-CRC migration below.
|
|
||||||
const WAL_VERSION_CRC_UNCHAINED: u8 = 2;
|
|
||||||
|
|
||||||
/// The oldest WAL version this crate still knows how to *read*: no
|
|
||||||
/// per-entry CRC trailer at all, so a bit-flip anywhere is silently
|
|
||||||
/// accepted. Written by versions of this crate before the CRC32 hardening.
|
|
||||||
/// Because of that — unlike [`WAL_VERSION_CRC_UNCHAINED`] — this version is
|
|
||||||
/// deliberately *not* reachable through the public [`WalFile::read_entries`]
|
|
||||||
/// API; only [`WalFile::read_entries_for_migration`] (used exclusively by
|
|
||||||
/// `HDF5Memory::open`'s one-time migration path) will parse it. Flipping a
|
|
||||||
/// version byte from 2/3 down to 1 no longer silently downgrades a file to
|
|
||||||
/// the fully-unverified parser for an arbitrary caller.
|
|
||||||
///
|
|
||||||
/// `WalFile::open` migrates a legacy file to [`WAL_VERSION`] by recreating
|
|
||||||
/// it fresh — safe because every real call site reads existing entries via
|
|
||||||
/// [`WalFile::read_entries_for_migration`] before calling `open` (see
|
|
||||||
/// `HDF5Memory::open`), so no data is lost.
|
/// `HDF5Memory::open`), so no data is lost.
|
||||||
const WAL_VERSION_LEGACY_NO_CRC: u8 = 1;
|
const WAL_VERSION_LEGACY_NO_CRC: u8 = 1;
|
||||||
|
|
||||||
@@ -78,10 +37,6 @@ pub enum WalEntryType {
|
|||||||
Save = 0x01,
|
Save = 0x01,
|
||||||
Tombstone = 0x02,
|
Tombstone = 0x02,
|
||||||
ActivationUpdate = 0x03,
|
ActivationUpdate = 0x03,
|
||||||
/// Replace the record at `update_index` in place (`save_or_update` hit).
|
|
||||||
/// Logged as a plain `Save` before this existed, so replay appended a
|
|
||||||
/// duplicate instead of updating.
|
|
||||||
Update = 0x04,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WalEntryType {
|
impl WalEntryType {
|
||||||
@@ -90,7 +45,6 @@ impl WalEntryType {
|
|||||||
0x01 => Some(Self::Save),
|
0x01 => Some(Self::Save),
|
||||||
0x02 => Some(Self::Tombstone),
|
0x02 => Some(Self::Tombstone),
|
||||||
0x03 => Some(Self::ActivationUpdate),
|
0x03 => Some(Self::ActivationUpdate),
|
||||||
0x04 => Some(Self::Update),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,8 +61,6 @@ pub struct WalEntry {
|
|||||||
pub tags: String,
|
pub tags: String,
|
||||||
/// For tombstone entries: the index of the entry to delete.
|
/// For tombstone entries: the index of the entry to delete.
|
||||||
pub tombstone_index: Option<usize>,
|
pub tombstone_index: Option<usize>,
|
||||||
/// For update entries: the index of the record to replace.
|
|
||||||
pub update_index: Option<usize>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many entries to accumulate before updating the header entry_count.
|
/// How many entries to accumulate before updating the header entry_count.
|
||||||
@@ -125,77 +77,15 @@ pub struct WalFile {
|
|||||||
entry_count: u32,
|
entry_count: u32,
|
||||||
/// Entries written since the last header count update.
|
/// Entries written since the last header count update.
|
||||||
pending_header_sync: u32,
|
pending_header_sync: u32,
|
||||||
/// CRC32 chain state: the previous entry's stored CRC (0 if this file
|
|
||||||
/// has no entries yet), folded into the next entry's CRC computation.
|
|
||||||
/// Reset to 0 by `truncate()`/`create_fresh_wal_file`, and re-derived by
|
|
||||||
/// scanning existing entries when `open()` attaches to a non-empty file.
|
|
||||||
running_crc: u32,
|
|
||||||
/// Bytes of verified entries after the header (the length of the chain
|
|
||||||
/// `running_crc` covers). Together they form the [`WalMark`].
|
|
||||||
chain_len: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a WAL file's 9-byte header looks like, without reading any entries.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum WalHeaderStatus {
|
|
||||||
/// A version this build can read (current or legacy).
|
|
||||||
Readable,
|
|
||||||
/// Shorter than a header — e.g. a crash while the file was being created.
|
|
||||||
/// It cannot contain entries.
|
|
||||||
Torn,
|
|
||||||
/// Not a WAL file at all.
|
|
||||||
BadMagic,
|
|
||||||
/// Well-formed header from a version this build doesn't know — most
|
|
||||||
/// likely written by a *newer* build. Never discard this: the entries are
|
|
||||||
/// probably fine, this binary just can't read them.
|
|
||||||
UnknownVersion(u8),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classify the header of the WAL at `path`.
|
|
||||||
pub fn wal_header_status(path: &Path) -> std::io::Result<WalHeaderStatus> {
|
|
||||||
let mut header = [0u8; WAL_HEADER_LEN as usize];
|
|
||||||
let mut f = File::open(path)?;
|
|
||||||
let mut filled = 0;
|
|
||||||
while filled < header.len() {
|
|
||||||
match f.read(&mut header[filled..])? {
|
|
||||||
0 => return Ok(WalHeaderStatus::Torn),
|
|
||||||
n => filled += n,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if header[0..4] != WAL_MAGIC {
|
|
||||||
return Ok(WalHeaderStatus::BadMagic);
|
|
||||||
}
|
|
||||||
Ok(match header[4] {
|
|
||||||
WAL_VERSION
|
|
||||||
| WAL_VERSION_CHAINED_NO_UPDATE
|
|
||||||
| WAL_VERSION_CRC_UNCHAINED
|
|
||||||
| WAL_VERSION_LEGACY_NO_CRC => WalHeaderStatus::Readable,
|
|
||||||
v => WalHeaderStatus::UnknownVersion(v),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A position in a WAL's CRC chain: `len` bytes of entries after the header,
|
|
||||||
/// whose chained CRC is `crc`.
|
|
||||||
///
|
|
||||||
/// A checkpoint stores the mark of the WAL prefix it folded into the `.h5`
|
|
||||||
/// file. If the process dies after the new `.h5` is in place but before the
|
|
||||||
/// WAL is truncated, the next `open()` finds that exact prefix still in the
|
|
||||||
/// WAL and skips it instead of replaying it on top of data that already
|
|
||||||
/// contains it (which used to duplicate every pending entry).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct WalMark {
|
|
||||||
pub len: u64,
|
|
||||||
pub crc: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WalFile {
|
impl WalFile {
|
||||||
/// Open or create a WAL file. If it exists, read the header and entry count.
|
/// Open or create a WAL file. If it exists, read the header and entry count.
|
||||||
///
|
///
|
||||||
/// A pre-chaining WAL file ([`WAL_VERSION_CRC_UNCHAINED`] or
|
/// A legacy (pre-CRC) WAL file is migrated to the current format by
|
||||||
/// [`WAL_VERSION_LEGACY_NO_CRC`]) is migrated to the current format by
|
/// recreating it fresh — see [`WAL_VERSION_LEGACY_NO_CRC`]. Callers that
|
||||||
/// recreating it fresh. Callers that need an existing file's entries must
|
/// need the legacy file's entries must call [`WalFile::read_entries`]
|
||||||
/// call [`WalFile::read_entries`] (or, for a legacy-no-CRC file,
|
/// first, before calling `open`.
|
||||||
/// [`WalFile::read_entries_for_migration`]) first, before calling `open`.
|
|
||||||
pub fn open(path: &Path) -> Result<Self, MemoryError> {
|
pub fn open(path: &Path) -> Result<Self, MemoryError> {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
// Read existing header
|
// Read existing header
|
||||||
@@ -212,71 +102,20 @@ impl WalFile {
|
|||||||
let mut ver = [0u8; 1];
|
let mut ver = [0u8; 1];
|
||||||
f.read_exact(&mut ver)?;
|
f.read_exact(&mut ver)?;
|
||||||
match ver[0] {
|
match ver[0] {
|
||||||
WAL_VERSION | WAL_VERSION_CHAINED_NO_UPDATE => {
|
WAL_VERSION => {
|
||||||
if ver[0] == WAL_VERSION_CHAINED_NO_UPDATE {
|
|
||||||
// Same framing; stamp the current version so an older
|
|
||||||
// binary refuses this file rather than truncating an
|
|
||||||
// Update record it can't parse. See the constant.
|
|
||||||
f.seek(SeekFrom::Start(4))?;
|
|
||||||
f.write_all(&[WAL_VERSION])?;
|
|
||||||
f.seek(SeekFrom::Start(5))?;
|
|
||||||
}
|
|
||||||
let mut count_buf = [0u8; 4];
|
let mut count_buf = [0u8; 4];
|
||||||
f.read_exact(&mut count_buf)?;
|
f.read_exact(&mut count_buf)?;
|
||||||
let header_count = u32::from_le_bytes(count_buf);
|
let entry_count = u32::from_le_bytes(count_buf);
|
||||||
// Scan any existing entries to resume the CRC chain
|
// Seek to end for appending
|
||||||
// correctly for further appends (the header's count may
|
f.seek(SeekFrom::End(0))?;
|
||||||
// be stale from deferred group-commit sync, same
|
|
||||||
// tolerance `read_entries` already has, so the scanned
|
|
||||||
// count is also the more accurate of the two).
|
|
||||||
let (entries, running_crc, verified_bytes) =
|
|
||||||
read_chained_entries(&mut f, 0, None);
|
|
||||||
let entry_count = if entries.is_empty() {
|
|
||||||
header_count
|
|
||||||
} else {
|
|
||||||
entries.len() as u32
|
|
||||||
};
|
|
||||||
// Position the append at the end of the VERIFIED prefix,
|
|
||||||
// and drop anything after it.
|
|
||||||
//
|
|
||||||
// This used to `seek(End(0))`, which appends PAST a torn
|
|
||||||
// tail — the ordinary outcome of a crash mid-append. The
|
|
||||||
// new entry is then chained to the last good entry, but
|
|
||||||
// sits on disk behind the garbage:
|
|
||||||
//
|
|
||||||
// [1..N verified][torn bytes][N+1 chained to N]
|
|
||||||
//
|
|
||||||
// Replay stops at the torn bytes, so N+1 is unreachable
|
|
||||||
// FOREVER even though its `append` returned Ok and synced.
|
|
||||||
// That is silent data loss in the one situation a WAL
|
|
||||||
// exists for. Truncating to the verified end is the
|
|
||||||
// standard recovery: the torn tail was never acknowledged
|
|
||||||
// to any caller, so discarding it loses nothing, and the
|
|
||||||
// chain then continues from a byte offset that matches
|
|
||||||
// `running_crc`.
|
|
||||||
let verified_end = WAL_HEADER_LEN + verified_bytes;
|
|
||||||
let file_len = f.metadata()?.len();
|
|
||||||
if file_len > verified_end {
|
|
||||||
eprintln!(
|
|
||||||
"clawhdf5-agent: WAL {} has {} unverifiable byte(s) after entry {}; \
|
|
||||||
discarding them so appends stay replayable",
|
|
||||||
path.display(),
|
|
||||||
file_len - verified_end,
|
|
||||||
entries.len()
|
|
||||||
);
|
|
||||||
f.set_len(verified_end)?;
|
|
||||||
}
|
|
||||||
f.seek(SeekFrom::Start(verified_end))?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
file: Some(f),
|
file: Some(f),
|
||||||
entry_count,
|
entry_count,
|
||||||
pending_header_sync: 0,
|
pending_header_sync: 0,
|
||||||
running_crc,
|
|
||||||
chain_len: verified_bytes,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
WAL_VERSION_CRC_UNCHAINED | WAL_VERSION_LEGACY_NO_CRC => {
|
WAL_VERSION_LEGACY_NO_CRC => {
|
||||||
drop(f);
|
drop(f);
|
||||||
let f = create_fresh_wal_file(path)?;
|
let f = create_fresh_wal_file(path)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -284,8 +123,6 @@ impl WalFile {
|
|||||||
file: Some(f),
|
file: Some(f),
|
||||||
entry_count: 0,
|
entry_count: 0,
|
||||||
pending_header_sync: 0,
|
pending_header_sync: 0,
|
||||||
running_crc: 0,
|
|
||||||
chain_len: 0,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
v => Err(MemoryError::Schema(format!("unsupported WAL version {v}"))),
|
v => Err(MemoryError::Schema(format!("unsupported WAL version {v}"))),
|
||||||
@@ -297,8 +134,6 @@ impl WalFile {
|
|||||||
file: Some(f),
|
file: Some(f),
|
||||||
entry_count: 0,
|
entry_count: 0,
|
||||||
pending_header_sync: 0,
|
pending_header_sync: 0,
|
||||||
running_crc: 0,
|
|
||||||
chain_len: 0,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,20 +157,8 @@ impl WalFile {
|
|||||||
4 + entry.session_id.len() +
|
4 + entry.session_id.len() +
|
||||||
4 + entry.tags.len(),
|
4 + entry.tags.len(),
|
||||||
);
|
);
|
||||||
match entry.update_index {
|
|
||||||
Some(index) => {
|
|
||||||
let index = u32::try_from(index).map_err(|_| {
|
|
||||||
MemoryError::Schema(format!("WAL update index {index} exceeds u32"))
|
|
||||||
})?;
|
|
||||||
buf.push(WalEntryType::Update as u8);
|
|
||||||
buf.extend_from_slice(&entry.timestamp.to_le_bytes());
|
|
||||||
buf.extend_from_slice(&index.to_le_bytes());
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
buf.push(WalEntryType::Save as u8);
|
buf.push(WalEntryType::Save as u8);
|
||||||
buf.extend_from_slice(&entry.timestamp.to_le_bytes());
|
buf.extend_from_slice(&entry.timestamp.to_le_bytes());
|
||||||
}
|
|
||||||
}
|
|
||||||
serialize_str(&mut buf, &entry.chunk);
|
serialize_str(&mut buf, &entry.chunk);
|
||||||
buf.extend_from_slice(&(emb_len as u32).to_le_bytes());
|
buf.extend_from_slice(&(emb_len as u32).to_le_bytes());
|
||||||
for &val in &entry.embedding {
|
for &val in &entry.embedding {
|
||||||
@@ -345,10 +168,7 @@ impl WalFile {
|
|||||||
serialize_str(&mut buf, &entry.session_id);
|
serialize_str(&mut buf, &entry.session_id);
|
||||||
serialize_str(&mut buf, &entry.tags);
|
serialize_str(&mut buf, &entry.tags);
|
||||||
|
|
||||||
// Chain this entry's CRC to the previous one's so reordering/
|
let crc = crc32(&buf);
|
||||||
// splicing entries (not just flipping a bit within one) is detected
|
|
||||||
// on replay — see WAL_VERSION's doc comment.
|
|
||||||
let crc = chained_crc(&buf, self.running_crc);
|
|
||||||
buf.extend_from_slice(&crc.to_le_bytes());
|
buf.extend_from_slice(&crc.to_le_bytes());
|
||||||
|
|
||||||
let f = self
|
let f = self
|
||||||
@@ -356,9 +176,7 @@ impl WalFile {
|
|||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
|
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
|
||||||
f.write_all(&buf)?;
|
f.write_all(&buf)?;
|
||||||
self.chain_len += buf.len() as u64;
|
|
||||||
|
|
||||||
self.running_crc = crc;
|
|
||||||
self.entry_count += 1;
|
self.entry_count += 1;
|
||||||
self.pending_header_sync += 1;
|
self.pending_header_sync += 1;
|
||||||
if self.pending_header_sync >= GROUP_COMMIT_SIZE {
|
if self.pending_header_sync >= GROUP_COMMIT_SIZE {
|
||||||
@@ -373,7 +191,7 @@ impl WalFile {
|
|||||||
buf[0] = WalEntryType::Tombstone as u8;
|
buf[0] = WalEntryType::Tombstone as u8;
|
||||||
buf[1..9].copy_from_slice(×tamp.to_le_bytes());
|
buf[1..9].copy_from_slice(×tamp.to_le_bytes());
|
||||||
buf[9..13].copy_from_slice(&(index as u32).to_le_bytes());
|
buf[9..13].copy_from_slice(&(index as u32).to_le_bytes());
|
||||||
let crc = chained_crc(&buf[..13], self.running_crc);
|
let crc = crc32(&buf[..13]);
|
||||||
buf[13..17].copy_from_slice(&crc.to_le_bytes());
|
buf[13..17].copy_from_slice(&crc.to_le_bytes());
|
||||||
|
|
||||||
let f = self
|
let f = self
|
||||||
@@ -381,9 +199,7 @@ impl WalFile {
|
|||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
|
.ok_or_else(|| MemoryError::Io(std::io::Error::other("WAL file not open")))?;
|
||||||
f.write_all(&buf)?;
|
f.write_all(&buf)?;
|
||||||
self.chain_len += buf.len() as u64;
|
|
||||||
|
|
||||||
self.running_crc = crc;
|
|
||||||
self.entry_count += 1;
|
self.entry_count += 1;
|
||||||
self.pending_header_sync += 1;
|
self.pending_header_sync += 1;
|
||||||
if self.pending_header_sync >= GROUP_COMMIT_SIZE {
|
if self.pending_header_sync >= GROUP_COMMIT_SIZE {
|
||||||
@@ -398,46 +214,9 @@ impl WalFile {
|
|||||||
/// (and may be stale if written with deferred group-commit updates). This
|
/// (and may be stale if written with deferred group-commit updates). This
|
||||||
/// tolerates both truncated files (crash mid-write) and stale header counts
|
/// tolerates both truncated files (crash mid-write) and stale header counts
|
||||||
/// (crash before the next group-commit header sync). On a `WAL_VERSION`
|
/// (crash before the next group-commit header sync). On a `WAL_VERSION`
|
||||||
/// file, a broken CRC chain (bit-flip, or an entry reordered/duplicated/
|
/// file, a CRC32 mismatch on an entry is treated the same way — replay
|
||||||
/// spliced in) is treated the same way — replay stops there rather than
|
/// stops there rather than accepting corrupted data.
|
||||||
/// accepting corrupted or tampered data. `WAL_VERSION_CRC_UNCHAINED`
|
|
||||||
/// files are read the same way minus the chain check (each entry's own
|
|
||||||
/// CRC is still verified).
|
|
||||||
///
|
|
||||||
/// Does **not** read [`WAL_VERSION_LEGACY_NO_CRC`] files — that format has
|
|
||||||
/// no integrity verification at all, so it's only reachable through
|
|
||||||
/// [`WalFile::read_entries_for_migration`], used exclusively by
|
|
||||||
/// `HDF5Memory::open`'s one-time migration path. Calling this on a
|
|
||||||
/// legacy-no-CRC file returns a typed error instead of silently
|
|
||||||
/// downgrading to the unverified parser.
|
|
||||||
pub fn read_entries(path: &Path) -> Result<Vec<WalEntry>, MemoryError> {
|
pub fn read_entries(path: &Path) -> Result<Vec<WalEntry>, MemoryError> {
|
||||||
Self::read_entries_impl(path, false, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [`WalFile::read_entries`], but also accepts
|
|
||||||
/// [`WAL_VERSION_LEGACY_NO_CRC`] files (no per-entry integrity check at
|
|
||||||
/// all). Restricted to `pub(crate)` and named accordingly: the only
|
|
||||||
/// legitimate caller is `HDF5Memory::open`'s one-time migration of a
|
|
||||||
/// pre-CRC WAL file, which immediately recreates it in the current
|
|
||||||
/// format afterward. Do not use this for anything else.
|
|
||||||
///
|
|
||||||
/// `applied` is the checkpoint mark read from the `.h5` file, if any: if
|
|
||||||
/// the WAL's chain passes through it (same byte length, same chained
|
|
||||||
/// CRC), everything up to that point is already in the `.h5` and is
|
|
||||||
/// dropped. If it never does — the normal case, because the WAL was
|
|
||||||
/// truncated after the checkpoint — every entry is returned.
|
|
||||||
pub(crate) fn read_entries_for_migration(
|
|
||||||
path: &Path,
|
|
||||||
applied: Option<WalMark>,
|
|
||||||
) -> Result<Vec<WalEntry>, MemoryError> {
|
|
||||||
Self::read_entries_impl(path, true, applied)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_entries_impl(
|
|
||||||
path: &Path,
|
|
||||||
allow_legacy_no_crc: bool,
|
|
||||||
applied: Option<WalMark>,
|
|
||||||
) -> Result<Vec<WalEntry>, MemoryError> {
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
@@ -450,16 +229,10 @@ impl WalFile {
|
|||||||
}
|
}
|
||||||
// entry_count is a pre-allocation hint only — we read until EOF.
|
// entry_count is a pre-allocation hint only — we read until EOF.
|
||||||
let entry_count_hint = u32::from_le_bytes([header[5], header[6], header[7], header[8]]);
|
let entry_count_hint = u32::from_le_bytes([header[5], header[6], header[7], header[8]]);
|
||||||
|
let mut entries = Vec::with_capacity(entry_count_hint as usize);
|
||||||
|
|
||||||
match header[4] {
|
match header[4] {
|
||||||
WAL_VERSION | WAL_VERSION_CHAINED_NO_UPDATE => {
|
WAL_VERSION => loop {
|
||||||
let (entries, _final_crc, _verified_bytes) =
|
|
||||||
read_chained_entries(&mut f, 0, applied);
|
|
||||||
Ok(entries)
|
|
||||||
}
|
|
||||||
WAL_VERSION_CRC_UNCHAINED => {
|
|
||||||
let mut entries = Vec::with_capacity(entry_count_hint as usize);
|
|
||||||
loop {
|
|
||||||
let raw_and_result = {
|
let raw_and_result = {
|
||||||
let mut tee = TeeReader::new(&mut f);
|
let mut tee = TeeReader::new(&mut f);
|
||||||
let result = read_one_entry(&mut tee);
|
let result = read_one_entry(&mut tee);
|
||||||
@@ -476,37 +249,27 @@ impl WalFile {
|
|||||||
}
|
}
|
||||||
let stored_crc = u32::from_le_bytes(crc_buf);
|
let stored_crc = u32::from_le_bytes(crc_buf);
|
||||||
if crc32(&raw) != stored_crc {
|
if crc32(&raw) != stored_crc {
|
||||||
// Corruption detected — stop replay here, same as a
|
// Corruption detected — stop replay here, same as a clean
|
||||||
// clean truncation/EOF, rather than accepting the bad
|
// truncation/EOF, rather than accepting the bad entry.
|
||||||
// entry.
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if let Some(entry) = entry_opt {
|
if let Some(entry) = entry_opt {
|
||||||
entries.push(entry);
|
entries.push(entry);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
Ok(entries)
|
WAL_VERSION_LEGACY_NO_CRC => loop {
|
||||||
}
|
|
||||||
WAL_VERSION_LEGACY_NO_CRC if allow_legacy_no_crc => {
|
|
||||||
let mut entries = Vec::with_capacity(entry_count_hint as usize);
|
|
||||||
loop {
|
|
||||||
match read_one_entry(&mut f) {
|
match read_one_entry(&mut f) {
|
||||||
Err(()) => break,
|
Err(()) => break,
|
||||||
Ok(Some(entry)) => entries.push(entry),
|
Ok(Some(entry)) => entries.push(entry),
|
||||||
Ok(None) => {}
|
Ok(None) => {}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
v => {
|
||||||
|
return Err(MemoryError::Schema(format!("unsupported WAL version {v}")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
WAL_VERSION_LEGACY_NO_CRC => Err(MemoryError::Schema(
|
|
||||||
"WAL file is in the legacy no-CRC format (version 1), which read_entries() no \
|
|
||||||
longer accepts — it has no per-entry integrity verification. Only the one-time \
|
|
||||||
migration path (WalFile::open) can read and upgrade it."
|
|
||||||
.into(),
|
|
||||||
)),
|
|
||||||
v => Err(MemoryError::Schema(format!("unsupported WAL version {v}"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Truncate the WAL (after merge into .h5).
|
/// Truncate the WAL (after merge into .h5).
|
||||||
pub fn truncate(&mut self) -> Result<(), MemoryError> {
|
pub fn truncate(&mut self) -> Result<(), MemoryError> {
|
||||||
@@ -516,20 +279,9 @@ impl WalFile {
|
|||||||
self.file = Some(f);
|
self.file = Some(f);
|
||||||
self.entry_count = 0;
|
self.entry_count = 0;
|
||||||
self.pending_header_sync = 0;
|
self.pending_header_sync = 0;
|
||||||
self.running_crc = 0;
|
|
||||||
self.chain_len = 0;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The mark covering every entry currently in this WAL. Store it with a
|
|
||||||
/// checkpoint taken from the state those entries produced.
|
|
||||||
pub fn mark(&self) -> WalMark {
|
|
||||||
WalMark {
|
|
||||||
len: self.chain_len,
|
|
||||||
crc: self.running_crc,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of pending entries.
|
/// Number of pending entries.
|
||||||
pub fn pending_count(&self) -> u32 {
|
pub fn pending_count(&self) -> u32 {
|
||||||
self.entry_count
|
self.entry_count
|
||||||
@@ -569,28 +321,6 @@ pub fn replay_into_cache(entries: &[WalEntry], cache: &mut crate::cache::MemoryC
|
|||||||
entry.tags.clone(),
|
entry.tags.clone(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
WalEntryType::Update => match entry.update_index {
|
|
||||||
// The index was valid when the record was written; if the
|
|
||||||
// store no longer has it, keep the data rather than drop it.
|
|
||||||
Some(idx) if idx < cache.len() => cache.update(
|
|
||||||
idx,
|
|
||||||
entry.chunk.clone(),
|
|
||||||
entry.embedding.clone(),
|
|
||||||
entry.source_channel.clone(),
|
|
||||||
entry.timestamp,
|
|
||||||
entry.session_id.clone(),
|
|
||||||
),
|
|
||||||
_ => {
|
|
||||||
cache.push(
|
|
||||||
entry.chunk.clone(),
|
|
||||||
entry.embedding.clone(),
|
|
||||||
entry.source_channel.clone(),
|
|
||||||
entry.timestamp,
|
|
||||||
entry.session_id.clone(),
|
|
||||||
entry.tags.clone(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
WalEntryType::Tombstone => {
|
WalEntryType::Tombstone => {
|
||||||
if let Some(idx) = entry.tombstone_index {
|
if let Some(idx) = entry.tombstone_index {
|
||||||
cache.mark_deleted(idx);
|
cache.mark_deleted(idx);
|
||||||
@@ -643,81 +373,6 @@ fn read_embedding<R: Read>(f: &mut R) -> Result<Vec<f32>, MemoryError> {
|
|||||||
Ok(vals)
|
Ok(vals)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the CRC32 trailer for a `WAL_VERSION` entry, chaining in the
|
|
||||||
/// previous entry's stored CRC (0 for the first entry after a truncation).
|
|
||||||
fn chained_crc(entry_bytes: &[u8], prev_crc: u32) -> u32 {
|
|
||||||
let mut chained = Vec::with_capacity(entry_bytes.len() + 4);
|
|
||||||
chained.extend_from_slice(entry_bytes);
|
|
||||||
chained.extend_from_slice(&prev_crc.to_le_bytes());
|
|
||||||
crc32(&chained)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read and verify all entries from a `WAL_VERSION` (chained-CRC) stream
|
|
||||||
/// starting at the reader's current position, given the chain state to
|
|
||||||
/// resume from (0 for a stream starting at the beginning of a fresh WAL).
|
|
||||||
///
|
|
||||||
/// Returns the parsed entries, the final running CRC — the chain state to
|
|
||||||
/// continue from for further appends — and the number of BYTES consumed by
|
|
||||||
/// those verified entries. Stops (without erroring) at the first entry that
|
|
||||||
/// fails to parse or whose stored CRC doesn't match the expected chain value
|
|
||||||
/// — a bit-flip, truncation/EOF, or an entry having been
|
|
||||||
/// reordered/duplicated/spliced all produce a chain mismatch at that point,
|
|
||||||
/// and are all handled the same way: replay stops there.
|
|
||||||
///
|
|
||||||
/// The byte count is what lets `open()` position an append at the end of the
|
|
||||||
/// VERIFIED prefix rather than at end-of-file. Appending past a torn tail
|
|
||||||
/// writes entries that replay can never reach — see `open`.
|
|
||||||
///
|
|
||||||
/// `applied`, when given, is a checkpoint mark: once the chain reaches exactly
|
|
||||||
/// that position, the entries collected so far are discarded (they are
|
|
||||||
/// already in the `.h5` file). A zero-length mark matches nothing.
|
|
||||||
fn read_chained_entries<R: Read>(
|
|
||||||
f: &mut R,
|
|
||||||
start_crc: u32,
|
|
||||||
applied: Option<WalMark>,
|
|
||||||
) -> (Vec<WalEntry>, u32, u64) {
|
|
||||||
let applied = applied.filter(|m| m.len > 0);
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
let mut running_crc = start_crc;
|
|
||||||
let mut verified_bytes: u64 = 0;
|
|
||||||
loop {
|
|
||||||
let raw_and_result = {
|
|
||||||
let mut tee = TeeReader::new(f);
|
|
||||||
let result = read_one_entry(&mut tee);
|
|
||||||
(tee.into_buf(), result)
|
|
||||||
};
|
|
||||||
let (raw, result) = raw_and_result;
|
|
||||||
let entry_opt = match result {
|
|
||||||
Err(()) => break,
|
|
||||||
Ok(v) => v,
|
|
||||||
};
|
|
||||||
let mut crc_buf = [0u8; 4];
|
|
||||||
if f.read_exact(&mut crc_buf).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let stored_crc = u32::from_le_bytes(crc_buf);
|
|
||||||
if chained_crc(&raw, running_crc) != stored_crc {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
running_crc = stored_crc;
|
|
||||||
// Only counted once the entry AND its CRC trailer verified, so the
|
|
||||||
// offset always points just past a complete, checked entry.
|
|
||||||
verified_bytes += raw.len() as u64 + crc_buf.len() as u64;
|
|
||||||
if let Some(entry) = entry_opt {
|
|
||||||
entries.push(entry);
|
|
||||||
}
|
|
||||||
if applied
|
|
||||||
== Some(WalMark {
|
|
||||||
len: verified_bytes,
|
|
||||||
crc: running_crc,
|
|
||||||
})
|
|
||||||
{
|
|
||||||
entries.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(entries, running_crc, verified_bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a fresh WAL file at `path` with the current-version header,
|
/// Create a fresh WAL file at `path` with the current-version header,
|
||||||
/// truncating/overwriting anything already there.
|
/// truncating/overwriting anything already there.
|
||||||
fn create_fresh_wal_file(path: &Path) -> Result<File, MemoryError> {
|
fn create_fresh_wal_file(path: &Path) -> Result<File, MemoryError> {
|
||||||
@@ -775,14 +430,7 @@ fn read_one_entry<R: Read>(r: &mut R) -> Result<Option<WalEntry>, ()> {
|
|||||||
let timestamp = f64::from_le_bytes(ts_buf);
|
let timestamp = f64::from_le_bytes(ts_buf);
|
||||||
|
|
||||||
match entry_type {
|
match entry_type {
|
||||||
WalEntryType::Save | WalEntryType::Update => {
|
WalEntryType::Save => {
|
||||||
let update_index = if entry_type == WalEntryType::Update {
|
|
||||||
let mut idx_buf = [0u8; 4];
|
|
||||||
r.read_exact(&mut idx_buf).map_err(|_| ())?;
|
|
||||||
Some(u32::from_le_bytes(idx_buf) as usize)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let chunk = read_len_prefixed_str(r).map_err(|_| ())?;
|
let chunk = read_len_prefixed_str(r).map_err(|_| ())?;
|
||||||
let embedding = read_embedding(r).map_err(|_| ())?;
|
let embedding = read_embedding(r).map_err(|_| ())?;
|
||||||
let source_channel = read_len_prefixed_str(r).map_err(|_| ())?;
|
let source_channel = read_len_prefixed_str(r).map_err(|_| ())?;
|
||||||
@@ -797,7 +445,6 @@ fn read_one_entry<R: Read>(r: &mut R) -> Result<Option<WalEntry>, ()> {
|
|||||||
session_id,
|
session_id,
|
||||||
tags,
|
tags,
|
||||||
tombstone_index: None,
|
tombstone_index: None,
|
||||||
update_index,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
WalEntryType::Tombstone => {
|
WalEntryType::Tombstone => {
|
||||||
@@ -813,7 +460,6 @@ fn read_one_entry<R: Read>(r: &mut R) -> Result<Option<WalEntry>, ()> {
|
|||||||
session_id: String::new(),
|
session_id: String::new(),
|
||||||
tags: String::new(),
|
tags: String::new(),
|
||||||
tombstone_index: Some(idx),
|
tombstone_index: Some(idx),
|
||||||
update_index: None,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
WalEntryType::ActivationUpdate => Ok(None),
|
WalEntryType::ActivationUpdate => Ok(None),
|
||||||
@@ -837,7 +483,6 @@ mod tests {
|
|||||||
session_id: "sess-001".to_string(),
|
session_id: "sess-001".to_string(),
|
||||||
tags: "tag1,tag2".to_string(),
|
tags: "tag1,tag2".to_string(),
|
||||||
tombstone_index: None,
|
tombstone_index: None,
|
||||||
update_index: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,7 +601,7 @@ mod tests {
|
|||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let wal_path = dir.path().join("test.h5.wal");
|
let wal_path = dir.path().join("test.h5.wal");
|
||||||
let unicode_chunk = "Hello 世界! 🌍 émojis & ünïcödé";
|
let unicode_chunk = "Hello 世界! 🌍 émojis & ünïcödé";
|
||||||
let embedding = vec![0.1, -0.2, 3.4567, f32::MAX, f32::MIN_POSITIVE];
|
let embedding = vec![0.1, -0.2, 3.14159, f32::MAX, f32::MIN_POSITIVE];
|
||||||
{
|
{
|
||||||
let mut wal = WalFile::open(&wal_path).unwrap();
|
let mut wal = WalFile::open(&wal_path).unwrap();
|
||||||
let entry = WalEntry {
|
let entry = WalEntry {
|
||||||
@@ -968,7 +613,6 @@ mod tests {
|
|||||||
session_id: "sess-öö-123".to_string(),
|
session_id: "sess-öö-123".to_string(),
|
||||||
tags: "α,β,γ".to_string(),
|
tags: "α,β,γ".to_string(),
|
||||||
tombstone_index: None,
|
tombstone_index: None,
|
||||||
update_index: None,
|
|
||||||
};
|
};
|
||||||
wal.append_save(&entry).unwrap();
|
wal.append_save(&entry).unwrap();
|
||||||
}
|
}
|
||||||
@@ -1103,148 +747,6 @@ mod tests {
|
|||||||
assert!(entries.is_empty());
|
assert!(entries.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reopen `path` and return the stored chunks in order.
|
|
||||||
fn reopen_chunks(path: &std::path::Path) -> Vec<String> {
|
|
||||||
let mem = HDF5Memory::open(path).unwrap();
|
|
||||||
mem.cache.chunks.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn crash_between_checkpoint_and_wal_truncate_does_not_duplicate() {
|
|
||||||
// flush() writes the new .h5 and only then truncates the WAL. Dying in
|
|
||||||
// between leaves BOTH a .h5 that contains the pending entries and a
|
|
||||||
// WAL that still lists them; replaying blindly used to double them.
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let config = make_config(&dir);
|
|
||||||
let h5_path = config.path.clone();
|
|
||||||
let wal_path = h5_path.with_extension("h5.wal");
|
|
||||||
let stale_wal = dir.path().join("stale.wal");
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut mem = HDF5Memory::create(config).unwrap();
|
|
||||||
for name in ["a", "b", "c"] {
|
|
||||||
mem.save(make_entry(name, &[1.0, 0.0, 0.0, 0.0])).unwrap();
|
|
||||||
}
|
|
||||||
assert_eq!(mem.wal_pending_count(), 3);
|
|
||||||
std::fs::copy(&wal_path, &stale_wal).unwrap();
|
|
||||||
mem.flush_wal().unwrap();
|
|
||||||
}
|
|
||||||
// Undo the truncate: this is the on-disk state right after the crash.
|
|
||||||
std::fs::copy(&stale_wal, &wal_path).unwrap();
|
|
||||||
assert_eq!(WalFile::read_entries(&wal_path).unwrap().len(), 3);
|
|
||||||
|
|
||||||
assert_eq!(reopen_chunks(&h5_path), ["a", "b", "c"]);
|
|
||||||
|
|
||||||
// Entries appended to that same WAL after recovery are still replayed.
|
|
||||||
{
|
|
||||||
let mut mem = HDF5Memory::open(&h5_path).unwrap();
|
|
||||||
mem.save(make_entry("d", &[0.0, 1.0, 0.0, 0.0])).unwrap();
|
|
||||||
}
|
|
||||||
assert_eq!(reopen_chunks(&h5_path), ["a", "b", "c", "d"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn entries_written_after_a_completed_checkpoint_are_all_replayed() {
|
|
||||||
// Normal case: the checkpoint's mark refers to a WAL that has since
|
|
||||||
// been truncated, so it must not suppress anything in the new one —
|
|
||||||
// including when the new WAL grows past the old mark's length.
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let config = make_config(&dir);
|
|
||||||
let h5_path = config.path.clone();
|
|
||||||
{
|
|
||||||
let mut mem = HDF5Memory::create(config).unwrap();
|
|
||||||
mem.save(make_entry("a", &[1.0, 0.0, 0.0, 0.0])).unwrap();
|
|
||||||
mem.flush_wal().unwrap();
|
|
||||||
for name in ["b", "c", "d"] {
|
|
||||||
mem.save(make_entry(name, &[1.0, 0.0, 0.0, 0.0])).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert_eq!(reopen_chunks(&h5_path), ["a", "b", "c", "d"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn save_or_update_replays_as_update_not_duplicate() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let config = make_config(&dir);
|
|
||||||
let h5_path = config.path.clone();
|
|
||||||
{
|
|
||||||
let mut mem = HDF5Memory::create(config).unwrap();
|
|
||||||
let mut first = make_entry("v1", &[1.0, 0.0, 0.0, 0.0]);
|
|
||||||
first.tags = "key".into();
|
|
||||||
let mut second = make_entry("v2", &[0.0, 1.0, 0.0, 0.0]);
|
|
||||||
second.tags = "key".into();
|
|
||||||
let a = mem.save_or_update(first).unwrap();
|
|
||||||
mem.save(make_entry("other", &[0.0, 0.0, 1.0, 0.0]))
|
|
||||||
.unwrap();
|
|
||||||
let b = mem.save_or_update(second).unwrap();
|
|
||||||
assert_eq!(a, b);
|
|
||||||
assert_eq!(mem.cache.chunks, ["v2", "other"]);
|
|
||||||
// Dropped without a checkpoint: all three records live in the WAL.
|
|
||||||
}
|
|
||||||
let mem = HDF5Memory::open(&h5_path).unwrap();
|
|
||||||
assert_eq!(mem.cache.chunks, ["v2", "other"]);
|
|
||||||
assert_eq!(mem.cache.embeddings[0], [0.0, 1.0, 0.0, 0.0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn v3_wal_is_read_and_upgraded_in_place() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let wal_path = dir.path().join("old.wal");
|
|
||||||
{
|
|
||||||
let mut wal = WalFile::open(&wal_path).unwrap();
|
|
||||||
wal.append_save(&make_wal_entry("kept", &[1.0])).unwrap();
|
|
||||||
}
|
|
||||||
// Rewrite the header as the pre-Update chained format.
|
|
||||||
let mut bytes = std::fs::read(&wal_path).unwrap();
|
|
||||||
bytes[4] = WAL_VERSION_CHAINED_NO_UPDATE;
|
|
||||||
std::fs::write(&wal_path, &bytes).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(WalFile::read_entries(&wal_path).unwrap().len(), 1);
|
|
||||||
{
|
|
||||||
let mut wal = WalFile::open(&wal_path).unwrap();
|
|
||||||
assert_eq!(wal.pending_count(), 1);
|
|
||||||
wal.append_save(&make_wal_entry("new", &[2.0])).unwrap();
|
|
||||||
}
|
|
||||||
assert_eq!(std::fs::read(&wal_path).unwrap()[4], WAL_VERSION);
|
|
||||||
let chunks: Vec<_> = WalFile::read_entries(&wal_path)
|
|
||||||
.unwrap()
|
|
||||||
.into_iter()
|
|
||||||
.map(|e| e.chunk)
|
|
||||||
.collect();
|
|
||||||
assert_eq!(chunks, ["kept", "new"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mark_matching_is_exact() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let wal_path = dir.path().join("m.wal");
|
|
||||||
let mut wal = WalFile::open(&wal_path).unwrap();
|
|
||||||
wal.append_save(&make_wal_entry("one", &[1.0])).unwrap();
|
|
||||||
let after_one = wal.mark();
|
|
||||||
wal.append_save(&make_wal_entry("two", &[2.0])).unwrap();
|
|
||||||
let after_two = wal.mark();
|
|
||||||
drop(wal);
|
|
||||||
|
|
||||||
let read = |m| {
|
|
||||||
WalFile::read_entries_for_migration(&wal_path, m)
|
|
||||||
.unwrap()
|
|
||||||
.into_iter()
|
|
||||||
.map(|e| e.chunk)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
};
|
|
||||||
assert_eq!(read(None), ["one", "two"]);
|
|
||||||
assert_eq!(read(Some(after_one)), ["two"]);
|
|
||||||
assert!(read(Some(after_two)).is_empty());
|
|
||||||
// Right length, wrong CRC (a different WAL generation): skip nothing.
|
|
||||||
let foreign = WalMark {
|
|
||||||
crc: after_one.crc ^ 1,
|
|
||||||
..after_one
|
|
||||||
};
|
|
||||||
assert_eq!(read(Some(foreign)), ["one", "two"]);
|
|
||||||
// Reopening resumes the same mark.
|
|
||||||
assert_eq!(WalFile::open(&wal_path).unwrap().mark(), after_two);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_wal_replay_on_open() {
|
fn test_wal_replay_on_open() {
|
||||||
// Test WAL replay using read_entries + replay_into_cache directly,
|
// Test WAL replay using read_entries + replay_into_cache directly,
|
||||||
@@ -1410,157 +912,16 @@ mod tests {
|
|||||||
assert_eq!(entries[0].chunk, "first");
|
assert_eq!(entries[0].chunk, "first");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A crash mid-append leaves a torn final entry. Reopening the WAL must
|
|
||||||
/// place the next append at the end of the VERIFIED prefix, not at
|
|
||||||
/// end-of-file, or that append is written behind garbage the replay
|
|
||||||
/// scanner stops at — unreachable forever despite having returned Ok.
|
|
||||||
///
|
|
||||||
/// This is the ordinary crash case, so getting it wrong loses
|
|
||||||
/// acknowledged writes in exactly the situation a WAL exists for.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_wal_append_after_torn_tail_stays_replayable() {
|
fn test_wal_reads_legacy_v1_format_without_crc() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let wal_path = dir.path().join("test.h5.wal");
|
let wal_path = dir.path().join("legacy.h5.wal");
|
||||||
|
|
||||||
let mut wal = WalFile::open(&wal_path).unwrap();
|
|
||||||
wal.append_save(&make_wal_entry("first", &[1.0, 2.0]))
|
|
||||||
.unwrap();
|
|
||||||
drop(wal);
|
|
||||||
|
|
||||||
// Simulate the crash: a partial entry appended after the good one.
|
|
||||||
{
|
|
||||||
use std::io::Write;
|
|
||||||
let mut f = std::fs::OpenOptions::new()
|
|
||||||
.append(true)
|
|
||||||
.open(&wal_path)
|
|
||||||
.unwrap();
|
|
||||||
f.write_all(&[0xAB, 0xCD, 0xEF, 0x01, 0x02]).unwrap();
|
|
||||||
f.flush().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reopen and append. The torn bytes must not survive between the
|
|
||||||
// verified prefix and the new entry.
|
|
||||||
let mut wal = WalFile::open(&wal_path).unwrap();
|
|
||||||
wal.append_save(&make_wal_entry("second", &[3.0, 4.0]))
|
|
||||||
.unwrap();
|
|
||||||
drop(wal);
|
|
||||||
|
|
||||||
let entries = WalFile::read_entries(&wal_path).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
entries.len(),
|
|
||||||
2,
|
|
||||||
"the append after a torn tail must be replayable; got {} entr(y/ies) — \
|
|
||||||
the post-crash write was silently lost",
|
|
||||||
entries.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reordering two entries on disk must break the CRC chain — the
|
|
||||||
/// second entry's stored CRC was computed against the first entry's
|
|
||||||
/// real CRC, not against the chain state a reader sees after swapping
|
|
||||||
/// them, so replay stops immediately instead of accepting the tampered
|
|
||||||
/// order (INT-09).
|
|
||||||
#[test]
|
|
||||||
fn test_wal_detects_reordered_entries() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let wal_path = dir.path().join("test.h5.wal");
|
|
||||||
let mut wal = WalFile::open(&wal_path).unwrap();
|
|
||||||
wal.append_save(&make_wal_entry("first", &[1.0, 2.0]))
|
|
||||||
.unwrap();
|
|
||||||
let len_after_first = std::fs::metadata(&wal_path).unwrap().len() as usize;
|
|
||||||
wal.append_save(&make_wal_entry("second", &[3.0, 4.0]))
|
|
||||||
.unwrap();
|
|
||||||
let len_after_second = std::fs::metadata(&wal_path).unwrap().len() as usize;
|
|
||||||
drop(wal);
|
|
||||||
|
|
||||||
let bytes = std::fs::read(&wal_path).unwrap();
|
|
||||||
let header_len = 9usize;
|
|
||||||
let entry1_bytes = bytes[header_len..len_after_first].to_vec();
|
|
||||||
let entry2_bytes = bytes[len_after_first..len_after_second].to_vec();
|
|
||||||
|
|
||||||
let mut spliced = bytes[..header_len].to_vec();
|
|
||||||
spliced.extend_from_slice(&entry2_bytes);
|
|
||||||
spliced.extend_from_slice(&entry1_bytes);
|
|
||||||
std::fs::write(&wal_path, &spliced).unwrap();
|
|
||||||
|
|
||||||
let entries = WalFile::read_entries(&wal_path).unwrap();
|
|
||||||
assert!(
|
|
||||||
entries.is_empty(),
|
|
||||||
"reordered entries must break the CRC chain and stop replay, got {} entries",
|
|
||||||
entries.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Splicing a third-party entry in between two legitimate entries (e.g.
|
|
||||||
/// moving a Tombstone in front of the Save it's meant to follow) must
|
|
||||||
/// also break the chain for everything after the splice point.
|
|
||||||
#[test]
|
|
||||||
fn test_wal_detects_spliced_entry() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let wal_path = dir.path().join("test.h5.wal");
|
|
||||||
let mut wal = WalFile::open(&wal_path).unwrap();
|
|
||||||
wal.append_save(&make_wal_entry("first", &[1.0])).unwrap();
|
|
||||||
let len_after_first = std::fs::metadata(&wal_path).unwrap().len() as usize;
|
|
||||||
wal.append_save(&make_wal_entry("second", &[2.0])).unwrap();
|
|
||||||
let len_after_second = std::fs::metadata(&wal_path).unwrap().len() as usize;
|
|
||||||
wal.append_save(&make_wal_entry("third", &[3.0])).unwrap();
|
|
||||||
drop(wal);
|
|
||||||
|
|
||||||
let bytes = std::fs::read(&wal_path).unwrap();
|
|
||||||
let entry2_bytes = bytes[len_after_first..len_after_second].to_vec();
|
|
||||||
|
|
||||||
// Duplicate "second" right after itself: [first][second][second][third]
|
|
||||||
let mut spliced = bytes[..len_after_second].to_vec();
|
|
||||||
spliced.extend_from_slice(&entry2_bytes);
|
|
||||||
spliced.extend_from_slice(&bytes[len_after_second..]);
|
|
||||||
std::fs::write(&wal_path, &spliced).unwrap();
|
|
||||||
|
|
||||||
let entries = WalFile::read_entries(&wal_path).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
entries.len(),
|
|
||||||
2,
|
|
||||||
"replay must stop at the spliced duplicate, keeping only the entries before it"
|
|
||||||
);
|
|
||||||
assert_eq!(entries[0].chunk, "first");
|
|
||||||
assert_eq!(entries[1].chunk, "second");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A WAL closed (without truncating) and reopened must continue the CRC
|
|
||||||
/// chain correctly for newly appended entries — this is the normal
|
|
||||||
/// crash-restart-without-flush scenario (`HDF5Memory::open` replays
|
|
||||||
/// existing entries, then reopens the same file for further appends
|
|
||||||
/// without clearing it), and must not produce a false "reordering"
|
|
||||||
/// detection for its own legitimately-appended entries.
|
|
||||||
#[test]
|
|
||||||
fn test_wal_chain_continues_across_reopen() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let wal_path = dir.path().join("test.h5.wal");
|
|
||||||
|
|
||||||
let mut wal = WalFile::open(&wal_path).unwrap();
|
|
||||||
wal.append_save(&make_wal_entry("first", &[1.0])).unwrap();
|
|
||||||
drop(wal); // simulate a restart without ever truncating the WAL
|
|
||||||
|
|
||||||
let mut wal2 = WalFile::open(&wal_path).unwrap();
|
|
||||||
wal2.append_save(&make_wal_entry("second", &[2.0])).unwrap();
|
|
||||||
drop(wal2);
|
|
||||||
|
|
||||||
let entries = WalFile::read_entries(&wal_path).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
entries.len(),
|
|
||||||
2,
|
|
||||||
"both pre- and post-reopen entries must replay cleanly"
|
|
||||||
);
|
|
||||||
assert_eq!(entries[0].chunk, "first");
|
|
||||||
assert_eq!(entries[1].chunk, "second");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a legacy (WAL_VERSION_LEGACY_NO_CRC) WAL file containing one
|
|
||||||
/// Save entry, with no trailing CRC32.
|
|
||||||
fn build_legacy_v1_wal_bytes() -> Vec<u8> {
|
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
buf.extend_from_slice(&WAL_MAGIC);
|
buf.extend_from_slice(&WAL_MAGIC);
|
||||||
buf.push(WAL_VERSION_LEGACY_NO_CRC);
|
buf.push(WAL_VERSION_LEGACY_NO_CRC);
|
||||||
buf.extend_from_slice(&1u32.to_le_bytes());
|
buf.extend_from_slice(&1u32.to_le_bytes());
|
||||||
|
// One Save entry in the old format: type + timestamp + fields, with
|
||||||
|
// no trailing CRC32.
|
||||||
buf.push(WalEntryType::Save as u8);
|
buf.push(WalEntryType::Save as u8);
|
||||||
buf.extend_from_slice(&42.0f64.to_le_bytes());
|
buf.extend_from_slice(&42.0f64.to_le_bytes());
|
||||||
serialize_str(&mut buf, "legacy-chunk");
|
serialize_str(&mut buf, "legacy-chunk");
|
||||||
@@ -1572,39 +933,14 @@ mod tests {
|
|||||||
serialize_str(&mut buf, "chan");
|
serialize_str(&mut buf, "chan");
|
||||||
serialize_str(&mut buf, "sess");
|
serialize_str(&mut buf, "sess");
|
||||||
serialize_str(&mut buf, "tags");
|
serialize_str(&mut buf, "tags");
|
||||||
buf
|
std::fs::write(&wal_path, &buf).unwrap();
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
let entries = WalFile::read_entries(&wal_path).unwrap();
|
||||||
fn test_wal_reads_legacy_v1_format_without_crc() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let wal_path = dir.path().join("legacy.h5.wal");
|
|
||||||
std::fs::write(&wal_path, build_legacy_v1_wal_bytes()).unwrap();
|
|
||||||
|
|
||||||
// Only the migration-only reader may read a legacy no-CRC file.
|
|
||||||
let entries = WalFile::read_entries_for_migration(&wal_path, None).unwrap();
|
|
||||||
assert_eq!(entries.len(), 1);
|
assert_eq!(entries.len(), 1);
|
||||||
assert_eq!(entries[0].chunk, "legacy-chunk");
|
assert_eq!(entries[0].chunk, "legacy-chunk");
|
||||||
assert_eq!(entries[0].embedding, vec![1.0, 2.0]);
|
assert_eq!(entries[0].embedding, vec![1.0, 2.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The public `read_entries` must reject a legacy no-CRC file instead of
|
|
||||||
/// silently downgrading to the fully-unverified parser (INT-09) — flipping
|
|
||||||
/// a version byte from 2/3 down to 1 must not be a way to bypass every
|
|
||||||
/// integrity check for an arbitrary caller of the public API.
|
|
||||||
#[test]
|
|
||||||
fn test_wal_read_entries_rejects_legacy_v1_format() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let wal_path = dir.path().join("legacy.h5.wal");
|
|
||||||
std::fs::write(&wal_path, build_legacy_v1_wal_bytes()).unwrap();
|
|
||||||
|
|
||||||
let result = WalFile::read_entries(&wal_path);
|
|
||||||
assert!(
|
|
||||||
result.is_err(),
|
|
||||||
"read_entries() must reject a legacy no-CRC WAL file, not silently parse it"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_wal_open_migrates_legacy_v1_to_current_version() {
|
fn test_wal_open_migrates_legacy_v1_to_current_version() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
//! 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();
|
mem.add_relation(e1, e2, "discusses", 0.8).unwrap();
|
||||||
|
|
||||||
// Verify all data transferred by reopening
|
// Verify all data transferred by reopening
|
||||||
let reopened = HDF5Memory::open_read_only(&path).unwrap();
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
assert_eq!(reopened.count(), 500);
|
assert_eq!(reopened.count(), 500);
|
||||||
|
|
||||||
// Verify sessions
|
// Verify sessions
|
||||||
@@ -266,7 +266,7 @@ fn test_knowledge_graph_workflow() {
|
|||||||
assert_eq!(entity.entity_type, "library");
|
assert_eq!(entity.entity_type, "library");
|
||||||
|
|
||||||
// Persistence
|
// Persistence
|
||||||
let reopened = HDF5Memory::open_read_only(&path).unwrap();
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
assert_eq!(reopened.knowledge().entities.len(), 4);
|
assert_eq!(reopened.knowledge().entities.len(), 4);
|
||||||
assert_eq!(reopened.knowledge().relations.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
|
assert_eq!(mem.count(), 100); // 5 sessions * 20 entries
|
||||||
|
|
||||||
// Reopen and verify sessions
|
// Reopen and verify sessions
|
||||||
let reopened = HDF5Memory::open_read_only(&path).unwrap();
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
for sess in 0..5 {
|
for sess in 0..5 {
|
||||||
let summary = reopened
|
let summary = reopened
|
||||||
.get_session_summary(&format!("sess_{sess}"))
|
.get_session_summary(&format!("sess_{sess}"))
|
||||||
@@ -460,7 +460,7 @@ fn test_snapshot_and_continue() {
|
|||||||
assert_eq!(snap_mem.count(), 50);
|
assert_eq!(snap_mem.count(), 50);
|
||||||
|
|
||||||
// Original should have 100
|
// Original should have 100
|
||||||
let orig_mem = HDF5Memory::open_read_only(&path).unwrap();
|
let orig_mem = HDF5Memory::open(&path).unwrap();
|
||||||
assert_eq!(orig_mem.count(), 100);
|
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_session("s1", 0, 0, "ch", "summary").unwrap();
|
||||||
mem.add_entity("Entity", "type", -1).unwrap();
|
mem.add_entity("Entity", "type", -1).unwrap();
|
||||||
|
|
||||||
let reopened = HDF5Memory::open_read_only(&path).unwrap();
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
assert_eq!(reopened.config().embedding_dim, 128);
|
assert_eq!(reopened.config().embedding_dim, 128);
|
||||||
assert_eq!(reopened.config().embedder, "custom:my-embedder-v2");
|
assert_eq!(reopened.config().embedder, "custom:my-embedder-v2");
|
||||||
assert_eq!(reopened.config().chunk_size, 2048);
|
assert_eq!(reopened.config().chunk_size, 2048);
|
||||||
@@ -695,7 +695,7 @@ fn test_large_text_chunks() {
|
|||||||
mem.save_batch(entries).unwrap();
|
mem.save_batch(entries).unwrap();
|
||||||
|
|
||||||
// Reopen and verify
|
// Reopen and verify
|
||||||
let reopened = HDF5Memory::open_read_only(&path).unwrap();
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
assert_eq!(reopened.count(), 10);
|
assert_eq!(reopened.count(), 10);
|
||||||
|
|
||||||
let (_, cache, _, _) = read_cache(&path);
|
let (_, cache, _, _) = read_cache(&path);
|
||||||
@@ -752,7 +752,7 @@ fn test_interleaved_sessions_entries() {
|
|||||||
mem.flush_wal().unwrap();
|
mem.flush_wal().unwrap();
|
||||||
|
|
||||||
// Verify
|
// Verify
|
||||||
let reopened = HDF5Memory::open_read_only(&path).unwrap();
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
assert_eq!(reopened.count(), 6);
|
assert_eq!(reopened.count(), 6);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reopened.get_session_summary("s1").unwrap().as_deref(),
|
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();
|
mem.add_relation(e_python, e_hdf5, "reads", 0.9).unwrap();
|
||||||
|
|
||||||
// Verify entity-embedding linkage persists
|
// Verify entity-embedding linkage persists
|
||||||
let reopened = HDF5Memory::open_read_only(&path).unwrap();
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
let rust_entity = reopened.knowledge().get_entity(e_rust).unwrap();
|
let rust_entity = reopened.knowledge().get_entity(e_rust).unwrap();
|
||||||
assert_eq!(rust_entity.embedding_idx, idx0 as i64);
|
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 tombstones = vec![0u8; 3];
|
||||||
|
|
||||||
let gpu = clawhdf5_agent::gpu_search::GpuSearchBackend::try_init(&vectors, &norms, 2, 1);
|
let gpu = clawhdf5_agent::gpu_search::GpuSearchBackend::try_init(&vectors, &norms, 2, 1);
|
||||||
let results = gpu.search_l2(&[0.0, 0.0], &vectors, &tombstones, 3);
|
let results = gpu.search_l2(&vec![0.0, 0.0], &vectors, &tombstones, 3);
|
||||||
|
|
||||||
assert_eq!(results.len(), 3);
|
assert_eq!(results.len(), 3);
|
||||||
assert_eq!(results[0].0, 0);
|
assert_eq!(results[0].0, 0);
|
||||||
@@ -1099,7 +1099,7 @@ fn test_mmap_reader_direct_access() {
|
|||||||
|
|
||||||
// Open via MmapReader directly
|
// Open via MmapReader directly
|
||||||
let mmap = clawhdf5_io::MmapReader::open(&path).unwrap();
|
let mmap = clawhdf5_io::MmapReader::open(&path).unwrap();
|
||||||
assert!(!mmap.is_empty());
|
assert!(mmap.len() > 0);
|
||||||
// Verify we can read bytes at specific offsets
|
// Verify we can read bytes at specific offsets
|
||||||
let bytes = mmap.read_at(0, 8);
|
let bytes = mmap.read_at(0, 8);
|
||||||
assert!(bytes.is_some());
|
assert!(bytes.is_some());
|
||||||
@@ -1144,11 +1144,9 @@ fn test_strategy_reports_backend() {
|
|||||||
let tombstones = vec![0u8; n];
|
let tombstones = vec![0u8; n];
|
||||||
let query = vectors[0].clone();
|
let query = vectors[0].clone();
|
||||||
|
|
||||||
let flat: Vec<f32> = vectors.iter().flatten().copied().collect();
|
|
||||||
let (_, metrics) = strategy::search_with_metrics(
|
let (_, metrics) = strategy::search_with_metrics(
|
||||||
&query,
|
&query,
|
||||||
&vectors,
|
&vectors,
|
||||||
&flat,
|
|
||||||
&norms,
|
&norms,
|
||||||
&tombstones,
|
&tombstones,
|
||||||
5,
|
5,
|
||||||
|
|||||||
@@ -165,72 +165,3 @@ fn save_batch_then_search_is_consistent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn quantized_index_matches_the_f32_index_after_re_scoring() {
|
|
||||||
// A quantised index holds approximate vectors, but the store still has the
|
|
||||||
// exact ones, so the query path re-scores the candidate pool before
|
|
||||||
// fusion. The results a caller sees should therefore be the same.
|
|
||||||
let dim = 64;
|
|
||||||
let n = 400;
|
|
||||||
let mut seed = 0x5EED_1234_5678_9ABC;
|
|
||||||
let vectors: Vec<Vec<f32>> = (0..n).map(|_| make_vector(&mut seed, dim)).collect();
|
|
||||||
let queries: Vec<Vec<f32>> = (0..20).map(|_| make_vector(&mut seed, dim)).collect();
|
|
||||||
|
|
||||||
let build = |dir: &TempDir, quantized: bool| {
|
|
||||||
let mut config = MemoryConfig::new(dir.path().join("mem.h5"), "agent", dim);
|
|
||||||
config.quantized_index = quantized;
|
|
||||||
let mut mem = HDF5Memory::create(config).unwrap();
|
|
||||||
for (i, v) in vectors.iter().enumerate() {
|
|
||||||
mem.save(entry(&format!("chunk {i}"), v.clone(), &format!("k{i}")))
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
mem
|
|
||||||
};
|
|
||||||
|
|
||||||
let exact_dir = TempDir::new().unwrap();
|
|
||||||
let quant_dir = TempDir::new().unwrap();
|
|
||||||
let mut exact = build(&exact_dir, false);
|
|
||||||
let mut quantized = build(&quant_dir, true);
|
|
||||||
|
|
||||||
let k = 10;
|
|
||||||
let mut agree = 0;
|
|
||||||
for q in &queries {
|
|
||||||
let want: Vec<usize> = exact
|
|
||||||
.hybrid_search(q, "", 1.0, 0.0, k)
|
|
||||||
.iter()
|
|
||||||
.map(|r| r.index)
|
|
||||||
.collect();
|
|
||||||
agree += quantized
|
|
||||||
.hybrid_search(q, "", 1.0, 0.0, k)
|
|
||||||
.iter()
|
|
||||||
.filter(|r| want.contains(&r.index))
|
|
||||||
.count();
|
|
||||||
}
|
|
||||||
let overlap = agree as f64 / (k * queries.len()) as f64;
|
|
||||||
assert!(
|
|
||||||
overlap >= 0.95,
|
|
||||||
"quantised store should match the f32 one: {overlap}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn quantized_index_setting_survives_a_reopen() {
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let path = dir.path().join("mem.h5");
|
|
||||||
let mut config = MemoryConfig::new(path.clone(), "agent", 8);
|
|
||||||
config.quantized_index = true;
|
|
||||||
let mut mem = HDF5Memory::create(config).unwrap();
|
|
||||||
let mut seed = 7;
|
|
||||||
for i in 0..30 {
|
|
||||||
mem.save(entry(&format!("c{i}"), make_vector(&mut seed, 8), "t"))
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
mem.flush_wal().unwrap();
|
|
||||||
drop(mem);
|
|
||||||
|
|
||||||
// Reopening must not silently quadruple the index's memory, so the flag
|
|
||||||
// is part of the stored config rather than a per-session choice.
|
|
||||||
let reopened = HDF5Memory::open(&path).unwrap();
|
|
||||||
assert!(reopened.config().quantized_index);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -137,12 +137,12 @@ fn bench_hit_at_1_1014_records() {
|
|||||||
0.3,
|
0.3,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
if let Some((top_idx, _)) = results.first()
|
if let Some((top_idx, _)) = results.first() {
|
||||||
&& *top_idx == target_indices[qi]
|
if *top_idx == target_indices[qi] {
|
||||||
{
|
|
||||||
hits += 1;
|
hits += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let hit_at_1 = hits as f64 / NUM_QUERIES as f64;
|
let hit_at_1 = hits as f64 / NUM_QUERIES as f64;
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ fn test_heavy_tombstoning() {
|
|||||||
assert_eq!(mem.count_active(), 5000);
|
assert_eq!(mem.count_active(), 5000);
|
||||||
|
|
||||||
// Verify persistence
|
// Verify persistence
|
||||||
let reopened = HDF5Memory::open_read_only(&path).unwrap();
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
assert_eq!(reopened.count(), 5000);
|
assert_eq!(reopened.count(), 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ fn test_large_embeddings_1536() {
|
|||||||
assert_eq!(mem.count(), 10_000);
|
assert_eq!(mem.count(), 10_000);
|
||||||
|
|
||||||
// Verify persistence
|
// Verify persistence
|
||||||
let reopened = HDF5Memory::open_read_only(&path).unwrap();
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
assert_eq!(reopened.count(), 10_000);
|
assert_eq!(reopened.count(), 10_000);
|
||||||
|
|
||||||
// Verify search works on large dims
|
// Verify search works on large dims
|
||||||
@@ -545,7 +545,7 @@ fn test_delete_all_entries() {
|
|||||||
assert_eq!(mem.count(), 0);
|
assert_eq!(mem.count(), 0);
|
||||||
|
|
||||||
// Verify persistence
|
// Verify persistence
|
||||||
let reopened = HDF5Memory::open_read_only(&path).unwrap();
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
assert_eq!(reopened.count(), 0);
|
assert_eq!(reopened.count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,7 +639,7 @@ fn test_unicode_content() {
|
|||||||
];
|
];
|
||||||
mem.save_batch(entries).unwrap();
|
mem.save_batch(entries).unwrap();
|
||||||
|
|
||||||
let reopened = HDF5Memory::open_read_only(&path).unwrap();
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
assert_eq!(reopened.count(), 3);
|
assert_eq!(reopened.count(), 3);
|
||||||
|
|
||||||
let (_, cache, _, _) = clawhdf5_agent::storage::read_from_disk(&path).unwrap();
|
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!(removed, 250);
|
||||||
assert_eq!(mem.count(), 250);
|
assert_eq!(mem.count(), 250);
|
||||||
|
|
||||||
let reopened = HDF5Memory::open_read_only(&path).unwrap();
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
assert_eq!(reopened.count(), 250);
|
assert_eq!(reopened.count(), 250);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
//! 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]
|
[package]
|
||||||
name = "clawhdf5-android"
|
name = "clawhdf5-android"
|
||||||
version = "2.6.0"
|
version = "2.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Android JNI bridge for edgehdf5-memory HDF5 backend"
|
description = "Android JNI bridge for edgehdf5-memory HDF5 backend"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-ann"
|
name = "clawhdf5-ann"
|
||||||
version = "2.6.0"
|
version = "2.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "HNSW approximate nearest neighbor index stored as HDF5"
|
description = "HNSW approximate nearest neighbor index stored as HDF5"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["hdf5", "ann", "hnsw", "nearest-neighbor"]
|
keywords = ["hdf5", "ann", "hnsw", "nearest-neighbor"]
|
||||||
categories = ["algorithms", "science"]
|
categories = ["algorithms", "science"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0" }
|
||||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.6.0" }
|
clawhdf5-io = { path = "../clawhdf5-io", version = "2.1.0" }
|
||||||
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.6.0" }
|
|
||||||
rayon = { version = "1", optional = true }
|
rayon = { version = "1", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
+146
-1135
File diff suppressed because it is too large
Load Diff
@@ -5,4 +5,4 @@
|
|||||||
|
|
||||||
mod hnsw;
|
mod hnsw;
|
||||||
|
|
||||||
pub use hnsw::{DistanceMetric, HnswIndex, Storage};
|
pub use hnsw::{DistanceMetric, HnswIndex};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-bench"
|
name = "clawhdf5-bench"
|
||||||
version = "2.6.0"
|
version = "2.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Benchmark harnesses for clawhdf5-agent (Track 8)"
|
description = "Benchmark harnesses for clawhdf5-agent (Track 8)"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -13,14 +13,6 @@ path = "src/bin/longmemeval_bench.rs"
|
|||||||
name = "memory_arena"
|
name = "memory_arena"
|
||||||
path = "src/bin/memory_arena.rs"
|
path = "src/bin/memory_arena.rs"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "read_harness"
|
|
||||||
path = "src/bin/read_harness.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "search_harness"
|
|
||||||
path = "src/bin/search_harness.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "footprint_bench"
|
name = "footprint_bench"
|
||||||
path = "src/bin/footprint_bench.rs"
|
path = "src/bin/footprint_bench.rs"
|
||||||
@@ -56,9 +48,6 @@ harness = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-agent = { path = "../clawhdf5-agent" }
|
clawhdf5-agent = { path = "../clawhdf5-agent" }
|
||||||
clawhdf5-ann = { path = "../clawhdf5-ann" }
|
|
||||||
clawhdf5 = { path = "../clawhdf5" }
|
|
||||||
clawhdf5-format = { path = "../clawhdf5-format" }
|
|
||||||
clawhdf5-io = { path = "../clawhdf5-io" }
|
clawhdf5-io = { path = "../clawhdf5-io" }
|
||||||
mpi = { version = "0.8", optional = true }
|
mpi = { version = "0.8", optional = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -22,9 +22,7 @@
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use clawhdf5_agent::bm25::BM25Index;
|
use clawhdf5_agent::bm25::BM25Index;
|
||||||
use clawhdf5_agent::consolidation::{
|
use clawhdf5_agent::consolidation::{ConsolidationConfig, ConsolidationEngine, MemorySource};
|
||||||
ConsolidationConfig, ConsolidationEngine, TrustedSource, UntrustedSource,
|
|
||||||
};
|
|
||||||
use clawhdf5_agent::hybrid::hybrid_search;
|
use clawhdf5_agent::hybrid::hybrid_search;
|
||||||
|
|
||||||
const EMBEDDING_DIM: usize = 384;
|
const EMBEDDING_DIM: usize = 384;
|
||||||
@@ -234,7 +232,7 @@ fn run_quality_benchmark() {
|
|||||||
for i in 0..SIGNAL_KEYWORDS.len() {
|
for i in 0..SIGNAL_KEYWORDS.len() {
|
||||||
let chunk = make_signal_content(i);
|
let chunk = make_signal_content(i);
|
||||||
let embedding = make_embedding(i * 1000);
|
let embedding = make_embedding(i * 1000);
|
||||||
let id = engine.add_trusted_memory(chunk, embedding, TrustedSource::Correction, now);
|
let id = engine.add_memory(chunk, embedding, MemorySource::Correction, now);
|
||||||
signal_ids.push(id);
|
signal_ids.push(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,12 +240,7 @@ fn run_quality_benchmark() {
|
|||||||
for i in 0..990 {
|
for i in 0..990 {
|
||||||
let chunk = make_noise_content(i);
|
let chunk = make_noise_content(i);
|
||||||
let embedding = make_embedding(i + 100);
|
let embedding = make_embedding(i + 100);
|
||||||
engine.add_trusted_memory(
|
engine.add_memory(chunk, embedding, MemorySource::System, now + i as f64 * 0.1);
|
||||||
chunk,
|
|
||||||
embedding,
|
|
||||||
TrustedSource::System,
|
|
||||||
now + i as f64 * 0.1,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(" → Inserted {} records total", engine.records().len());
|
println!(" → Inserted {} records total", engine.records().len());
|
||||||
@@ -340,7 +333,7 @@ fn run_cycle_time_benchmark() {
|
|||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let chunk = make_noise_content(i);
|
let chunk = make_noise_content(i);
|
||||||
let embedding = make_embedding(i);
|
let embedding = make_embedding(i);
|
||||||
engine.add_memory(chunk, embedding, UntrustedSource::User, now + i as f64);
|
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warmup
|
// Warmup
|
||||||
@@ -351,7 +344,7 @@ fn run_cycle_time_benchmark() {
|
|||||||
for i in n..(n * 2) {
|
for i in n..(n * 2) {
|
||||||
let chunk = make_noise_content(i);
|
let chunk = make_noise_content(i);
|
||||||
let embedding = make_embedding(i);
|
let embedding = make_embedding(i);
|
||||||
engine.add_memory(chunk, embedding, UntrustedSource::User, now + i as f64);
|
engine.add_memory(chunk, embedding, MemorySource::User, now + i as f64);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timed consolidation
|
// Timed consolidation
|
||||||
@@ -417,13 +410,13 @@ fn run_memory_reduction_benchmark() {
|
|||||||
for i in 0..signal_count {
|
for i in 0..signal_count {
|
||||||
let chunk = make_signal_content(i % SIGNAL_KEYWORDS.len());
|
let chunk = make_signal_content(i % SIGNAL_KEYWORDS.len());
|
||||||
let emb = make_embedding(i * 999);
|
let emb = make_embedding(i * 999);
|
||||||
let id = engine.add_trusted_memory(chunk, emb, TrustedSource::Correction, now);
|
let id = engine.add_memory(chunk, emb, MemorySource::Correction, now);
|
||||||
signal_ids.push(id);
|
signal_ids.push(id);
|
||||||
}
|
}
|
||||||
for i in 0..noise_count {
|
for i in 0..noise_count {
|
||||||
let chunk = make_noise_content(i);
|
let chunk = make_noise_content(i);
|
||||||
let emb = make_embedding(i + 200);
|
let emb = make_embedding(i + 200);
|
||||||
engine.add_trusted_memory(chunk, emb, TrustedSource::System, now + i as f64 * 0.1);
|
engine.add_memory(chunk, emb, MemorySource::System, now + i as f64 * 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access signal records heavily
|
// Access signal records heavily
|
||||||
|
|||||||
@@ -55,150 +55,44 @@ use std::time::{Duration, Instant};
|
|||||||
#[path = "longmemeval_bench/embedder.rs"]
|
#[path = "longmemeval_bench/embedder.rs"]
|
||||||
mod embedder;
|
mod embedder;
|
||||||
|
|
||||||
use clawhdf5_agent::bm25::TokenFilter;
|
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
||||||
use clawhdf5_agent::hybrid::Fusion;
|
|
||||||
use clawhdf5_agent::reranker::{ReRankConfig, RerankInput, rerank};
|
|
||||||
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry, SearchResult};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
const EMBEDDING_DIM: usize = 384;
|
const EMBEDDING_DIM: usize = 384;
|
||||||
|
|
||||||
/// A mode's fusion, as one short string for the reports.
|
|
||||||
fn describe(mode: Mode) -> String {
|
|
||||||
let fusion = match mode.fusion {
|
|
||||||
Fusion::Weighted { vector, keyword } => format!("vector_{vector:.1}_keyword_{keyword:.1}"),
|
|
||||||
Fusion::Rrf { k } => format!("rrf_k{k:.0}"),
|
|
||||||
};
|
|
||||||
let tokens = match mode.tokens {
|
|
||||||
TokenFilter::Plain => fusion,
|
|
||||||
TokenFilter::Stemmed => format!("{fusion}_stemmed"),
|
|
||||||
};
|
|
||||||
match mode.rerank {
|
|
||||||
None => tokens,
|
|
||||||
Some(cfg) if cfg.relevance_weight == 0.0 => format!("{tokens}_rerank_metadata"),
|
|
||||||
Some(cfg) => format!(
|
|
||||||
"{tokens}_rerank_blended_hl{:.0}d",
|
|
||||||
cfg.temporal_half_life_secs / 86_400.0
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A retrieval configuration: how much of the score comes from each stage.
|
/// A retrieval configuration: how much of the score comes from each stage.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct Mode {
|
struct Mode {
|
||||||
label: &'static str,
|
label: &'static str,
|
||||||
/// How the two retrieval stages are combined into one ranking.
|
vector_weight: f32,
|
||||||
fusion: Fusion,
|
keyword_weight: f32,
|
||||||
/// How keyword tokens are normalised before indexing and querying.
|
|
||||||
tokens: TokenFilter,
|
|
||||||
/// Re-rank the retrieved candidates with recency and friends, relative to
|
|
||||||
/// the question's own date.
|
|
||||||
rerank: Option<ReRankConfig>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Mode {
|
|
||||||
const fn weighted(label: &'static str, vector: f32, keyword: f32) -> Self {
|
|
||||||
Self {
|
|
||||||
label,
|
|
||||||
fusion: Fusion::Weighted { vector, keyword },
|
|
||||||
tokens: TokenFilter::Plain,
|
|
||||||
rerank: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(not(feature = "embeddings"), allow(dead_code))]
|
|
||||||
fn reranked(mut self, label: &'static str, rerank: ReRankConfig) -> Self {
|
|
||||||
self.label = label;
|
|
||||||
self.rerank = Some(rerank);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn stemmed(mut self, label: &'static str) -> Self {
|
|
||||||
self.label = label;
|
|
||||||
self.tokens = TokenFilter::Stemmed;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The only mode available without real embeddings. Passing zero vectors with
|
/// The only mode available without real embeddings. Passing zero vectors with
|
||||||
/// `vector_weight = 0.0` is what made the vector stage inert.
|
/// `vector_weight = 0.0` is what made the vector stage inert.
|
||||||
const BM25_ONLY: Mode = Mode::weighted("BM25 only (vector stage inert)", 0.0, 1.0);
|
const BM25_ONLY: Mode = Mode {
|
||||||
|
label: "BM25 only (vector stage inert)",
|
||||||
|
vector_weight: 0.0,
|
||||||
|
keyword_weight: 1.0,
|
||||||
|
};
|
||||||
#[cfg(feature = "embeddings")]
|
#[cfg(feature = "embeddings")]
|
||||||
const VECTOR_ONLY: Mode = Mode::weighted("Vector only (MiniLM + HNSW)", 1.0, 0.0);
|
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
|
/// 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
|
/// 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
|
/// strictly dominated: 0.4/0.6 is better on Hit@1, Hit@5, Hit@10 and MRR at
|
||||||
/// both granularities.
|
/// both granularities.
|
||||||
#[cfg(feature = "embeddings")]
|
#[cfg(feature = "embeddings")]
|
||||||
const HYBRID: Mode = Mode::weighted("Hybrid (0.4 vector / 0.6 BM25, tuned)", 0.4, 0.6);
|
const HYBRID: Mode = Mode {
|
||||||
|
label: "Hybrid (0.4 vector / 0.6 BM25, tuned)",
|
||||||
/// Reciprocal rank fusion, the documented alternative to the weighted sum.
|
vector_weight: 0.4,
|
||||||
/// It ignores score magnitudes, so there is nothing to tune — which is the
|
keyword_weight: 0.6,
|
||||||
/// claim being tested.
|
|
||||||
#[cfg(feature = "embeddings")]
|
|
||||||
const RRF: Mode = Mode {
|
|
||||||
label: "Hybrid (reciprocal rank fusion, k=60)",
|
|
||||||
fusion: Fusion::Rrf { k: 60.0 },
|
|
||||||
tokens: TokenFilter::Plain,
|
|
||||||
rerank: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The same two configurations with stemmed keyword tokens, so the tokenizer's
|
|
||||||
/// effect is isolated from everything else.
|
|
||||||
const BM25_STEMMED: Mode = BM25_ONLY.stemmed("BM25 only, stemmed tokens");
|
|
||||||
|
|
||||||
/// Re-ranking as it behaved before `relevance` was an input: the combined
|
|
||||||
/// score was recency + authority + activation only, so the retriever's own
|
|
||||||
/// ordering was discarded.
|
|
||||||
#[cfg(feature = "embeddings")]
|
|
||||||
fn hybrid_rerank_metadata_only() -> Mode {
|
|
||||||
HYBRID.reranked(
|
|
||||||
"Hybrid + rerank (metadata only, pre-fix)",
|
|
||||||
ReRankConfig {
|
|
||||||
relevance_weight: 0.0,
|
|
||||||
..ReRankConfig::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-ranking as it behaves now: relevance leads, recency nudges.
|
|
||||||
#[cfg(feature = "embeddings")]
|
|
||||||
fn hybrid_rerank_blended() -> Mode {
|
|
||||||
HYBRID.reranked(
|
|
||||||
"Hybrid + rerank (relevance + recency)",
|
|
||||||
ReRankConfig::default(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The same blend at several half-lives. Decay is `2^(-age / half_life)`, so a
|
|
||||||
/// half-life far shorter than the gaps between memories sends every score to
|
|
||||||
/// zero and the signal vanishes; far longer and everything scores ~1 and it
|
|
||||||
/// vanishes the other way. The right value tracks how far apart the memories
|
|
||||||
/// actually are.
|
|
||||||
#[cfg(feature = "embeddings")]
|
|
||||||
fn hybrid_rerank_half_lives() -> Vec<Mode> {
|
|
||||||
[
|
|
||||||
("1 day", 86_400.0),
|
|
||||||
("7 days", 7.0 * 86_400.0),
|
|
||||||
("30 days", 30.0 * 86_400.0),
|
|
||||||
("90 days", 90.0 * 86_400.0),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.map(|(label, half_life)| {
|
|
||||||
HYBRID.reranked(
|
|
||||||
Box::leak(format!("Hybrid + rerank, half-life {label}").into_boxed_str()),
|
|
||||||
ReRankConfig {
|
|
||||||
temporal_half_life_secs: half_life,
|
|
||||||
..ReRankConfig::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
#[cfg(feature = "embeddings")]
|
|
||||||
const HYBRID_STEMMED: Mode = HYBRID.stemmed("Hybrid 0.4/0.6, stemmed tokens");
|
|
||||||
|
|
||||||
/// Every 0.1 step of vector weight, keyword weight taking the remainder.
|
/// Every 0.1 step of vector weight, keyword weight taking the remainder.
|
||||||
///
|
///
|
||||||
/// Labels are leaked to `&'static str` because `Mode::label` is a `&'static
|
/// Labels are leaked to `&'static str` because `Mode::label` is a `&'static
|
||||||
@@ -210,11 +104,11 @@ fn sweep_modes() -> Vec<Mode> {
|
|||||||
(0..=10)
|
(0..=10)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let v = i as f32 / 10.0;
|
let v = i as f32 / 10.0;
|
||||||
Mode::weighted(
|
Mode {
|
||||||
Box::leak(format!("sweep v={v:.1} / k={:.1}", 1.0 - v).into_boxed_str()),
|
label: Box::leak(format!("sweep v={v:.1} / k={:.1}", 1.0 - v).into_boxed_str()),
|
||||||
v,
|
vector_weight: v,
|
||||||
1.0 - v,
|
keyword_weight: 1.0 - v,
|
||||||
)
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -287,37 +181,6 @@ struct Question {
|
|||||||
haystack_session_ids: Vec<String>,
|
haystack_session_ids: Vec<String>,
|
||||||
haystack_sessions: Vec<Vec<Turn>>,
|
haystack_sessions: Vec<Vec<Turn>>,
|
||||||
answer_session_ids: Vec<String>,
|
answer_session_ids: Vec<String>,
|
||||||
/// One timestamp per haystack session, e.g. "2023/05/25 (Thu) 20:21".
|
|
||||||
#[serde(default)]
|
|
||||||
haystack_dates: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seconds since the epoch for a LongMemEval session date, which looks like
|
|
||||||
/// `2023/05/25 (Thu) 20:21`. Sessions are stored in chronological order, so a
|
|
||||||
/// date that cannot be parsed falls back to its position — order is preserved
|
|
||||||
/// even if the interval is not.
|
|
||||||
fn session_time(date: &str, position: usize) -> f64 {
|
|
||||||
let stamp = |y: i64, mo: i64, d: i64, h: i64, mi: i64| -> f64 {
|
|
||||||
// Days since 1970-01-01 via the civil-from-days algorithm.
|
|
||||||
let (y, mo) = if mo <= 2 { (y - 1, mo + 12) } else { (y, mo) };
|
|
||||||
let era = y.div_euclid(400);
|
|
||||||
let yoe = y - era * 400;
|
|
||||||
let doy = (153 * (mo - 3) + 2) / 5 + d - 1;
|
|
||||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
|
||||||
let days = era * 146_097 + doe - 719_468;
|
|
||||||
(days * 86_400 + h * 3_600 + mi * 60) as f64
|
|
||||||
};
|
|
||||||
let parse = || -> Option<f64> {
|
|
||||||
let (ymd, rest) = date.split_once(' ')?;
|
|
||||||
let mut ymd = ymd.split('/');
|
|
||||||
let y = ymd.next()?.parse().ok()?;
|
|
||||||
let mo = ymd.next()?.parse().ok()?;
|
|
||||||
let d = ymd.next()?.parse().ok()?;
|
|
||||||
let hm = rest.rsplit(' ').next()?;
|
|
||||||
let (h, mi) = hm.split_once(':')?;
|
|
||||||
Some(stamp(y, mo, d, h.parse().ok()?, mi.parse().ok()?))
|
|
||||||
};
|
|
||||||
parse().unwrap_or(1_000_000.0 + position as f64 * 86_400.0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -336,21 +199,11 @@ struct Metrics {
|
|||||||
rr_turn: f64,
|
rr_turn: f64,
|
||||||
abstention_correct: u32,
|
abstention_correct: u32,
|
||||||
abstention_total: u32,
|
abstention_total: u32,
|
||||||
/// Questions where the newest gold session outranked the older ones, out
|
|
||||||
/// of those with more than one gold session and at least one retrieved.
|
|
||||||
newest_gold_first: u32,
|
|
||||||
newest_gold_total: u32,
|
|
||||||
latency_ns: Vec<u64>,
|
latency_ns: Vec<u64>,
|
||||||
count: u32,
|
count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Metrics {
|
impl Metrics {
|
||||||
/// `None` when no question in this bucket had multiple gold sessions.
|
|
||||||
fn newest_gold_first_pct(&self) -> Option<f64> {
|
|
||||||
(self.newest_gold_total > 0)
|
|
||||||
.then(|| self.newest_gold_first as f64 / self.newest_gold_total as f64 * 100.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hit1_session_pct(&self) -> f64 {
|
fn hit1_session_pct(&self) -> f64 {
|
||||||
self.hit1_session as f64 / self.count.max(1) as f64 * 100.0
|
self.hit1_session as f64 / self.count.max(1) as f64 * 100.0
|
||||||
}
|
}
|
||||||
@@ -408,16 +261,6 @@ struct EvalResult {
|
|||||||
hit5_turn: bool,
|
hit5_turn: bool,
|
||||||
hit10_turn: bool,
|
hit10_turn: bool,
|
||||||
rr_turn: Option<f64>,
|
rr_turn: Option<f64>,
|
||||||
/// For a question whose evidence spans several dated sessions (a
|
|
||||||
/// `knowledge-update`, where an earlier fact is superseded by a later
|
|
||||||
/// one): did the *newest* gold session outrank every older gold session
|
|
||||||
/// that was returned? `None` when the question has one gold session, or
|
|
||||||
/// when none were retrieved, so there is nothing to discriminate.
|
|
||||||
///
|
|
||||||
/// Plain recall cannot see this. LongMemEval labels *both* the stale and
|
|
||||||
/// the updated session as gold, so returning either counts as a hit — yet
|
|
||||||
/// only one of them answers the question correctly.
|
|
||||||
newest_gold_first: Option<bool>,
|
|
||||||
latency: Duration,
|
latency: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,26 +276,19 @@ fn evaluate_question(
|
|||||||
config.compact_threshold = 0.0;
|
config.compact_threshold = 0.0;
|
||||||
|
|
||||||
let mut memory = HDF5Memory::create(config).expect("failed to create HDF5Memory");
|
let mut memory = HDF5Memory::create(config).expect("failed to create HDF5Memory");
|
||||||
memory.set_token_filter(mode.tokens);
|
|
||||||
|
|
||||||
// Build MemoryEntry list from all haystack sessions
|
// Build MemoryEntry list from all haystack sessions
|
||||||
let mut entries: Vec<MemoryEntry> = Vec::new();
|
let mut entries: Vec<MemoryEntry> = Vec::new();
|
||||||
let mut turn_has_answer: Vec<bool> = Vec::new();
|
let mut turn_has_answer: Vec<bool> = Vec::new();
|
||||||
|
let mut ts = 1_000_000.0f64;
|
||||||
|
|
||||||
for (sess_idx, session) in q.haystack_sessions.iter().enumerate() {
|
for (sess_idx, session) in q.haystack_sessions.iter().enumerate() {
|
||||||
let sess_id = q
|
let sess_id = q
|
||||||
.haystack_session_ids
|
.haystack_session_ids
|
||||||
.get(sess_idx)
|
.get(sess_idx)
|
||||||
.map(String::as_str)
|
.map(String::as_str)
|
||||||
.unwrap_or("unknown");
|
.unwrap_or("unknown");
|
||||||
// Real session dates, not a synthetic counter: anything that decays
|
for turn in session {
|
||||||
// with age needs true intervals, not just the right order.
|
|
||||||
let session_start = q
|
|
||||||
.haystack_dates
|
|
||||||
.get(sess_idx)
|
|
||||||
.map_or(sess_idx as f64 * 86_400.0, |d| session_time(d, sess_idx));
|
|
||||||
for (turn_idx, turn) in session.iter().enumerate() {
|
|
||||||
// Spread a session's turns over the minutes following its start.
|
|
||||||
let ts = session_start + turn_idx as f64 * 60.0;
|
|
||||||
entries.push(MemoryEntry {
|
entries.push(MemoryEntry {
|
||||||
chunk: turn.content.clone(),
|
chunk: turn.content.clone(),
|
||||||
embedding: embedding_for(embeddings, &turn.content),
|
embedding: embedding_for(embeddings, &turn.content),
|
||||||
@@ -466,6 +302,7 @@ fn evaluate_question(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
turn_has_answer.push(turn.has_answer);
|
turn_has_answer.push(turn.has_answer);
|
||||||
|
ts += 1.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,87 +319,17 @@ fn evaluate_question(
|
|||||||
// Set of session IDs that contain the answer
|
// Set of session IDs that contain the answer
|
||||||
let answer_sess_set: HashSet<&str> = q.answer_session_ids.iter().map(String::as_str).collect();
|
let answer_sess_set: HashSet<&str> = q.answer_session_ids.iter().map(String::as_str).collect();
|
||||||
|
|
||||||
// When each gold session was recorded, so "newest" is by date rather than
|
|
||||||
// by position (the two agree in this dataset, but the metric should not
|
|
||||||
// depend on that).
|
|
||||||
let gold_times: HashMap<&str, f64> = q
|
|
||||||
.haystack_session_ids
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, sid)| answer_sess_set.contains(sid.as_str()))
|
|
||||||
.map(|(i, sid)| {
|
|
||||||
let t = q
|
|
||||||
.haystack_dates
|
|
||||||
.get(i)
|
|
||||||
.map_or(i as f64 * 86_400.0, |d| session_time(d, i));
|
|
||||||
(sid.as_str(), t)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let query_emb = embedding_for(embeddings, &q.question);
|
let query_emb = embedding_for(embeddings, &q.question);
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
// Re-ranking only reorders; it needs a candidate pool larger than `top_k`
|
let results = memory.hybrid_search(
|
||||||
// to have anything to promote.
|
&query_emb,
|
||||||
let pool = if mode.rerank.is_some() {
|
&q.question,
|
||||||
top_k * 4
|
mode.vector_weight,
|
||||||
} else {
|
mode.keyword_weight,
|
||||||
top_k
|
top_k,
|
||||||
};
|
);
|
||||||
let mut results = memory.hybrid_search_with(&query_emb, &q.question, mode.fusion, pool);
|
|
||||||
if let Some(config) = mode.rerank {
|
|
||||||
// "Now" is the moment the question was asked, so decay measures how
|
|
||||||
// stale each memory was at that point.
|
|
||||||
let now = session_time(&q.question_date, q.haystack_sessions.len());
|
|
||||||
let inputs: Vec<RerankInput> = results
|
|
||||||
.iter()
|
|
||||||
.map(|r| RerankInput {
|
|
||||||
index: r.index,
|
|
||||||
timestamp: r.timestamp,
|
|
||||||
source_channel: r.source_channel.clone(),
|
|
||||||
raw_activation: r.activation,
|
|
||||||
relevance: r.score,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let order: Vec<usize> = rerank(&inputs, &config, now)
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| r.index)
|
|
||||||
.collect();
|
|
||||||
let by_index: HashMap<usize, SearchResult> =
|
|
||||||
results.into_iter().map(|r| (r.index, r)).collect();
|
|
||||||
results = order
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|i| by_index.get(&i).cloned())
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
results.truncate(top_k);
|
|
||||||
let latency = t0.elapsed();
|
let latency = t0.elapsed();
|
||||||
|
|
||||||
// Rank of the best-placed result from each gold session.
|
|
||||||
let mut first_rank: HashMap<&str, usize> = HashMap::new();
|
|
||||||
for (rank, result) in results.iter().enumerate() {
|
|
||||||
let sid = memory.cache.session_ids[result.index].as_str();
|
|
||||||
if let Some((gold_sid, _)) = gold_times.get_key_value(sid) {
|
|
||||||
first_rank.entry(gold_sid).or_insert(rank);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let newest_gold_first = if gold_times.len() < 2 || first_rank.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
// The newest gold session must be retrieved, and no older gold session
|
|
||||||
// may outrank it.
|
|
||||||
let newest = gold_times
|
|
||||||
.iter()
|
|
||||||
.max_by(|a, b| a.1.total_cmp(b.1))
|
|
||||||
.map(|(sid, _)| *sid)
|
|
||||||
.expect("at least two gold sessions");
|
|
||||||
Some(match first_rank.get(newest) {
|
|
||||||
Some(&newest_rank) => first_rank
|
|
||||||
.iter()
|
|
||||||
.all(|(sid, &rank)| *sid == newest || rank > newest_rank),
|
|
||||||
None => false,
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
// Session-level recall
|
// Session-level recall
|
||||||
let mut hit1_session = false;
|
let mut hit1_session = false;
|
||||||
let mut hit5_session = false;
|
let mut hit5_session = false;
|
||||||
@@ -617,7 +384,6 @@ fn evaluate_question(
|
|||||||
hit5_turn,
|
hit5_turn,
|
||||||
hit10_turn,
|
hit10_turn,
|
||||||
rr_turn,
|
rr_turn,
|
||||||
newest_gold_first,
|
|
||||||
latency,
|
latency,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -706,7 +472,10 @@ fn print_report(
|
|||||||
println!(" LongMemEval Benchmark — {}", mode.label);
|
println!(" LongMemEval Benchmark — {}", mode.label);
|
||||||
println!("=================================================================");
|
println!("=================================================================");
|
||||||
println!();
|
println!();
|
||||||
println!("Mode: {}", describe(mode));
|
println!(
|
||||||
|
"Mode: vector_weight={:.1} / keyword_weight={:.1}",
|
||||||
|
mode.vector_weight, mode.keyword_weight
|
||||||
|
);
|
||||||
println!();
|
println!();
|
||||||
println!("Scoring target: RETRIEVAL RECALL (did the gold memory land in top-k).");
|
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!(" No answer is generated or scored. This is NOT the official");
|
||||||
@@ -769,24 +538,6 @@ fn print_report(
|
|||||||
);
|
);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
if let Some(pct) = overall.newest_gold_first_pct() {
|
|
||||||
println!(
|
|
||||||
"## Recency Discrimination (n={})",
|
|
||||||
overall.newest_gold_total
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" Newest gold session ranked first: {}/{} ({pct:.1}%)",
|
|
||||||
overall.newest_gold_first, overall.newest_gold_total
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" Questions whose evidence spans several dated sessions — a fact and\n \
|
|
||||||
its later correction. Both sessions are labelled gold, so recall\n \
|
|
||||||
scores either as a hit; this asks whether the *current* one came\n \
|
|
||||||
first. A retriever with no sense of time scores near chance."
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
if overall.abstention_total > 0 {
|
if overall.abstention_total > 0 {
|
||||||
println!("## Abstention Accuracy");
|
println!("## Abstention Accuracy");
|
||||||
println!(
|
println!(
|
||||||
@@ -851,7 +602,10 @@ fn print_report(
|
|||||||
println!("```json");
|
println!("```json");
|
||||||
println!("{{");
|
println!("{{");
|
||||||
println!(" \"benchmark\": \"longmemeval\",");
|
println!(" \"benchmark\": \"longmemeval\",");
|
||||||
println!(" \"mode\": \"{}\",", describe(mode));
|
println!(
|
||||||
|
" \"mode\": \"vector_{:.1}_keyword_{:.1}\",",
|
||||||
|
mode.vector_weight, mode.keyword_weight
|
||||||
|
);
|
||||||
println!(" \"dataset_variant\": \"{}\",", profile.variant());
|
println!(" \"dataset_variant\": \"{}\",", profile.variant());
|
||||||
println!(" \"scoring_target\": \"retrieval_recall\",");
|
println!(" \"scoring_target\": \"retrieval_recall\",");
|
||||||
println!(" \"k\": 10,");
|
println!(" \"k\": 10,");
|
||||||
@@ -900,14 +654,6 @@ fn print_report(
|
|||||||
} else {
|
} else {
|
||||||
println!(" \"abstention_accuracy\": null,");
|
println!(" \"abstention_accuracy\": null,");
|
||||||
}
|
}
|
||||||
match overall.newest_gold_first_pct() {
|
|
||||||
Some(pct) => println!(
|
|
||||||
" \"newest_gold_first\": {:.4}, \"newest_gold_n\": {},",
|
|
||||||
pct / 100.0,
|
|
||||||
overall.newest_gold_total
|
|
||||||
),
|
|
||||||
None => println!(" \"newest_gold_first\": null,"),
|
|
||||||
}
|
|
||||||
println!(" \"latency_us\": {{");
|
println!(" \"latency_us\": {{");
|
||||||
println!(
|
println!(
|
||||||
" \"avg\": {:.1}, \"p50\": {:.1}, \"p95\": {:.1}, \"p99\": {:.1}",
|
" \"avg\": {:.1}, \"p50\": {:.1}, \"p95\": {:.1}, \"p99\": {:.1}",
|
||||||
@@ -930,8 +676,6 @@ fn main() {
|
|||||||
let mut limit: Option<usize> = None;
|
let mut limit: Option<usize> = None;
|
||||||
let mut weights_dir: Option<String> = None;
|
let mut weights_dir: Option<String> = None;
|
||||||
let mut sweep = false;
|
let mut sweep = false;
|
||||||
#[cfg_attr(not(feature = "embeddings"), allow(unused_mut, unused_variables))]
|
|
||||||
let mut rerank_sweep = false;
|
|
||||||
let mut args = std::env::args().skip(1);
|
let mut args = std::env::args().skip(1);
|
||||||
while let Some(arg) = args.next() {
|
while let Some(arg) = args.next() {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
@@ -940,16 +684,6 @@ fn main() {
|
|||||||
limit = Some(v.parse().expect("--limit must be a positive integer"));
|
limit = Some(v.parse().expect("--limit must be a positive integer"));
|
||||||
}
|
}
|
||||||
"--sweep" => sweep = true,
|
"--sweep" => sweep = true,
|
||||||
"--rerank-sweep" => {
|
|
||||||
// Re-ranking needs the vector stage to have candidates worth
|
|
||||||
// reordering, so this is an embeddings-only comparison.
|
|
||||||
#[cfg(feature = "embeddings")]
|
|
||||||
{
|
|
||||||
rerank_sweep = true;
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "embeddings"))]
|
|
||||||
eprintln!("warning: --rerank-sweep needs --features embeddings; ignoring");
|
|
||||||
}
|
|
||||||
"--embeddings" => {
|
"--embeddings" => {
|
||||||
weights_dir = Some(args.next().expect("--embeddings needs a directory"));
|
weights_dir = Some(args.next().expect("--embeddings needs a directory"));
|
||||||
}
|
}
|
||||||
@@ -968,9 +702,6 @@ fn main() {
|
|||||||
BM25-only, vector-only, and hybrid separately. Requires\n\
|
BM25-only, vector-only, and hybrid separately. Requires\n\
|
||||||
--features embeddings; without it the vector stage is\n\
|
--features embeddings; without it the vector stage is\n\
|
||||||
inert and only the BM25 row is produced.\n\
|
inert and only the BM25 row is produced.\n\
|
||||||
--rerank-sweep\n\
|
|
||||||
compare re-ranking off, metadata-only (the old\n\
|
|
||||||
behaviour) and blended at several half-lives.\n\
|
|
||||||
--sweep instead of the three named modes, sweep vector_weight\n\
|
--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\
|
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."
|
never searched; this is what searches it."
|
||||||
@@ -1036,34 +767,19 @@ fn main() {
|
|||||||
{
|
{
|
||||||
if sweep {
|
if sweep {
|
||||||
sweep_modes()
|
sweep_modes()
|
||||||
} else if rerank_sweep {
|
|
||||||
let mut modes = vec![HYBRID, hybrid_rerank_metadata_only()];
|
|
||||||
modes.extend(hybrid_rerank_half_lives());
|
|
||||||
modes
|
|
||||||
} else {
|
} else {
|
||||||
vec![
|
vec![BM25_ONLY, VECTOR_ONLY, HYBRID]
|
||||||
BM25_ONLY,
|
|
||||||
VECTOR_ONLY,
|
|
||||||
HYBRID,
|
|
||||||
RRF,
|
|
||||||
BM25_STEMMED,
|
|
||||||
HYBRID_STEMMED,
|
|
||||||
hybrid_rerank_metadata_only(),
|
|
||||||
hybrid_rerank_blended(),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "embeddings"))]
|
#[cfg(not(feature = "embeddings"))]
|
||||||
{
|
{
|
||||||
vec![BM25_ONLY, BM25_STEMMED]
|
vec![BM25_ONLY]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if sweep {
|
if sweep {
|
||||||
eprintln!("warning: --sweep needs --embeddings; running BM25 only");
|
eprintln!("warning: --sweep needs --embeddings; running BM25 only");
|
||||||
}
|
}
|
||||||
// Stemming is a property of the keyword stage, so it can be compared
|
vec![BM25_ONLY]
|
||||||
// without a model.
|
|
||||||
vec![BM25_ONLY, BM25_STEMMED]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (mode_idx, mode) in modes.iter().enumerate() {
|
for (mode_idx, mode) in modes.iter().enumerate() {
|
||||||
@@ -1166,14 +882,6 @@ fn run_mode(
|
|||||||
entry.rr_turn += rr;
|
entry.rr_turn += rr;
|
||||||
overall.rr_turn += rr;
|
overall.rr_turn += rr;
|
||||||
}
|
}
|
||||||
if let Some(newest_first) = result.newest_gold_first {
|
|
||||||
entry.newest_gold_total += 1;
|
|
||||||
overall.newest_gold_total += 1;
|
|
||||||
if newest_first {
|
|
||||||
entry.newest_gold_first += 1;
|
|
||||||
overall.newest_gold_first += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let ns = result.latency.as_nanos() as u64;
|
let ns = result.latency.as_nanos() as u64;
|
||||||
entry.latency_ns.push(ns);
|
entry.latency_ns.push(ns);
|
||||||
@@ -1185,30 +893,3 @@ fn run_mode(
|
|||||||
eprintln!();
|
eprintln!();
|
||||||
print_report(&overall, &by_type, profile, mode);
|
print_report(&overall, &by_type, profile, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::session_time;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn session_dates_parse_to_the_right_instant() {
|
|
||||||
// Reference values from Python's datetime, UTC.
|
|
||||||
for (date, expected) in [
|
|
||||||
("2023/05/25 (Thu) 20:21", 1_685_046_060.0),
|
|
||||||
("1970/01/01 (Thu) 00:00", 0.0),
|
|
||||||
("2000/02/29 (Tue) 12:00", 951_825_600.0),
|
|
||||||
("2023/12/31 (Sun) 23:59", 1_704_067_140.0),
|
|
||||||
("2024/03/01 (Fri) 00:00", 1_709_251_200.0),
|
|
||||||
] {
|
|
||||||
assert_eq!(session_time(date, 0), expected, "{date}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unparseable_dates_fall_back_to_position_order() {
|
|
||||||
let a = session_time("not a date", 0);
|
|
||||||
let b = session_time("", 1);
|
|
||||||
let c = session_time("2023/13/99 (???) 99:99", 2);
|
|
||||||
assert!(a < b && b < c, "fallback must preserve session order");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
//! HDF5 read-path measurement harness: full reads vs. hyperslab selections on
|
|
||||||
//! a chunked 2-D dataset, compressed and uncompressed, plus a contiguous one.
|
|
||||||
//!
|
|
||||||
//! The question it answers for every read-path change: does the cost of a
|
|
||||||
//! selection scale with the *selection*, or with the whole dataset?
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! cargo run --release -p clawhdf5-bench --bin read_harness
|
|
||||||
//! cargo run --release -p clawhdf5-bench --bin read_harness -- --large # 512 MB
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use clawhdf5::{File, FileBuilder};
|
|
||||||
use clawhdf5_format::selection::Selection;
|
|
||||||
|
|
||||||
const CHUNK: u64 = 256;
|
|
||||||
|
|
||||||
struct Layout {
|
|
||||||
name: &'static str,
|
|
||||||
chunked: bool,
|
|
||||||
deflate: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
const LAYOUTS: [Layout; 3] = [
|
|
||||||
Layout {
|
|
||||||
name: "chunked + deflate",
|
|
||||||
chunked: true,
|
|
||||||
deflate: true,
|
|
||||||
},
|
|
||||||
Layout {
|
|
||||||
name: "chunked",
|
|
||||||
chunked: true,
|
|
||||||
deflate: false,
|
|
||||||
},
|
|
||||||
Layout {
|
|
||||||
name: "contiguous",
|
|
||||||
chunked: false,
|
|
||||||
deflate: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Smooth-ish, compressible data whose value encodes its position, so a read
|
|
||||||
/// can be verified exactly.
|
|
||||||
fn value(row: u64, col: u64) -> f64 {
|
|
||||||
(row * 100_003 + col) as f64 * 0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_file(path: &std::path::Path, rows: u64, cols: u64) {
|
|
||||||
let data: Vec<f64> = (0..rows)
|
|
||||||
.flat_map(|r| (0..cols).map(move |c| value(r, c)))
|
|
||||||
.collect();
|
|
||||||
let mut builder = FileBuilder::new();
|
|
||||||
for (i, layout) in LAYOUTS.iter().enumerate() {
|
|
||||||
let ds = builder.create_dataset(&format!("d{i}"));
|
|
||||||
ds.with_f64_data(&data).with_shape(&[rows, cols]);
|
|
||||||
if layout.chunked {
|
|
||||||
ds.with_chunks(&[CHUNK, CHUNK]);
|
|
||||||
}
|
|
||||||
if layout.deflate {
|
|
||||||
ds.with_deflate(4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
builder.write(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn median(mut samples: Vec<Duration>) -> Duration {
|
|
||||||
samples.sort();
|
|
||||||
samples[samples.len() / 2]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn time<T>(reps: usize, mut f: impl FnMut() -> T) -> Duration {
|
|
||||||
median(
|
|
||||||
(0..reps)
|
|
||||||
.map(|_| {
|
|
||||||
let t = Instant::now();
|
|
||||||
std::hint::black_box(f());
|
|
||||||
t.elapsed()
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn slab(start: [u64; 2], count: [u64; 2]) -> Selection {
|
|
||||||
Selection::Hyperslab {
|
|
||||||
start: start.to_vec(),
|
|
||||||
stride: vec![1, 1],
|
|
||||||
count: count.to_vec(),
|
|
||||||
block: vec![1, 1],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let large = std::env::args().any(|a| a == "--large");
|
|
||||||
let (rows, cols) = if large { (8192, 8192) } else { (4096, 2048) };
|
|
||||||
let total_mb = (rows * cols * 8) as f64 / (1 << 20) as f64;
|
|
||||||
if cfg!(debug_assertions) {
|
|
||||||
eprintln!("warning: debug build — numbers are meaningless. Use --release.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let dir = tempfile::TempDir::new().unwrap();
|
|
||||||
let path = dir.path().join("read_harness.h5");
|
|
||||||
write_file(&path, rows, cols);
|
|
||||||
let file_mb = std::fs::metadata(&path).unwrap().len() as f64 / (1 << 20) as f64;
|
|
||||||
|
|
||||||
println!("## Read harness");
|
|
||||||
println!(
|
|
||||||
"\n{rows} x {cols} f64 ({total_mb:.0} MB per dataset), chunks {CHUNK} x {CHUNK}, file {file_mb:.0} MB\n"
|
|
||||||
);
|
|
||||||
|
|
||||||
// (label, selection, elements selected)
|
|
||||||
let selections: Vec<(&str, Selection, u64)> = vec![
|
|
||||||
(
|
|
||||||
"64 x 64 window (1 chunk)",
|
|
||||||
slab([300, 300], [64, 64]),
|
|
||||||
64 * 64,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"512 x 512 window (4-9 chunks)",
|
|
||||||
slab([1000, 700], [512, 512]),
|
|
||||||
512 * 512,
|
|
||||||
),
|
|
||||||
("one row", slab([rows / 2, 0], [1, cols]), cols),
|
|
||||||
("one column", slab([0, cols / 2], [rows, 1]), rows),
|
|
||||||
];
|
|
||||||
|
|
||||||
println!("| layout | read | selected | time ms | MB/s of selection | vs full read |");
|
|
||||||
println!("|---|---|---:|---:|---:|---:|");
|
|
||||||
for (i, layout) in LAYOUTS.iter().enumerate() {
|
|
||||||
// Fresh handle per layout so one dataset's cached chunks don't help
|
|
||||||
// (or evict) another's.
|
|
||||||
let file = File::open(&path).unwrap();
|
|
||||||
let ds = file.dataset(&format!("d{i}")).unwrap();
|
|
||||||
|
|
||||||
let full_cold = time(1, || ds.read_f64().unwrap());
|
|
||||||
let full = time(3, || ds.read_f64().unwrap());
|
|
||||||
println!(
|
|
||||||
"| {} | full (first) | {total_mb:.0} MB | {:.1} | {:.0} | |",
|
|
||||||
layout.name,
|
|
||||||
full_cold.as_secs_f64() * 1e3,
|
|
||||||
total_mb / full_cold.as_secs_f64()
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"| {} | full (repeat) | {total_mb:.0} MB | {:.1} | {:.0} | 1.00x |",
|
|
||||||
layout.name,
|
|
||||||
full.as_secs_f64() * 1e3,
|
|
||||||
total_mb / full.as_secs_f64()
|
|
||||||
);
|
|
||||||
|
|
||||||
for (label, selection, elements) in &selections {
|
|
||||||
// A fresh handle again: measure the selection on its own, not
|
|
||||||
// served from chunks the full read just cached.
|
|
||||||
let file = File::open(&path).unwrap();
|
|
||||||
let ds = file.dataset(&format!("d{i}")).unwrap();
|
|
||||||
let got = ds.read_f64_selection(selection).unwrap();
|
|
||||||
assert_eq!(got.len() as u64, *elements, "{label}");
|
|
||||||
if let Selection::Hyperslab { start, .. } = selection {
|
|
||||||
assert_eq!(got[0], value(start[0], start[1]), "{label}: wrong data");
|
|
||||||
}
|
|
||||||
let took = time(5, || {
|
|
||||||
let file = File::open(&path).unwrap();
|
|
||||||
let ds = file.dataset(&format!("d{i}")).unwrap();
|
|
||||||
ds.read_f64_selection(selection).unwrap()
|
|
||||||
});
|
|
||||||
let mb = (*elements * 8) as f64 / (1 << 20) as f64;
|
|
||||||
println!(
|
|
||||||
"| {} | {label} | {:.2} MB | {:.2} | {:.0} | {:.3}x |",
|
|
||||||
layout.name,
|
|
||||||
mb,
|
|
||||||
took.as_secs_f64() * 1e3,
|
|
||||||
mb / took.as_secs_f64(),
|
|
||||||
took.as_secs_f64() / full_cold.as_secs_f64()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,679 +0,0 @@
|
|||||||
//! Search measurement harness: recall vs. speed for the HNSW index, and
|
|
||||||
//! end-to-end `hybrid_search` latency as the store grows.
|
|
||||||
//!
|
|
||||||
//! Every search-path change should be justified by a before/after run of this
|
|
||||||
//! binary. It reports, for deterministic synthetic data:
|
|
||||||
//!
|
|
||||||
//! * **ANN** — index build time, and for each `ef`: recall@10 against an exact
|
|
||||||
//! brute-force scan, queries/second, and p50/p99 latency.
|
|
||||||
//! * **End to end** — `HDF5Memory`: ingest time, checkpoint time, `open()`
|
|
||||||
//! time, the one-off cold index build (first query ever), the first query
|
|
||||||
//! after a reopen, and steady-state `hybrid_search` p50/p99 at each size.
|
|
||||||
//!
|
|
||||||
//! Data is *clustered* (points = cluster centre + noise, unit-normalised), not
|
|
||||||
//! uniform: uniform random high-dimensional vectors are nearly equidistant,
|
|
||||||
//! which makes recall numbers meaningless and is nothing like embeddings.
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! cargo run --release -p clawhdf5-bench --bin search_harness # 1K, 10K
|
|
||||||
//! cargo run --release -p clawhdf5-bench --bin search_harness -- --full # + 100K
|
|
||||||
//! cargo run --release -p clawhdf5-bench --bin search_harness -- --json out.json
|
|
||||||
//! cargo run --release -p clawhdf5-bench --bin search_harness -- --ann-only --uniform
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
|
||||||
use clawhdf5_ann::{DistanceMetric, HnswIndex, Storage};
|
|
||||||
|
|
||||||
const DIM: usize = 384;
|
|
||||||
const K: usize = 10;
|
|
||||||
const N_QUERIES: usize = 200;
|
|
||||||
const HNSW_M: usize = 16;
|
|
||||||
const HNSW_EF_CONSTRUCTION: usize = 64;
|
|
||||||
const EF_VALUES: [usize; 5] = [16, 32, 64, 128, 256];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Deterministic data
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
struct Rng(u64);
|
|
||||||
|
|
||||||
impl Rng {
|
|
||||||
fn next_u64(&mut self) -> u64 {
|
|
||||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
|
||||||
let mut z = self.0;
|
|
||||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
|
||||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
|
||||||
z ^ (z >> 31)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Uniform in [0, 1).
|
|
||||||
fn unit(&mut self) -> f32 {
|
|
||||||
(self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Approximately standard normal (sum of uniforms).
|
|
||||||
fn gauss(&mut self) -> f32 {
|
|
||||||
let sum: f32 = (0..6).map(|_| self.unit()).sum();
|
|
||||||
(sum - 3.0) * std::f32::consts::SQRT_2
|
|
||||||
}
|
|
||||||
|
|
||||||
fn below(&mut self, n: usize) -> usize {
|
|
||||||
(self.next_u64() % n as u64) as usize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize(v: &mut [f32]) {
|
|
||||||
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
|
||||||
if norm > 0.0 {
|
|
||||||
v.iter_mut().for_each(|x| *x /= norm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Dataset {
|
|
||||||
vectors: Vec<Vec<f32>>,
|
|
||||||
queries: Vec<Vec<f32>>,
|
|
||||||
/// Cluster id of each vector (used to give records topical text).
|
|
||||||
cluster_of: Vec<usize>,
|
|
||||||
query_cluster: Vec<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--uniform`: isotropic random unit vectors instead of clusters. Not a
|
|
||||||
/// realistic workload, but a useful second distribution — a recall problem
|
|
||||||
/// that appears only on clustered data points at graph connectivity.
|
|
||||||
static UNIFORM: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
|
||||||
|
|
||||||
/// `--int8`: build the HNSW index over int8-quantised vectors (a quarter of
|
|
||||||
/// the memory) instead of f32, to price the recall it costs.
|
|
||||||
static INT8: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
|
||||||
|
|
||||||
/// `--rerank`: re-score the candidate pool against the exact vectors before
|
|
||||||
/// taking the top K.
|
|
||||||
static RERANK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
|
||||||
|
|
||||||
fn storage() -> Storage {
|
|
||||||
if INT8.load(std::sync::atomic::Ordering::Relaxed) {
|
|
||||||
Storage::Int8
|
|
||||||
} else {
|
|
||||||
Storage::Float32
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_dataset(n: usize, seed: u64) -> Dataset {
|
|
||||||
let mut rng = Rng(seed);
|
|
||||||
if UNIFORM.load(std::sync::atomic::Ordering::Relaxed) {
|
|
||||||
let random_unit = |rng: &mut Rng| {
|
|
||||||
let mut v: Vec<f32> = (0..DIM).map(|_| rng.gauss()).collect();
|
|
||||||
normalize(&mut v);
|
|
||||||
v
|
|
||||||
};
|
|
||||||
return Dataset {
|
|
||||||
vectors: (0..n).map(|_| random_unit(&mut rng)).collect(),
|
|
||||||
queries: (0..N_QUERIES).map(|_| random_unit(&mut rng)).collect(),
|
|
||||||
cluster_of: vec![0; n],
|
|
||||||
query_cluster: vec![0; N_QUERIES],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let n_clusters = (n / 100).clamp(8, 512);
|
|
||||||
let centres: Vec<Vec<f32>> = (0..n_clusters)
|
|
||||||
.map(|_| {
|
|
||||||
let mut c: Vec<f32> = (0..DIM).map(|_| rng.gauss()).collect();
|
|
||||||
normalize(&mut c);
|
|
||||||
c
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let point = |rng: &mut Rng, cluster: usize| {
|
|
||||||
// Noise comparable to the centre's per-dimension magnitude, so
|
|
||||||
// clusters overlap and the nearest neighbours are non-trivial.
|
|
||||||
let scale = 0.6 / (DIM as f32).sqrt();
|
|
||||||
let mut v: Vec<f32> = centres[cluster]
|
|
||||||
.iter()
|
|
||||||
.map(|c| c + rng.gauss() * scale)
|
|
||||||
.collect();
|
|
||||||
normalize(&mut v);
|
|
||||||
v
|
|
||||||
};
|
|
||||||
let mut vectors = Vec::with_capacity(n);
|
|
||||||
let mut cluster_of = Vec::with_capacity(n);
|
|
||||||
for _ in 0..n {
|
|
||||||
let c = rng.below(n_clusters);
|
|
||||||
vectors.push(point(&mut rng, c));
|
|
||||||
cluster_of.push(c);
|
|
||||||
}
|
|
||||||
let mut queries = Vec::with_capacity(N_QUERIES);
|
|
||||||
let mut query_cluster = Vec::with_capacity(N_QUERIES);
|
|
||||||
for _ in 0..N_QUERIES {
|
|
||||||
let c = rng.below(n_clusters);
|
|
||||||
queries.push(point(&mut rng, c));
|
|
||||||
query_cluster.push(c);
|
|
||||||
}
|
|
||||||
Dataset {
|
|
||||||
vectors,
|
|
||||||
queries,
|
|
||||||
cluster_of,
|
|
||||||
query_cluster,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORDS: &[&str] = &[
|
|
||||||
"deploy", "latency", "cache", "schema", "index", "vector", "memory", "agent", "kernel",
|
|
||||||
"buffer", "socket", "thread", "tensor", "gradient", "ledger", "invoice", "meeting", "roadmap",
|
|
||||||
"customer", "contract", "sensor", "orbit", "protein", "genome", "harbor", "bridge", "engine",
|
|
||||||
"battery", "harvest", "weather", "museum", "recipe",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Text whose vocabulary is biased by cluster, so keyword and vector signals
|
|
||||||
/// agree the way they do for real embedded text.
|
|
||||||
fn text_for(cluster: usize, i: usize, rng: &mut Rng) -> String {
|
|
||||||
let topic = [
|
|
||||||
WORDS[cluster % WORDS.len()],
|
|
||||||
WORDS[(cluster / 7 + 3) % WORDS.len()],
|
|
||||||
];
|
|
||||||
let mut words = Vec::with_capacity(14);
|
|
||||||
for j in 0..14 {
|
|
||||||
if j % 3 == 0 {
|
|
||||||
words.push(topic[j / 3 % 2]);
|
|
||||||
} else {
|
|
||||||
words.push(WORDS[rng.below(WORDS.len())]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
format!("record {i}: {}", words.join(" "))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Measurement helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Exact cosine distance between unit-length vectors.
|
|
||||||
fn exact_dist(a: &[f32], b: &[f32]) -> f32 {
|
|
||||||
1.0 - a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn exact_top_k(vectors: &[Vec<f32>], query: &[f32], k: usize) -> Vec<usize> {
|
|
||||||
// Vectors are unit length, so cosine order == dot-product order.
|
|
||||||
let mut scored: Vec<(usize, f32)> = vectors
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, v)| (i, v.iter().zip(query).map(|(a, b)| a * b).sum()))
|
|
||||||
.collect();
|
|
||||||
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
|
||||||
scored.truncate(k);
|
|
||||||
scored.into_iter().map(|(i, _)| i).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Latency {
|
|
||||||
p50: Duration,
|
|
||||||
p99: Duration,
|
|
||||||
qps: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn summarize(mut samples: Vec<Duration>) -> Latency {
|
|
||||||
samples.sort();
|
|
||||||
let total: Duration = samples.iter().sum();
|
|
||||||
let at = |q: f64| samples[((samples.len() - 1) as f64 * q).round() as usize];
|
|
||||||
Latency {
|
|
||||||
p50: at(0.50),
|
|
||||||
p99: at(0.99),
|
|
||||||
qps: samples.len() as f64 / total.as_secs_f64(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Counts live heap bytes, so a structure's cost can be measured by
|
|
||||||
/// difference.
|
|
||||||
///
|
|
||||||
/// RSS cannot do this from inside one process: freeing a large structure
|
|
||||||
/// returns its pages to the allocator's pool rather than to the OS, so
|
|
||||||
/// allocating the next one shows no change. Measured that way, a store that
|
|
||||||
/// holds the corpus twice and one that holds it once look identical.
|
|
||||||
struct CountingAllocator;
|
|
||||||
|
|
||||||
static LIVE_BYTES: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
|
|
||||||
|
|
||||||
// SAFETY: every method forwards to the system allocator with the same layout
|
|
||||||
// it was given, and only adds bookkeeping around it.
|
|
||||||
unsafe impl std::alloc::GlobalAlloc for CountingAllocator {
|
|
||||||
unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
|
|
||||||
let ptr = unsafe { std::alloc::System.alloc(layout) };
|
|
||||||
if !ptr.is_null() {
|
|
||||||
LIVE_BYTES.fetch_add(layout.size() as i64, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
ptr
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: std::alloc::Layout) {
|
|
||||||
LIVE_BYTES.fetch_sub(layout.size() as i64, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
unsafe { std::alloc::System.dealloc(ptr, layout) }
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe fn realloc(&self, ptr: *mut u8, layout: std::alloc::Layout, new_size: usize) -> *mut u8 {
|
|
||||||
let new_ptr = unsafe { std::alloc::System.realloc(ptr, layout, new_size) };
|
|
||||||
if !new_ptr.is_null() {
|
|
||||||
LIVE_BYTES.fetch_add(
|
|
||||||
new_size as i64 - layout.size() as i64,
|
|
||||||
std::sync::atomic::Ordering::Relaxed,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
new_ptr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[global_allocator]
|
|
||||||
static ALLOCATOR: CountingAllocator = CountingAllocator;
|
|
||||||
|
|
||||||
/// Live heap bytes right now.
|
|
||||||
fn heap_bytes() -> u64 {
|
|
||||||
LIVE_BYTES.load(std::sync::atomic::Ordering::Relaxed).max(0) as u64
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mib(bytes: u64) -> f64 {
|
|
||||||
bytes as f64 / (1 << 20) as f64
|
|
||||||
}
|
|
||||||
|
|
||||||
fn micros(d: Duration) -> f64 {
|
|
||||||
d.as_secs_f64() * 1e6
|
|
||||||
}
|
|
||||||
|
|
||||||
fn millis(d: Duration) -> f64 {
|
|
||||||
d.as_secs_f64() * 1e3
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ANN: recall vs speed
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn bench_ann(n: usize, json: &mut Vec<serde_json::Value>) {
|
|
||||||
let data = make_dataset(n, 0xA11CE ^ n as u64);
|
|
||||||
let truth: Vec<Vec<usize>> = data
|
|
||||||
.queries
|
|
||||||
.iter()
|
|
||||||
.map(|q| exact_top_k(&data.vectors, q, K))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let started = Instant::now();
|
|
||||||
let index = HnswIndex::build_with(
|
|
||||||
&data.vectors,
|
|
||||||
HNSW_M,
|
|
||||||
HNSW_EF_CONSTRUCTION,
|
|
||||||
DistanceMetric::Cosine,
|
|
||||||
storage(),
|
|
||||||
);
|
|
||||||
let build = started.elapsed();
|
|
||||||
|
|
||||||
// Exact scan baseline, for scale.
|
|
||||||
let exact = summarize(
|
|
||||||
data.queries
|
|
||||||
.iter()
|
|
||||||
.map(|q| {
|
|
||||||
let t = Instant::now();
|
|
||||||
std::hint::black_box(exact_top_k(&data.vectors, q, K));
|
|
||||||
t.elapsed()
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"\n### HNSW, N = {n}, dim = {DIM}, M = {HNSW_M}, ef_construction = {HNSW_EF_CONSTRUCTION}, storage = {:?}\n",
|
|
||||||
index.storage()
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"build: {:.1} ms ({:.0} vectors/s) · exact scan: {:.0} QPS, p50 {:.0} µs\n",
|
|
||||||
millis(build),
|
|
||||||
n as f64 / build.as_secs_f64(),
|
|
||||||
exact.qps,
|
|
||||||
micros(exact.p50)
|
|
||||||
);
|
|
||||||
println!("| ef | recall@{K} | QPS | p50 µs | p99 µs |");
|
|
||||||
println!("|---:|---:|---:|---:|---:|");
|
|
||||||
// With a quantised index the distances it returns are approximate, so
|
|
||||||
// the candidates are re-scored against the exact vectors the caller
|
|
||||||
// already holds (in the agent, the embedding cache) before taking the
|
|
||||||
// top K. `--rerank` prices that: it costs one exact distance per
|
|
||||||
// candidate and is what decides whether int8 is usable.
|
|
||||||
let rerank = RERANK.load(std::sync::atomic::Ordering::Relaxed);
|
|
||||||
let pool = if rerank { K * 4 } else { K };
|
|
||||||
for ef in EF_VALUES {
|
|
||||||
let mut hits = 0usize;
|
|
||||||
let mut samples = Vec::with_capacity(data.queries.len());
|
|
||||||
for (q, want) in data.queries.iter().zip(&truth) {
|
|
||||||
let t = Instant::now();
|
|
||||||
let mut got = index.search(q, pool, ef.max(pool));
|
|
||||||
if rerank {
|
|
||||||
for cand in &mut got {
|
|
||||||
cand.1 = exact_dist(&data.vectors[cand.0], q);
|
|
||||||
}
|
|
||||||
got.select_nth_unstable_by(K - 1, |a, b| a.1.total_cmp(&b.1));
|
|
||||||
got.truncate(K);
|
|
||||||
}
|
|
||||||
samples.push(t.elapsed());
|
|
||||||
hits += got.iter().filter(|(id, _)| want.contains(id)).count();
|
|
||||||
}
|
|
||||||
let recall = hits as f64 / (K * data.queries.len()) as f64;
|
|
||||||
let lat = summarize(samples);
|
|
||||||
println!(
|
|
||||||
"| {ef} | {recall:.4} | {:.0} | {:.0} | {:.0} |",
|
|
||||||
lat.qps,
|
|
||||||
micros(lat.p50),
|
|
||||||
micros(lat.p99)
|
|
||||||
);
|
|
||||||
json.push(serde_json::json!({
|
|
||||||
"bench": "hnsw", "n": n, "ef": ef, "recall_at_10": recall,
|
|
||||||
"qps": lat.qps, "p50_us": micros(lat.p50), "p99_us": micros(lat.p99),
|
|
||||||
"build_ms": millis(build),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// End to end: HDF5Memory::hybrid_search
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn bench_end_to_end(n: usize, json: &mut Vec<serde_json::Value>) {
|
|
||||||
let data = make_dataset(n, 0xE2E ^ n as u64);
|
|
||||||
let dir = tempfile::TempDir::new().unwrap();
|
|
||||||
let path = dir.path().join("store.h5");
|
|
||||||
let mut rng = Rng(7);
|
|
||||||
|
|
||||||
let entries: Vec<MemoryEntry> = data
|
|
||||||
.vectors
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, v)| MemoryEntry {
|
|
||||||
chunk: text_for(data.cluster_of[i], i, &mut rng),
|
|
||||||
embedding: v.clone(),
|
|
||||||
source_channel: "bench".into(),
|
|
||||||
timestamp: i as f64,
|
|
||||||
session_id: format!("s{}", i % 50),
|
|
||||||
tags: format!("t{i}"),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let query_texts: Vec<String> = data
|
|
||||||
.query_cluster
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, c)| text_for(*c, i, &mut rng))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut mem = HDF5Memory::create(MemoryConfig::new(path.clone(), "bench", DIM)).unwrap();
|
|
||||||
let t = Instant::now();
|
|
||||||
mem.save_batch(entries).unwrap();
|
|
||||||
let ingest = t.elapsed();
|
|
||||||
// The very first query builds the vector and keyword indexes from
|
|
||||||
// scratch. It happens once per store, not once per session: the checkpoint
|
|
||||||
// below saves the vector index, so a later `open()` reloads it.
|
|
||||||
let t = Instant::now();
|
|
||||||
std::hint::black_box(mem.hybrid_search(&data.queries[1], &query_texts[1], 0.7, 0.3, K));
|
|
||||||
let cold_build = t.elapsed();
|
|
||||||
|
|
||||||
let t = Instant::now();
|
|
||||||
mem.flush_wal().unwrap();
|
|
||||||
let checkpoint = t.elapsed();
|
|
||||||
drop(mem);
|
|
||||||
|
|
||||||
let t = Instant::now();
|
|
||||||
let mut mem = HDF5Memory::open(&path).unwrap();
|
|
||||||
let open = t.elapsed();
|
|
||||||
|
|
||||||
// The first query after open pays for whatever is rebuilt lazily.
|
|
||||||
let t = Instant::now();
|
|
||||||
std::hint::black_box(mem.hybrid_search(&data.queries[0], &query_texts[0], 0.7, 0.3, K));
|
|
||||||
let first_query = t.elapsed();
|
|
||||||
|
|
||||||
// Fewer steady-state samples at large N: each query is currently O(N).
|
|
||||||
let samples_wanted = if n >= 100_000 { 20 } else { N_QUERIES.min(100) };
|
|
||||||
let steady = summarize(
|
|
||||||
(0..samples_wanted)
|
|
||||||
.map(|i| {
|
|
||||||
let t = Instant::now();
|
|
||||||
std::hint::black_box(mem.hybrid_search(
|
|
||||||
&data.queries[i % N_QUERIES],
|
|
||||||
&query_texts[i % N_QUERIES],
|
|
||||||
0.7,
|
|
||||||
0.3,
|
|
||||||
K,
|
|
||||||
));
|
|
||||||
t.elapsed()
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"| {n} | {:.0} | {:.0} | {:.1} | {:.1} | {:.1} | {:.2} | {:.2} | {:.1} |",
|
|
||||||
millis(ingest),
|
|
||||||
millis(cold_build),
|
|
||||||
millis(checkpoint),
|
|
||||||
millis(open),
|
|
||||||
millis(first_query),
|
|
||||||
millis(steady.p50),
|
|
||||||
millis(steady.p99),
|
|
||||||
steady.qps
|
|
||||||
);
|
|
||||||
json.push(serde_json::json!({
|
|
||||||
"bench": "hybrid_search", "n": n,
|
|
||||||
"ingest_ms": millis(ingest), "cold_index_build_ms": millis(cold_build),
|
|
||||||
"checkpoint_ms": millis(checkpoint),
|
|
||||||
"open_ms": millis(open), "first_query_ms": millis(first_query),
|
|
||||||
"p50_ms": millis(steady.p50), "p99_ms": millis(steady.p99), "qps": steady.qps,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Fusion study: does capping the keyword candidate pool change the ranking?
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `hybrid_search` min-max normalises each signal over the candidates it is
|
|
||||||
/// given. The vector stage supplies a pool of `max(8k, 64)`; the keyword stage
|
|
||||||
/// supplies *every* matching record, which is what now dominates query time.
|
|
||||||
/// This compares the current fusion with one whose keyword stage is capped to
|
|
||||||
/// a pool, reporting how often the final top-k agree and what each costs.
|
|
||||||
fn fusion_study(n: usize) {
|
|
||||||
use clawhdf5_agent::bm25::BM25Index;
|
|
||||||
use clawhdf5_agent::hybrid::merge_vector_keyword;
|
|
||||||
|
|
||||||
let data = make_dataset(n, 0xE2E ^ n as u64);
|
|
||||||
let mut rng = Rng(7);
|
|
||||||
let texts: Vec<String> = (0..n)
|
|
||||||
.map(|i| text_for(data.cluster_of[i], i, &mut rng))
|
|
||||||
.collect();
|
|
||||||
let query_texts: Vec<String> = data
|
|
||||||
.query_cluster
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, c)| text_for(*c, i, &mut rng))
|
|
||||||
.collect();
|
|
||||||
let bm25 = BM25Index::build(&texts, &vec![0u8; n]);
|
|
||||||
let index = HnswIndex::build_with(
|
|
||||||
&data.vectors,
|
|
||||||
HNSW_M,
|
|
||||||
HNSW_EF_CONSTRUCTION,
|
|
||||||
DistanceMetric::Cosine,
|
|
||||||
storage(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let vec_pool = (K * 8).max(64);
|
|
||||||
println!("\n### Fusion study, N = {n} (k = {K}, weights 0.7 / 0.3, vector pool {vec_pool})\n");
|
|
||||||
println!(
|
|
||||||
"| keyword pool | top-{K} overlap vs full | identical top-{K} | same #1 | keyword+merge µs |"
|
|
||||||
);
|
|
||||||
println!("|---:|---:|---:|---:|---:|");
|
|
||||||
|
|
||||||
let fuse = |q: usize, kw_pool: usize| -> (Vec<usize>, Duration) {
|
|
||||||
let vec_scores: Vec<(usize, f32)> = index
|
|
||||||
.search(&data.queries[q], vec_pool, vec_pool)
|
|
||||||
.into_iter()
|
|
||||||
.map(|(id, d)| (id, 1.0 - d))
|
|
||||||
.collect();
|
|
||||||
let t = Instant::now();
|
|
||||||
let kw = bm25.search(&query_texts[q], kw_pool);
|
|
||||||
let merged = merge_vector_keyword(vec_scores, kw, 0.7, 0.3, K);
|
|
||||||
let took = t.elapsed();
|
|
||||||
(merged.into_iter().map(|(id, _)| id).collect(), took)
|
|
||||||
};
|
|
||||||
|
|
||||||
let full: Vec<(Vec<usize>, Duration)> = (0..N_QUERIES).map(|q| fuse(q, n)).collect();
|
|
||||||
let full_time: Duration = full.iter().map(|f| f.1).sum();
|
|
||||||
println!(
|
|
||||||
"| all ({n}) | 1.0000 | 100.0% | 100.0% | {:.0} |",
|
|
||||||
micros(full_time) / N_QUERIES as f64
|
|
||||||
);
|
|
||||||
for pool in [vec_pool, vec_pool * 4, 1000] {
|
|
||||||
if pool >= n {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (mut overlap, mut identical, mut same_first) = (0usize, 0usize, 0usize);
|
|
||||||
let mut time = Duration::ZERO;
|
|
||||||
for (q, (want, _)) in full.iter().enumerate() {
|
|
||||||
let (got, took) = fuse(q, pool);
|
|
||||||
time += took;
|
|
||||||
overlap += got.iter().filter(|id| want.contains(id)).count();
|
|
||||||
identical += usize::from(&got == want);
|
|
||||||
same_first += usize::from(got.first() == want.first());
|
|
||||||
}
|
|
||||||
println!(
|
|
||||||
"| {pool} | {:.4} | {:.1}% | {:.1}% | {:.0} |",
|
|
||||||
overlap as f64 / (K * N_QUERIES) as f64,
|
|
||||||
100.0 * identical as f64 / N_QUERIES as f64,
|
|
||||||
100.0 * same_first as f64 / N_QUERIES as f64,
|
|
||||||
micros(time) / N_QUERIES as f64
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What an in-memory store costs, stage by stage. The vectors are the floor:
|
|
||||||
/// everything above it is bookkeeping that could in principle be shared.
|
|
||||||
fn bench_footprint(n: usize) {
|
|
||||||
let data = make_dataset(n, 0xF007 ^ n as u64);
|
|
||||||
let mut rng = Rng(11);
|
|
||||||
let dir = tempfile::TempDir::new().unwrap();
|
|
||||||
let path = dir.path().join("footprint.h5");
|
|
||||||
|
|
||||||
let base = heap_bytes();
|
|
||||||
let entries: Vec<MemoryEntry> = data
|
|
||||||
.vectors
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, v)| MemoryEntry {
|
|
||||||
chunk: text_for(data.cluster_of[i], i, &mut rng),
|
|
||||||
embedding: v.clone(),
|
|
||||||
source_channel: "bench".into(),
|
|
||||||
timestamp: i as f64,
|
|
||||||
session_id: format!("s{}", i % 50),
|
|
||||||
tags: format!("t{i}"),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let after_entries = heap_bytes();
|
|
||||||
|
|
||||||
let mut config = MemoryConfig::new(path, "bench", DIM);
|
|
||||||
config.quantized_index = INT8.load(std::sync::atomic::Ordering::Relaxed);
|
|
||||||
let mut mem = HDF5Memory::create(config).unwrap();
|
|
||||||
mem.save_batch(entries).unwrap();
|
|
||||||
let after_store = heap_bytes();
|
|
||||||
|
|
||||||
// First query builds the vector and keyword indexes.
|
|
||||||
std::hint::black_box(mem.hybrid_search(&data.queries[0], "record", 0.7, 0.3, K));
|
|
||||||
let after_indexes = heap_bytes();
|
|
||||||
|
|
||||||
// Reopening is the figure that matters for a long-lived process, and the
|
|
||||||
// only one RSS reports honestly: memory freed when the ingest buffers went
|
|
||||||
// away stays in the allocator's pool, so the stage deltas above understate
|
|
||||||
// what was given back.
|
|
||||||
let path = mem.config().path.clone();
|
|
||||||
drop(mem);
|
|
||||||
let before_open = heap_bytes();
|
|
||||||
let reopened = HDF5Memory::open(&path).unwrap();
|
|
||||||
let after_open = heap_bytes();
|
|
||||||
let loaded = after_open.saturating_sub(before_open);
|
|
||||||
drop(reopened);
|
|
||||||
|
|
||||||
let raw = (n * DIM * 4) as u64;
|
|
||||||
println!(
|
|
||||||
"| {n} | {:.0} | {:.0} | {:.0} | {:.0} | {:.0} | {:.2}x |",
|
|
||||||
mib(raw),
|
|
||||||
mib(after_entries.saturating_sub(base)),
|
|
||||||
mib(after_store.saturating_sub(after_entries)),
|
|
||||||
mib(after_indexes.saturating_sub(after_store)),
|
|
||||||
mib(loaded),
|
|
||||||
loaded as f64 / raw as f64,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
||||||
let full = args.iter().any(|a| a == "--full");
|
|
||||||
let ann_only = args.iter().any(|a| a == "--ann-only");
|
|
||||||
if args.iter().any(|a| a == "--fusion-study") {
|
|
||||||
for &n in if full {
|
|
||||||
&[10_000, 100_000][..]
|
|
||||||
} else {
|
|
||||||
&[10_000][..]
|
|
||||||
} {
|
|
||||||
fusion_study(n);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if args.iter().any(|a| a == "--int8") {
|
|
||||||
INT8.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
println!("(int8-quantised index vectors)");
|
|
||||||
}
|
|
||||||
if args.iter().any(|a| a == "--rerank") {
|
|
||||||
RERANK.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
println!("(candidates re-scored against exact vectors)");
|
|
||||||
}
|
|
||||||
if args.iter().any(|a| a == "--uniform") {
|
|
||||||
UNIFORM.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
println!("(uniform random data)");
|
|
||||||
}
|
|
||||||
let json_path = args
|
|
||||||
.iter()
|
|
||||||
.position(|a| a == "--json")
|
|
||||||
.and_then(|i| args.get(i + 1))
|
|
||||||
.cloned();
|
|
||||||
let sizes: &[usize] = if full {
|
|
||||||
&[1_000, 10_000, 100_000]
|
|
||||||
} else {
|
|
||||||
&[1_000, 10_000]
|
|
||||||
};
|
|
||||||
|
|
||||||
if cfg!(debug_assertions) {
|
|
||||||
eprintln!("warning: debug build — numbers are meaningless. Use --release.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut json = Vec::new();
|
|
||||||
println!("## Search harness");
|
|
||||||
|
|
||||||
if args.iter().any(|a| a == "--footprint") {
|
|
||||||
println!("\n### Resident memory, {DIM}-dim f32\n");
|
|
||||||
println!(
|
|
||||||
"| N | vectors (raw) | entries MiB | store MiB | indexes MiB | reopened MiB | reopened / raw |"
|
|
||||||
);
|
|
||||||
println!("|---:|---:|---:|---:|---:|---:|---:|");
|
|
||||||
for &n in sizes {
|
|
||||||
bench_footprint(n);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// `--e2e-only` skips the index benchmarks, so the end-to-end section runs
|
|
||||||
// in a process that has not already spun up a thread pool.
|
|
||||||
if !args.iter().any(|a| a == "--e2e-only") {
|
|
||||||
for &n in sizes {
|
|
||||||
bench_ann(n, &mut json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ann_only {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
println!("\n### End to end: `HDF5Memory::hybrid_search` (k = {K}, weights 0.7 / 0.3)\n");
|
|
||||||
println!(
|
|
||||||
"| N | ingest ms | cold index build ms | checkpoint ms | open ms | first query after open ms | p50 ms | p99 ms | QPS |"
|
|
||||||
);
|
|
||||||
println!("|---:|---:|---:|---:|---:|---:|---:|---:|---:|");
|
|
||||||
for &n in sizes {
|
|
||||||
bench_end_to_end(n, &mut json);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(path) = json_path {
|
|
||||||
std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
|
|
||||||
eprintln!("wrote {path}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-cli"
|
name = "clawhdf5-cli"
|
||||||
version = "2.6.0"
|
version = "2.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "CLI for clawhdf5 agent memory — create, save, search, recall, stats"
|
description = "CLI for clawhdf5 agent memory — create, save, search, recall, stats"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||||
keywords = ["hdf5", "ai", "memory", "agent", "cli"]
|
keywords = ["hdf5", "ai", "memory", "agent", "cli"]
|
||||||
categories = ["command-line-utilities", "science"]
|
categories = ["command-line-utilities", "science"]
|
||||||
readme = "../../README.md"
|
readme = "../../README.md"
|
||||||
@@ -14,7 +14,7 @@ name = "clawhdf5"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.6.0" }
|
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.1.0" }
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ enum Commands {
|
|||||||
/// Enable write-ahead log
|
/// Enable write-ahead log
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
wal: bool,
|
wal: bool,
|
||||||
/// Store the vector index's copy of the embeddings as int8, roughly
|
|
||||||
/// halving a loaded store's memory at about 13% fewer queries/second
|
|
||||||
#[arg(long)]
|
|
||||||
quantized_index: bool,
|
|
||||||
},
|
},
|
||||||
/// Save a memory entry (reads JSON from stdin or --json)
|
/// Save a memory entry (reads JSON from stdin or --json)
|
||||||
Save {
|
Save {
|
||||||
@@ -92,15 +88,9 @@ fn main() {
|
|||||||
|
|
||||||
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Create {
|
Commands::Create { agent_id, dim, wal } => {
|
||||||
agent_id,
|
|
||||||
dim,
|
|
||||||
wal,
|
|
||||||
quantized_index,
|
|
||||||
} => {
|
|
||||||
let mut config = MemoryConfig::new(cli.path.clone(), &agent_id, dim);
|
let mut config = MemoryConfig::new(cli.path.clone(), &agent_id, dim);
|
||||||
config.wal_enabled = wal;
|
config.wal_enabled = wal;
|
||||||
config.quantized_index = quantized_index;
|
|
||||||
let mem = HDF5Memory::create(config)?;
|
let mem = HDF5Memory::create(config)?;
|
||||||
let j = serde_json::json!({
|
let j = serde_json::json!({
|
||||||
"status": "created",
|
"status": "created",
|
||||||
@@ -108,7 +98,6 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"embedding_dim": dim,
|
"embedding_dim": dim,
|
||||||
"wal_enabled": wal,
|
"wal_enabled": wal,
|
||||||
"quantized_index": quantized_index,
|
|
||||||
"count": mem.count(),
|
"count": mem.count(),
|
||||||
});
|
});
|
||||||
println!("{}", serde_json::to_string_pretty(&j)?);
|
println!("{}", serde_json::to_string_pretty(&j)?);
|
||||||
@@ -157,7 +146,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Commands::Recall { index } => {
|
Commands::Recall { index } => {
|
||||||
let mem = HDF5Memory::open_read_only(&cli.path)?;
|
let mem = HDF5Memory::open(&cli.path)?;
|
||||||
match mem.get_chunk(index) {
|
match mem.get_chunk(index) {
|
||||||
Some(content) => {
|
Some(content) => {
|
||||||
let j = serde_json::json!({ "index": index, "chunk": content });
|
let j = serde_json::json!({ "index": index, "chunk": content });
|
||||||
@@ -171,7 +160,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Commands::Stats => {
|
Commands::Stats => {
|
||||||
let mem = HDF5Memory::open_read_only(&cli.path)?;
|
let mem = HDF5Memory::open(&cli.path)?;
|
||||||
let cfg = mem.config();
|
let cfg = mem.config();
|
||||||
let j = serde_json::json!({
|
let j = serde_json::json!({
|
||||||
"path": cli.path.display().to_string(),
|
"path": cli.path.display().to_string(),
|
||||||
@@ -198,7 +187,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Commands::AgentsMd { output } => {
|
Commands::AgentsMd { output } => {
|
||||||
let mem = HDF5Memory::open_read_only(&cli.path)?;
|
let mem = HDF5Memory::open(&cli.path)?;
|
||||||
let md = mem.generate_agents_md();
|
let md = mem.generate_agents_md();
|
||||||
match output {
|
match output {
|
||||||
Some(p) => {
|
Some(p) => {
|
||||||
@@ -210,7 +199,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Commands::Export => {
|
Commands::Export => {
|
||||||
let mem = HDF5Memory::open_read_only(&cli.path)?;
|
let mem = HDF5Memory::open(&cli.path)?;
|
||||||
for i in 0..mem.count() {
|
for i in 0..mem.count() {
|
||||||
if let Some(chunk) = mem.get_chunk(i) {
|
if let Some(chunk) = mem.get_chunk(i) {
|
||||||
let j = serde_json::json!({ "index": i, "chunk": chunk });
|
let j = serde_json::json!({ "index": i, "chunk": chunk });
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-derive"
|
name = "clawhdf5-derive"
|
||||||
version = "2.6.0"
|
version = "2.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Derive macros for rustyhdf5 HDF5 traits"
|
description = "Derive macros for rustyhdf5 HDF5 traits"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["hdf5", "derive", "macros", "science"]
|
keywords = ["hdf5", "derive", "macros", "science"]
|
||||||
categories = ["development-tools::procedural-macro-helpers"]
|
categories = ["development-tools::procedural-macro-helpers"]
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-filters"
|
name = "clawhdf5-filters"
|
||||||
version = "2.6.0"
|
version = "2.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Filter and compression pipeline for clawhdf5"
|
description = "Filter and compression pipeline for clawhdf5"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["hdf5", "compression", "deflate", "filters"]
|
keywords = ["hdf5", "compression", "deflate", "filters"]
|
||||||
categories = ["compression", "science"]
|
categories = ["compression", "science"]
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-format"
|
name = "clawhdf5-format"
|
||||||
version = "2.6.0"
|
version = "2.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Pure-Rust HDF5 binary format parsing and writing — no C dependencies"
|
description = "Pure-Rust HDF5 binary format parsing and writing — no C dependencies"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["hdf5", "science", "data", "binary", "no-std"]
|
keywords = ["hdf5", "science", "data", "binary", "no-std"]
|
||||||
categories = ["parser-implementations", "science", "encoding", "no-std"]
|
categories = ["parser-implementations", "science", "encoding", "no-std"]
|
||||||
@@ -25,7 +25,7 @@ pco = { version = "1.0", optional = true }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
criterion = { workspace = true }
|
criterion = { workspace = true }
|
||||||
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.6.0" }
|
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.1.0" }
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "bench"
|
name = "bench"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,9 +1,7 @@
|
|||||||
//! HDF5 Attribute message parsing (message type 0x000C).
|
//! HDF5 Attribute message parsing (message type 0x000C).
|
||||||
|
|
||||||
#[cfg(not(feature = "std"))]
|
#[cfg(not(feature = "std"))]
|
||||||
use alloc::{borrow::Cow, string::String, vec::Vec};
|
use alloc::{string::String, vec::Vec};
|
||||||
#[cfg(feature = "std")]
|
|
||||||
use std::borrow::Cow;
|
|
||||||
|
|
||||||
use crate::attribute_info::AttributeInfoMessage;
|
use crate::attribute_info::AttributeInfoMessage;
|
||||||
use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records};
|
use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records};
|
||||||
@@ -50,64 +48,17 @@ impl AttributeMessage {
|
|||||||
///
|
///
|
||||||
/// `length_size` is needed for dataspace dimension parsing.
|
/// `length_size` is needed for dataspace dimension parsing.
|
||||||
pub fn parse(data: &[u8], length_size: u8) -> Result<AttributeMessage, FormatError> {
|
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)?;
|
ensure_len(data, 0, 2)?;
|
||||||
let version = data[0];
|
let version = data[0];
|
||||||
|
|
||||||
match version {
|
match version {
|
||||||
1 => Self::parse_v1(data, length_size),
|
1 => Self::parse_v1(data, length_size),
|
||||||
2 => Self::parse_v2(data, length_size, file),
|
2 => Self::parse_v2(data, length_size),
|
||||||
3 => Self::parse_v3(data, length_size, file),
|
3 => Self::parse_v3(data, length_size),
|
||||||
_ => Err(FormatError::InvalidAttributeVersion(version)),
|
_ => 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> {
|
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
|
// version(1) + reserved(1) + name_size(2) + datatype_size(2) + dataspace_size(2) = 8
|
||||||
ensure_len(data, 0, 8)?;
|
ensure_len(data, 0, 8)?;
|
||||||
@@ -143,13 +94,7 @@ impl AttributeMessage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_v2(
|
fn parse_v2(data: &[u8], length_size: u8) -> Result<AttributeMessage, FormatError> {
|
||||||
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
|
// version(1) + flags(1) + name_size(2) + datatype_size(2) + dataspace_size(2) = 8
|
||||||
ensure_len(data, 0, 8)?;
|
ensure_len(data, 0, 8)?;
|
||||||
let name_size = u16::from_le_bytes([data[2], data[3]]) as usize;
|
let name_size = u16::from_le_bytes([data[2], data[3]]) as usize;
|
||||||
@@ -165,26 +110,12 @@ impl AttributeMessage {
|
|||||||
|
|
||||||
// Datatype (NO padding)
|
// Datatype (NO padding)
|
||||||
ensure_len(data, pos, datatype_size)?;
|
ensure_len(data, pos, datatype_size)?;
|
||||||
let dt_bytes = Self::embedded_message(
|
let (datatype, _) = Datatype::parse(&data[pos..pos + datatype_size])?;
|
||||||
&data[pos..pos + datatype_size],
|
|
||||||
flags & 0x01 != 0,
|
|
||||||
MessageType::Datatype,
|
|
||||||
length_size,
|
|
||||||
file,
|
|
||||||
)?;
|
|
||||||
let (datatype, _) = Datatype::parse(&dt_bytes)?;
|
|
||||||
pos += datatype_size;
|
pos += datatype_size;
|
||||||
|
|
||||||
// Dataspace (NO padding)
|
// Dataspace (NO padding)
|
||||||
ensure_len(data, pos, dataspace_size)?;
|
ensure_len(data, pos, dataspace_size)?;
|
||||||
let ds_bytes = Self::embedded_message(
|
let dataspace = Dataspace::parse(&data[pos..pos + dataspace_size], length_size)?;
|
||||||
&data[pos..pos + dataspace_size],
|
|
||||||
flags & 0x02 != 0,
|
|
||||||
MessageType::Dataspace,
|
|
||||||
length_size,
|
|
||||||
file,
|
|
||||||
)?;
|
|
||||||
let dataspace = Dataspace::parse(&ds_bytes, length_size)?;
|
|
||||||
pos += dataspace_size;
|
pos += dataspace_size;
|
||||||
|
|
||||||
let raw_data = compute_raw_data(data, pos, &dataspace, &datatype);
|
let raw_data = compute_raw_data(data, pos, &dataspace, &datatype);
|
||||||
@@ -197,13 +128,7 @@ impl AttributeMessage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_v3(
|
fn parse_v3(data: &[u8], length_size: u8) -> Result<AttributeMessage, FormatError> {
|
||||||
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
|
// version(1) + flags(1) + name_size(2) + datatype_size(2) + dataspace_size(2) + encoding(1) = 9
|
||||||
ensure_len(data, 0, 9)?;
|
ensure_len(data, 0, 9)?;
|
||||||
let name_size = u16::from_le_bytes([data[2], data[3]]) as usize;
|
let name_size = u16::from_le_bytes([data[2], data[3]]) as usize;
|
||||||
@@ -220,26 +145,12 @@ impl AttributeMessage {
|
|||||||
|
|
||||||
// Datatype (NO padding)
|
// Datatype (NO padding)
|
||||||
ensure_len(data, pos, datatype_size)?;
|
ensure_len(data, pos, datatype_size)?;
|
||||||
let dt_bytes = Self::embedded_message(
|
let (datatype, _) = Datatype::parse(&data[pos..pos + datatype_size])?;
|
||||||
&data[pos..pos + datatype_size],
|
|
||||||
flags & 0x01 != 0,
|
|
||||||
MessageType::Datatype,
|
|
||||||
length_size,
|
|
||||||
file,
|
|
||||||
)?;
|
|
||||||
let (datatype, _) = Datatype::parse(&dt_bytes)?;
|
|
||||||
pos += datatype_size;
|
pos += datatype_size;
|
||||||
|
|
||||||
// Dataspace (NO padding)
|
// Dataspace (NO padding)
|
||||||
ensure_len(data, pos, dataspace_size)?;
|
ensure_len(data, pos, dataspace_size)?;
|
||||||
let ds_bytes = Self::embedded_message(
|
let dataspace = Dataspace::parse(&data[pos..pos + dataspace_size], length_size)?;
|
||||||
&data[pos..pos + dataspace_size],
|
|
||||||
flags & 0x02 != 0,
|
|
||||||
MessageType::Dataspace,
|
|
||||||
length_size,
|
|
||||||
file,
|
|
||||||
)?;
|
|
||||||
let dataspace = Dataspace::parse(&ds_bytes, length_size)?;
|
|
||||||
pos += dataspace_size;
|
pos += dataspace_size;
|
||||||
|
|
||||||
let raw_data = compute_raw_data(data, pos, &dataspace, &datatype);
|
let raw_data = compute_raw_data(data, pos, &dataspace, &datatype);
|
||||||
@@ -415,20 +326,10 @@ pub fn extract_attributes_full(
|
|||||||
offset_size,
|
offset_size,
|
||||||
length_size,
|
length_size,
|
||||||
)?;
|
)?;
|
||||||
let attr = AttributeMessage::parse_in_file(
|
let attr = AttributeMessage::parse(&resolved_data, length_size)?;
|
||||||
&resolved_data,
|
|
||||||
file_data,
|
|
||||||
offset_size,
|
|
||||||
length_size,
|
|
||||||
)?;
|
|
||||||
attrs.push(attr);
|
attrs.push(attr);
|
||||||
} else {
|
} else {
|
||||||
let attr = AttributeMessage::parse_in_file(
|
let attr = AttributeMessage::parse(&msg.data, length_size)?;
|
||||||
&msg.data,
|
|
||||||
file_data,
|
|
||||||
offset_size,
|
|
||||||
length_size,
|
|
||||||
)?;
|
|
||||||
attrs.push(attr);
|
attrs.push(attr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,8 +399,7 @@ fn extract_dense_attributes(
|
|||||||
let attr_data = fh.read_managed_object(file_data, id_bytes, offset_size)?;
|
let attr_data = fh.read_managed_object(file_data, id_bytes, offset_size)?;
|
||||||
|
|
||||||
// The data in the heap is a complete attribute message
|
// The data in the heap is a complete attribute message
|
||||||
let attr =
|
let attr = AttributeMessage::parse(&attr_data, length_size)?;
|
||||||
AttributeMessage::parse_in_file(&attr_data, file_data, offset_size, length_size)?;
|
|
||||||
attrs.push(attr);
|
attrs.push(attr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,13 +472,14 @@ mod tests {
|
|||||||
|
|
||||||
// Name padded to 8 bytes
|
// Name padded to 8 bytes
|
||||||
data.extend_from_slice(name);
|
data.extend_from_slice(name);
|
||||||
if data.len() % 8 != 0 || data.len() == 8 {
|
while data.len() % 8 != 0 || data.len() == 8 {
|
||||||
// Pad name to 8-byte boundary from start of name
|
// Pad name to 8-byte boundary from start of name
|
||||||
let name_start = 8;
|
let name_start = 8;
|
||||||
let name_padded = pad8(name_size);
|
let name_padded = pad8(name_size);
|
||||||
while data.len() < name_start + name_padded {
|
while data.len() < name_start + name_padded {
|
||||||
data.push(0);
|
data.push(0);
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Datatype padded to 8 bytes
|
// Datatype padded to 8 bytes
|
||||||
@@ -848,11 +749,11 @@ mod tests {
|
|||||||
data.extend_from_slice(name);
|
data.extend_from_slice(name);
|
||||||
data.extend_from_slice(&dt_bytes);
|
data.extend_from_slice(&dt_bytes);
|
||||||
data.extend_from_slice(&ds_bytes);
|
data.extend_from_slice(&ds_bytes);
|
||||||
data.extend_from_slice(&3.25f64.to_le_bytes());
|
data.extend_from_slice(&3.14f64.to_le_bytes());
|
||||||
|
|
||||||
let attr = AttributeMessage::parse(&data, 8).unwrap();
|
let attr = AttributeMessage::parse(&data, 8).unwrap();
|
||||||
let vals = attr.read_as_f64().unwrap();
|
let vals = attr.read_as_f64().unwrap();
|
||||||
assert_eq!(vals, vec![3.25]);
|
assert_eq!(vals, vec![3.14]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -416,7 +416,6 @@ fn header_max_total_records(max_leaf_nrec: u64, depth: u16) -> u64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn build_btree_v2_header(
|
fn build_btree_v2_header(
|
||||||
tree_type: u8,
|
tree_type: u8,
|
||||||
node_size: u32,
|
node_size: u32,
|
||||||
|
|||||||
@@ -374,11 +374,6 @@ impl ChunkCache {
|
|||||||
|
|
||||||
// ----- Index operations -----
|
// ----- Index operations -----
|
||||||
|
|
||||||
/// The most decompressed bytes this cache will hold.
|
|
||||||
pub fn max_bytes(&self) -> usize {
|
|
||||||
self.inner.lock().map(|g| g.max_bytes).unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bind the cache to the dataset at chunk-index address `addr`.
|
/// Bind the cache to the dataset at chunk-index address `addr`.
|
||||||
///
|
///
|
||||||
/// The cache is shared per file across all of its datasets. If the cache
|
/// The cache is shared per file across all of its datasets. If the cache
|
||||||
|
|||||||
@@ -132,64 +132,6 @@ fn ensure_len(data: &[u8], offset: usize, needed: usize) -> Result<(), FormatErr
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `elements * elem_size` for sizes that come from the file. Dataspace and
|
|
||||||
/// chunk dimensions are untrusted 64-bit fields, so a crafted file can make
|
|
||||||
/// the plain product wrap to a small number (or to something enormous).
|
|
||||||
pub(crate) fn checked_byte_len(elements: u64, elem_size: usize) -> Result<usize, FormatError> {
|
|
||||||
usize::try_from(elements)
|
|
||||||
.ok()
|
|
||||||
.and_then(|n| n.checked_mul(elem_size))
|
|
||||||
.ok_or_else(|| {
|
|
||||||
FormatError::Overflow(format!(
|
|
||||||
"{elements} elements of {elem_size} bytes exceeds the addressable size"
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Product of chunk dimensions times the element size, overflow-checked.
|
|
||||||
pub(crate) fn checked_chunk_byte_len(
|
|
||||||
chunk_dims: &[usize],
|
|
||||||
elem_size: usize,
|
|
||||||
) -> Result<usize, FormatError> {
|
|
||||||
chunk_dims
|
|
||||||
.iter()
|
|
||||||
.try_fold(elem_size, |acc, &d| acc.checked_mul(d))
|
|
||||||
.ok_or_else(|| {
|
|
||||||
FormatError::Overflow(format!(
|
|
||||||
"chunk dimensions {chunk_dims:?} x {elem_size} bytes exceeds the addressable size"
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A zero-filled output buffer of `len` bytes. `vec![0; len]` aborts the
|
|
||||||
/// process when the allocation fails; a size taken from the file must surface
|
|
||||||
/// as an error instead.
|
|
||||||
pub(crate) fn alloc_output(len: usize) -> Result<Vec<u8>, FormatError> {
|
|
||||||
if len == 0 {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let failed =
|
|
||||||
|| FormatError::Overflow(format!("cannot allocate {len} bytes for dataset output"));
|
|
||||||
let layout = core::alloc::Layout::array::<u8>(len).map_err(|_| failed())?;
|
|
||||||
// Ask the allocator for zeroed memory instead of reserving and then
|
|
||||||
// writing zeros: for a large buffer the OS hands out already-zero pages
|
|
||||||
// lazily, where an explicit fill touches every page up front — and most of
|
|
||||||
// the buffer is about to be overwritten with chunk data anyway.
|
|
||||||
//
|
|
||||||
// SAFETY (both arms): `layout` has non-zero size (len > 0) and alignment 1.
|
|
||||||
#[cfg(feature = "std")]
|
|
||||||
let ptr = unsafe { std::alloc::alloc_zeroed(layout) };
|
|
||||||
#[cfg(not(feature = "std"))]
|
|
||||||
let ptr = unsafe { alloc::alloc::alloc_zeroed(layout) };
|
|
||||||
if ptr.is_null() {
|
|
||||||
return Err(failed());
|
|
||||||
}
|
|
||||||
// SAFETY: `ptr` came from the global allocator with the layout of
|
|
||||||
// `[u8; len]`, which is exactly what `Vec<u8>` with capacity `len` frees;
|
|
||||||
// all `len` bytes are initialised (zero).
|
|
||||||
Ok(unsafe { Vec::from_raw_parts(ptr, len, len) })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_offset(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
|
fn read_offset(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
|
||||||
let s = size as usize;
|
let s = size as usize;
|
||||||
if pos.checked_add(s).is_none_or(|end| end > data.len()) {
|
if pos.checked_add(s).is_none_or(|end| end > data.len()) {
|
||||||
@@ -379,127 +321,15 @@ pub fn generate_implicit_chunks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read a chunked dataset, decompressing chunks as needed.
|
/// Read a chunked dataset, decompressing chunks as needed.
|
||||||
/// Chunks decompressed together before being copied out, bounding the extra
|
pub fn read_chunked_data(
|
||||||
/// memory a parallel full read holds at once.
|
|
||||||
const DECODE_BATCH: usize = 128;
|
|
||||||
|
|
||||||
/// B-tree v2 record types used for chunk indexing.
|
|
||||||
const BT2_CHUNK_UNFILTERED: u8 = 10;
|
|
||||||
const BT2_CHUNK_FILTERED: u8 = 11;
|
|
||||||
|
|
||||||
/// Chunks indexed by a version-2 B-tree (layout v4, index type 5).
|
|
||||||
///
|
|
||||||
/// Record layouts (all little endian):
|
|
||||||
/// * type 10, unfiltered: address, then one 8-byte *scaled* offset per
|
|
||||||
/// dimension (offset / chunk dimension);
|
|
||||||
/// * type 11, filtered: address, stored chunk size (a variable number of
|
|
||||||
/// bytes), 4-byte filter mask, then the scaled offsets.
|
|
||||||
///
|
|
||||||
/// The width of the stored-size field depends on the largest possible chunk;
|
|
||||||
/// rather than re-derive the library's formula it is taken from the record
|
|
||||||
/// size the tree header declares, which is what actually governs the bytes.
|
|
||||||
fn read_btree_v2_chunks(
|
|
||||||
file_data: &[u8],
|
|
||||||
addr: u64,
|
|
||||||
chunk_dims: &[usize],
|
|
||||||
elem_size: usize,
|
|
||||||
offset_size: u8,
|
|
||||||
length_size: u8,
|
|
||||||
) -> Result<Vec<ChunkInfo>, FormatError> {
|
|
||||||
use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records};
|
|
||||||
|
|
||||||
let bad = |what: &str| FormatError::ChunkedReadError(format!("B-tree v2 chunk index: {what}"));
|
|
||||||
let header = BTreeV2Header::parse(file_data, addr as usize, offset_size, length_size)?;
|
|
||||||
let rank = chunk_dims.len();
|
|
||||||
let os = offset_size as usize;
|
|
||||||
let record_size = header.record_size as usize;
|
|
||||||
let size_len = match header.tree_type {
|
|
||||||
BT2_CHUNK_UNFILTERED => {
|
|
||||||
if record_size != os + 8 * rank {
|
|
||||||
return Err(bad("unexpected record size for unfiltered chunks"));
|
|
||||||
}
|
|
||||||
0
|
|
||||||
}
|
|
||||||
BT2_CHUNK_FILTERED => {
|
|
||||||
let fixed = os + 4 + 8 * rank;
|
|
||||||
let size_len = record_size
|
|
||||||
.checked_sub(fixed)
|
|
||||||
.ok_or_else(|| bad("record too small"))?;
|
|
||||||
if !(1..=8).contains(&size_len) {
|
|
||||||
return Err(bad("implausible chunk-size field width"));
|
|
||||||
}
|
|
||||||
size_len
|
|
||||||
}
|
|
||||||
_ => return Err(bad("tree is not a chunk index")),
|
|
||||||
};
|
|
||||||
let unfiltered_bytes = checked_chunk_byte_len(chunk_dims, elem_size)?;
|
|
||||||
let unfiltered_bytes =
|
|
||||||
u32::try_from(unfiltered_bytes).map_err(|_| bad("chunk larger than 4 GiB"))?;
|
|
||||||
|
|
||||||
let records = collect_btree_v2_records(file_data, &header, offset_size, length_size)?;
|
|
||||||
let mut chunks = Vec::with_capacity(records.len());
|
|
||||||
for record in &records {
|
|
||||||
let data = record.data.as_slice();
|
|
||||||
if data.len() < record_size {
|
|
||||||
return Err(bad("truncated record"));
|
|
||||||
}
|
|
||||||
let address = read_offset(data, 0, offset_size)?;
|
|
||||||
let mut pos = os;
|
|
||||||
let (chunk_size, filter_mask) = if size_len == 0 {
|
|
||||||
(unfiltered_bytes, 0)
|
|
||||||
} else {
|
|
||||||
let mut size = 0u64;
|
|
||||||
for (i, &b) in data[pos..pos + size_len].iter().enumerate() {
|
|
||||||
size |= u64::from(b) << (8 * i);
|
|
||||||
}
|
|
||||||
pos += size_len;
|
|
||||||
let mask = u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
|
|
||||||
pos += 4;
|
|
||||||
(
|
|
||||||
u32::try_from(size).map_err(|_| bad("stored chunk larger than 4 GiB"))?,
|
|
||||||
mask,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let mut offsets = Vec::with_capacity(rank);
|
|
||||||
for &dim in chunk_dims {
|
|
||||||
let scaled = u64::from_le_bytes([
|
|
||||||
data[pos],
|
|
||||||
data[pos + 1],
|
|
||||||
data[pos + 2],
|
|
||||||
data[pos + 3],
|
|
||||||
data[pos + 4],
|
|
||||||
data[pos + 5],
|
|
||||||
data[pos + 6],
|
|
||||||
data[pos + 7],
|
|
||||||
]);
|
|
||||||
pos += 8;
|
|
||||||
offsets.push(
|
|
||||||
scaled
|
|
||||||
.checked_mul(dim as u64)
|
|
||||||
.ok_or_else(|| bad("chunk offset overflows"))?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
chunks.push(ChunkInfo {
|
|
||||||
chunk_size,
|
|
||||||
filter_mask,
|
|
||||||
offsets,
|
|
||||||
address,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(chunks)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every allocated chunk of a chunked dataset, for any supported chunk index,
|
|
||||||
/// plus the spatial chunk dimensions. Chunks the file never allocated (sparse
|
|
||||||
/// datasets) are simply absent from the list.
|
|
||||||
pub fn list_chunks(
|
|
||||||
file_data: &[u8],
|
file_data: &[u8],
|
||||||
layout: &DataLayout,
|
layout: &DataLayout,
|
||||||
dataspace: &Dataspace,
|
dataspace: &Dataspace,
|
||||||
elem_size: usize,
|
datatype: &Datatype,
|
||||||
|
pipeline: Option<&FilterPipeline>,
|
||||||
offset_size: u8,
|
offset_size: u8,
|
||||||
length_size: u8,
|
length_size: u8,
|
||||||
) -> Result<(Vec<ChunkInfo>, Vec<usize>), FormatError> {
|
) -> Result<Vec<u8>, FormatError> {
|
||||||
let (
|
let (
|
||||||
chunk_dimensions,
|
chunk_dimensions,
|
||||||
version,
|
version,
|
||||||
@@ -533,6 +363,8 @@ pub fn list_chunks(
|
|||||||
let addr = addr_opt
|
let addr = addr_opt
|
||||||
.ok_or_else(|| FormatError::ChunkedReadError("no address for chunked layout".into()))?;
|
.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)
|
// Both v3 and v4 include element size as last dim (rank+1)
|
||||||
let ndims = chunk_dimensions.len();
|
let ndims = chunk_dimensions.len();
|
||||||
let rank = ndims
|
let rank = ndims
|
||||||
@@ -561,7 +393,7 @@ pub fn list_chunks(
|
|||||||
}
|
}
|
||||||
(4, Some(1)) => {
|
(4, Some(1)) => {
|
||||||
// Single chunk — one chunk covering the entire dataset
|
// Single chunk — one chunk covering the entire dataset
|
||||||
let chunk_byte_size = checked_chunk_byte_len(&chunk_dims, elem_size)?;
|
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
|
||||||
let (csize, fmask) = if let Some(fs) = single_filtered_size {
|
let (csize, fmask) = if let Some(fs) = single_filtered_size {
|
||||||
(fs as u32, single_filter_mask.unwrap_or(0))
|
(fs as u32, single_filter_mask.unwrap_or(0))
|
||||||
} else {
|
} else {
|
||||||
@@ -614,18 +446,6 @@ pub fn list_chunks(
|
|||||||
length_size,
|
length_size,
|
||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
(4, Some(5)) => {
|
|
||||||
// Version-2 B-tree: what the library uses for a dataset with two
|
|
||||||
// or more unlimited dimensions.
|
|
||||||
read_btree_v2_chunks(
|
|
||||||
file_data,
|
|
||||||
addr,
|
|
||||||
&chunk_dims,
|
|
||||||
elem_size,
|
|
||||||
offset_size,
|
|
||||||
length_size,
|
|
||||||
)?
|
|
||||||
}
|
|
||||||
(v, idx) => {
|
(v, idx) => {
|
||||||
return Err(FormatError::ChunkedReadError(format!(
|
return Err(FormatError::ChunkedReadError(format!(
|
||||||
"unsupported chunked layout version={v}, index_type={idx:?}"
|
"unsupported chunked layout version={v}, index_type={idx:?}"
|
||||||
@@ -633,38 +453,10 @@ pub fn list_chunks(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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
|
// Assemble output
|
||||||
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
|
let total_elements = dataspace.num_elements() as usize;
|
||||||
if total_bytes == 0 {
|
let total_bytes = total_elements * elem_size;
|
||||||
// Also keeps the stride products below in range: with a zero-sized
|
let mut output = vec![0u8; total_bytes];
|
||||||
// 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];
|
let mut ds_strides = vec![1usize; rank];
|
||||||
for i in (0..rank.saturating_sub(1)).rev() {
|
for i in (0..rank.saturating_sub(1)).rev() {
|
||||||
@@ -676,7 +468,8 @@ pub fn read_chunked_data(
|
|||||||
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
|
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
let chunk_total_bytes = checked_chunk_byte_len(&chunk_dims, elem_size)?;
|
let chunk_total_elements: usize = chunk_dims.iter().product();
|
||||||
|
let chunk_total_bytes = chunk_total_elements * elem_size;
|
||||||
|
|
||||||
// Fast path: no filters — copy directly from file_data without intermediate alloc
|
// Fast path: no filters — copy directly from file_data without intermediate alloc
|
||||||
if pipeline.is_none() {
|
if pipeline.is_none() {
|
||||||
@@ -768,12 +561,29 @@ pub fn read_chunked_data_cached(
|
|||||||
length_size: u8,
|
length_size: u8,
|
||||||
cache: &ChunkCache,
|
cache: &ChunkCache,
|
||||||
) -> Result<Vec<u8>, FormatError> {
|
) -> Result<Vec<u8>, FormatError> {
|
||||||
let (chunk_dimensions, addr_opt) = match layout {
|
let (
|
||||||
|
chunk_dimensions,
|
||||||
|
version,
|
||||||
|
chunk_index_type,
|
||||||
|
addr_opt,
|
||||||
|
single_filtered_size,
|
||||||
|
single_filter_mask,
|
||||||
|
) = match layout {
|
||||||
DataLayout::Chunked {
|
DataLayout::Chunked {
|
||||||
chunk_dimensions,
|
chunk_dimensions,
|
||||||
btree_address,
|
btree_address,
|
||||||
..
|
version,
|
||||||
} => (chunk_dimensions, *btree_address),
|
chunk_index_type,
|
||||||
|
single_chunk_filtered_size,
|
||||||
|
single_chunk_filter_mask,
|
||||||
|
} => (
|
||||||
|
chunk_dimensions,
|
||||||
|
*version,
|
||||||
|
*chunk_index_type,
|
||||||
|
*btree_address,
|
||||||
|
*single_chunk_filtered_size,
|
||||||
|
*single_chunk_filter_mask,
|
||||||
|
),
|
||||||
_ => {
|
_ => {
|
||||||
return Err(FormatError::ChunkedReadError(
|
return Err(FormatError::ChunkedReadError(
|
||||||
"expected chunked layout".into(),
|
"expected chunked layout".into(),
|
||||||
@@ -810,27 +620,78 @@ pub fn read_chunked_data_cached(
|
|||||||
|
|
||||||
// Populate chunk index on first access
|
// Populate chunk index on first access
|
||||||
if !cache.has_index() {
|
if !cache.has_index() {
|
||||||
let (chunks, _) = list_chunks(
|
let chunks = match (version, chunk_index_type) {
|
||||||
|
(3, _) => collect_chunk_info(file_data, addr, ndims, offset_size, length_size)?,
|
||||||
|
(4, Some(1)) => {
|
||||||
|
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
|
||||||
|
let (csize, fmask) = if let Some(fs) = single_filtered_size {
|
||||||
|
(fs as u32, single_filter_mask.unwrap_or(0))
|
||||||
|
} else {
|
||||||
|
(chunk_byte_size as u32, 0)
|
||||||
|
};
|
||||||
|
vec![ChunkInfo {
|
||||||
|
chunk_size: csize,
|
||||||
|
filter_mask: fmask,
|
||||||
|
offsets: vec![0u64; rank],
|
||||||
|
address: addr,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
(4, Some(2)) => {
|
||||||
|
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||||
|
generate_implicit_chunks(
|
||||||
|
addr,
|
||||||
|
&dataspace.dimensions,
|
||||||
|
spatial_chunk_dims,
|
||||||
|
elem_size as u32,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(4, Some(3)) => {
|
||||||
|
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||||
|
let header =
|
||||||
|
FixedArrayHeader::parse(file_data, addr as usize, offset_size, length_size)?;
|
||||||
|
read_fixed_array_chunks(
|
||||||
file_data,
|
file_data,
|
||||||
layout,
|
&header,
|
||||||
dataspace,
|
&dataspace.dimensions,
|
||||||
elem_size,
|
spatial_chunk_dims,
|
||||||
|
elem_size as u32,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
(4, Some(4)) => {
|
||||||
|
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||||
|
let header = ExtensibleArrayHeader::parse(
|
||||||
|
file_data,
|
||||||
|
addr as usize,
|
||||||
offset_size,
|
offset_size,
|
||||||
length_size,
|
length_size,
|
||||||
)?;
|
)?;
|
||||||
|
read_extensible_array_chunks(
|
||||||
|
file_data,
|
||||||
|
&header,
|
||||||
|
&dataspace.dimensions,
|
||||||
|
spatial_chunk_dims,
|
||||||
|
elem_size as u32,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
(v, idx) => {
|
||||||
|
return Err(FormatError::ChunkedReadError(format!(
|
||||||
|
"unsupported chunked layout version={v}, index_type={idx:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
cache.populate_index(&chunks, rank);
|
cache.populate_index(&chunks, rank);
|
||||||
}
|
}
|
||||||
|
|
||||||
let chunks = cache.all_indexed_chunks().unwrap_or_default();
|
let chunks = cache.all_indexed_chunks().unwrap_or_default();
|
||||||
|
|
||||||
// Assemble output
|
// Assemble output
|
||||||
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
|
let total_elements = dataspace.num_elements() as usize;
|
||||||
if total_bytes == 0 {
|
let total_bytes = total_elements * elem_size;
|
||||||
// Also keeps the stride products below in range: with a zero-sized
|
let mut output = vec![0u8; total_bytes];
|
||||||
// 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];
|
let mut ds_strides = vec![1usize; rank];
|
||||||
for i in (0..rank.saturating_sub(1)).rev() {
|
for i in (0..rank.saturating_sub(1)).rev() {
|
||||||
@@ -842,22 +703,46 @@ pub fn read_chunked_data_cached(
|
|||||||
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
|
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
let chunk_total_bytes = checked_chunk_byte_len(&chunk_dims, elem_size)?;
|
let chunk_total_elements: usize = chunk_dims.iter().product();
|
||||||
|
let chunk_total_bytes = chunk_total_elements * elem_size;
|
||||||
|
|
||||||
let mut place = |data: &[u8], chunk_info: &ChunkInfo| {
|
for chunk_info in &chunks {
|
||||||
if rank == 0 {
|
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
|
||||||
let copy_len = data.len().min(output.len());
|
|
||||||
output[..copy_len].copy_from_slice(&data[..copy_len]);
|
// Try decompressed cache first
|
||||||
return;
|
let decompressed = if let Some(cached) = cache.get_decompressed_aligned(&coord) {
|
||||||
|
cached
|
||||||
|
} else {
|
||||||
|
// Decompress from file
|
||||||
|
let c_addr = chunk_info.address as usize;
|
||||||
|
let size = chunk_info.chunk_size as usize;
|
||||||
|
ensure_len(file_data, c_addr, size)?;
|
||||||
|
let raw_chunk = &file_data[c_addr..c_addr + size];
|
||||||
|
let dec = if let Some(pl) = pipeline {
|
||||||
|
if chunk_info.filter_mask == 0 {
|
||||||
|
decompress_chunk(raw_chunk, pl, chunk_total_bytes, elem_size as u32)?
|
||||||
|
} else {
|
||||||
|
raw_chunk.to_vec()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
raw_chunk.to_vec()
|
||||||
|
};
|
||||||
|
cache.put_decompressed(coord, dec)
|
||||||
|
};
|
||||||
|
|
||||||
let chunk_offsets: Vec<usize> = chunk_info
|
let chunk_offsets: Vec<usize> = chunk_info
|
||||||
.offsets
|
.offsets
|
||||||
.iter()
|
.iter()
|
||||||
.take(rank)
|
.take(rank)
|
||||||
.map(|&o| o as usize)
|
.map(|&o| o as usize)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
if rank == 0 {
|
||||||
|
let copy_len = decompressed.len().min(output.len());
|
||||||
|
output[..copy_len].copy_from_slice(&decompressed[..copy_len]);
|
||||||
|
} else {
|
||||||
copy_chunk_to_output(
|
copy_chunk_to_output(
|
||||||
data,
|
&decompressed,
|
||||||
&mut output,
|
&mut output,
|
||||||
&chunk_offsets,
|
&chunk_offsets,
|
||||||
&chunk_dims,
|
&chunk_dims,
|
||||||
@@ -867,63 +752,6 @@ pub fn read_chunked_data_cached(
|
|||||||
elem_size,
|
elem_size,
|
||||||
rank,
|
rank,
|
||||||
);
|
);
|
||||||
};
|
|
||||||
let raw_bytes = |chunk_info: &ChunkInfo| -> Result<&[u8], FormatError> {
|
|
||||||
let c_addr = chunk_info.address as usize;
|
|
||||||
let size = chunk_info.chunk_size as usize;
|
|
||||||
ensure_len(file_data, c_addr, size)?;
|
|
||||||
Ok(&file_data[c_addr..c_addr + size])
|
|
||||||
};
|
|
||||||
|
|
||||||
// Chunks stored as-is (no pipeline, or the filter mask says this chunk
|
|
||||||
// skipped it) are copied straight from the file bytes: they are already in
|
|
||||||
// memory, so routing them through a Vec and then an aligned cache buffer
|
|
||||||
// was two extra copies of the whole dataset for nothing.
|
|
||||||
let stored_raw = |c: &ChunkInfo| pipeline.is_none() || c.filter_mask != 0;
|
|
||||||
let mut misses: Vec<&ChunkInfo> = Vec::new();
|
|
||||||
for chunk_info in &chunks {
|
|
||||||
if stored_raw(chunk_info) {
|
|
||||||
place(raw_bytes(chunk_info)?, chunk_info);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
|
|
||||||
match cache.get_decompressed_aligned(&coord) {
|
|
||||||
Some(cached) => place(&cached, chunk_info),
|
|
||||||
None => misses.push(chunk_info),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decompress what the cache didn't have, a bounded batch at a time — in
|
|
||||||
// parallel with the `parallel` feature (this path, the one the facade
|
|
||||||
// uses, was sequential; only the uncached reader was parallel). Chunks are
|
|
||||||
// cached only when the whole dataset fits: pushing a larger dataset
|
|
||||||
// through the cache just evicts each chunk moments after inserting it.
|
|
||||||
let cache_them = total_bytes <= cache.max_bytes();
|
|
||||||
if let Some(pl) = pipeline {
|
|
||||||
let decode = |c: &&ChunkInfo| -> Result<Vec<u8>, FormatError> {
|
|
||||||
decompress_chunk(raw_bytes(c)?, pl, chunk_total_bytes, elem_size as u32)
|
|
||||||
};
|
|
||||||
for batch in misses.chunks(DECODE_BATCH) {
|
|
||||||
#[cfg(feature = "parallel")]
|
|
||||||
let decoded: Vec<Result<Vec<u8>, FormatError>> = if batch.len() >= 4 {
|
|
||||||
use rayon::prelude::*;
|
|
||||||
batch.par_iter().map(decode).collect()
|
|
||||||
} else {
|
|
||||||
batch.iter().map(decode).collect()
|
|
||||||
};
|
|
||||||
#[cfg(not(feature = "parallel"))]
|
|
||||||
let decoded: Vec<Result<Vec<u8>, FormatError>> = batch.iter().map(decode).collect();
|
|
||||||
|
|
||||||
for (chunk_info, data) in batch.iter().zip(decoded) {
|
|
||||||
let data = data?;
|
|
||||||
if cache_them {
|
|
||||||
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
|
|
||||||
let cached = cache.put_decompressed(coord, data);
|
|
||||||
place(&cached, chunk_info);
|
|
||||||
} else {
|
|
||||||
place(&data, chunk_info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1086,12 +914,29 @@ pub fn read_chunked_data_sweep(
|
|||||||
cache: &ChunkCache,
|
cache: &ChunkCache,
|
||||||
sweep: &mut SweepContext,
|
sweep: &mut SweepContext,
|
||||||
) -> Result<Vec<u8>, FormatError> {
|
) -> Result<Vec<u8>, FormatError> {
|
||||||
let (chunk_dimensions, addr_opt) = match layout {
|
let (
|
||||||
|
chunk_dimensions,
|
||||||
|
version,
|
||||||
|
chunk_index_type,
|
||||||
|
addr_opt,
|
||||||
|
single_filtered_size,
|
||||||
|
single_filter_mask,
|
||||||
|
) = match layout {
|
||||||
DataLayout::Chunked {
|
DataLayout::Chunked {
|
||||||
chunk_dimensions,
|
chunk_dimensions,
|
||||||
btree_address,
|
btree_address,
|
||||||
..
|
version,
|
||||||
} => (chunk_dimensions, *btree_address),
|
chunk_index_type,
|
||||||
|
single_chunk_filtered_size,
|
||||||
|
single_chunk_filter_mask,
|
||||||
|
} => (
|
||||||
|
chunk_dimensions,
|
||||||
|
*version,
|
||||||
|
*chunk_index_type,
|
||||||
|
*btree_address,
|
||||||
|
*single_chunk_filtered_size,
|
||||||
|
*single_chunk_filter_mask,
|
||||||
|
),
|
||||||
_ => {
|
_ => {
|
||||||
return Err(FormatError::ChunkedReadError(
|
return Err(FormatError::ChunkedReadError(
|
||||||
"expected chunked layout".into(),
|
"expected chunked layout".into(),
|
||||||
@@ -1128,27 +973,78 @@ pub fn read_chunked_data_sweep(
|
|||||||
|
|
||||||
// Populate chunk index on first access
|
// Populate chunk index on first access
|
||||||
if !cache.has_index() {
|
if !cache.has_index() {
|
||||||
let (chunks, _) = list_chunks(
|
let chunks = match (version, chunk_index_type) {
|
||||||
|
(3, _) => collect_chunk_info(file_data, addr, ndims, offset_size, length_size)?,
|
||||||
|
(4, Some(1)) => {
|
||||||
|
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
|
||||||
|
let (csize, fmask) = if let Some(fs) = single_filtered_size {
|
||||||
|
(fs as u32, single_filter_mask.unwrap_or(0))
|
||||||
|
} else {
|
||||||
|
(chunk_byte_size as u32, 0)
|
||||||
|
};
|
||||||
|
vec![ChunkInfo {
|
||||||
|
chunk_size: csize,
|
||||||
|
filter_mask: fmask,
|
||||||
|
offsets: vec![0u64; rank],
|
||||||
|
address: addr,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
(4, Some(2)) => {
|
||||||
|
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||||
|
generate_implicit_chunks(
|
||||||
|
addr,
|
||||||
|
&dataspace.dimensions,
|
||||||
|
spatial_chunk_dims,
|
||||||
|
elem_size as u32,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(4, Some(3)) => {
|
||||||
|
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||||
|
let header =
|
||||||
|
FixedArrayHeader::parse(file_data, addr as usize, offset_size, length_size)?;
|
||||||
|
read_fixed_array_chunks(
|
||||||
file_data,
|
file_data,
|
||||||
layout,
|
&header,
|
||||||
dataspace,
|
&dataspace.dimensions,
|
||||||
elem_size,
|
spatial_chunk_dims,
|
||||||
|
elem_size as u32,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
(4, Some(4)) => {
|
||||||
|
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||||
|
let header = ExtensibleArrayHeader::parse(
|
||||||
|
file_data,
|
||||||
|
addr as usize,
|
||||||
offset_size,
|
offset_size,
|
||||||
length_size,
|
length_size,
|
||||||
)?;
|
)?;
|
||||||
|
read_extensible_array_chunks(
|
||||||
|
file_data,
|
||||||
|
&header,
|
||||||
|
&dataspace.dimensions,
|
||||||
|
spatial_chunk_dims,
|
||||||
|
elem_size as u32,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
(v, idx) => {
|
||||||
|
return Err(FormatError::ChunkedReadError(format!(
|
||||||
|
"unsupported chunked layout version={v}, index_type={idx:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
cache.populate_index(&chunks, rank);
|
cache.populate_index(&chunks, rank);
|
||||||
}
|
}
|
||||||
|
|
||||||
let chunks = cache.all_indexed_chunks().unwrap_or_default();
|
let chunks = cache.all_indexed_chunks().unwrap_or_default();
|
||||||
|
|
||||||
// Assemble output
|
// Assemble output
|
||||||
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
|
let total_elements = dataspace.num_elements() as usize;
|
||||||
if total_bytes == 0 {
|
let total_bytes = total_elements * elem_size;
|
||||||
// Also keeps the stride products below in range: with a zero-sized
|
let mut output = vec![0u8; total_bytes];
|
||||||
// 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];
|
let mut ds_strides = vec![1usize; rank];
|
||||||
for i in (0..rank.saturating_sub(1)).rev() {
|
for i in (0..rank.saturating_sub(1)).rev() {
|
||||||
@@ -1160,7 +1056,8 @@ pub fn read_chunked_data_sweep(
|
|||||||
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
|
chunk_strides[i] = chunk_strides[i + 1] * chunk_dims[i + 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
let chunk_total_bytes = checked_chunk_byte_len(&chunk_dims, elem_size)?;
|
let chunk_total_elements: usize = chunk_dims.iter().product();
|
||||||
|
let chunk_total_bytes = chunk_total_elements * elem_size;
|
||||||
|
|
||||||
for chunk_info in &chunks {
|
for chunk_info in &chunks {
|
||||||
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
|
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
|
||||||
@@ -1240,12 +1137,29 @@ pub fn read_chunked_data_indexed(
|
|||||||
length_size: u8,
|
length_size: u8,
|
||||||
cache: &ChunkCache,
|
cache: &ChunkCache,
|
||||||
) -> Result<Vec<u8>, FormatError> {
|
) -> Result<Vec<u8>, FormatError> {
|
||||||
let (chunk_dimensions, addr_opt) = match layout {
|
let (
|
||||||
|
chunk_dimensions,
|
||||||
|
version,
|
||||||
|
chunk_index_type,
|
||||||
|
addr_opt,
|
||||||
|
single_filtered_size,
|
||||||
|
single_filter_mask,
|
||||||
|
) = match layout {
|
||||||
DataLayout::Chunked {
|
DataLayout::Chunked {
|
||||||
chunk_dimensions,
|
chunk_dimensions,
|
||||||
btree_address,
|
btree_address,
|
||||||
..
|
version,
|
||||||
} => (chunk_dimensions, *btree_address),
|
chunk_index_type,
|
||||||
|
single_chunk_filtered_size,
|
||||||
|
single_chunk_filter_mask,
|
||||||
|
} => (
|
||||||
|
chunk_dimensions,
|
||||||
|
*version,
|
||||||
|
*chunk_index_type,
|
||||||
|
*btree_address,
|
||||||
|
*single_chunk_filtered_size,
|
||||||
|
*single_chunk_filter_mask,
|
||||||
|
),
|
||||||
_ => {
|
_ => {
|
||||||
return Err(FormatError::ChunkedReadError(
|
return Err(FormatError::ChunkedReadError(
|
||||||
"expected chunked layout".into(),
|
"expected chunked layout".into(),
|
||||||
@@ -1282,14 +1196,69 @@ pub fn read_chunked_data_indexed(
|
|||||||
|
|
||||||
// Build chunk index on first access
|
// Build chunk index on first access
|
||||||
if !cache.has_chunk_index() {
|
if !cache.has_chunk_index() {
|
||||||
let (chunks, _) = list_chunks(
|
let chunks = match (version, chunk_index_type) {
|
||||||
|
(3, _) => collect_chunk_info(file_data, addr, ndims, offset_size, length_size)?,
|
||||||
|
(4, Some(1)) => {
|
||||||
|
let chunk_byte_size: usize = chunk_dims.iter().product::<usize>() * elem_size;
|
||||||
|
let (csize, fmask) = if let Some(fs) = single_filtered_size {
|
||||||
|
(fs as u32, single_filter_mask.unwrap_or(0))
|
||||||
|
} else {
|
||||||
|
(chunk_byte_size as u32, 0)
|
||||||
|
};
|
||||||
|
vec![ChunkInfo {
|
||||||
|
chunk_size: csize,
|
||||||
|
filter_mask: fmask,
|
||||||
|
offsets: vec![0u64; rank],
|
||||||
|
address: addr,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
(4, Some(2)) => {
|
||||||
|
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||||
|
generate_implicit_chunks(
|
||||||
|
addr,
|
||||||
|
&dataspace.dimensions,
|
||||||
|
spatial_chunk_dims,
|
||||||
|
elem_size as u32,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(4, Some(3)) => {
|
||||||
|
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||||
|
let header =
|
||||||
|
FixedArrayHeader::parse(file_data, addr as usize, offset_size, length_size)?;
|
||||||
|
read_fixed_array_chunks(
|
||||||
file_data,
|
file_data,
|
||||||
layout,
|
&header,
|
||||||
dataspace,
|
&dataspace.dimensions,
|
||||||
elem_size,
|
spatial_chunk_dims,
|
||||||
|
elem_size as u32,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
(4, Some(4)) => {
|
||||||
|
let spatial_chunk_dims: &[u32] = &chunk_dimensions[..rank];
|
||||||
|
let header = ExtensibleArrayHeader::parse(
|
||||||
|
file_data,
|
||||||
|
addr as usize,
|
||||||
offset_size,
|
offset_size,
|
||||||
length_size,
|
length_size,
|
||||||
)?;
|
)?;
|
||||||
|
read_extensible_array_chunks(
|
||||||
|
file_data,
|
||||||
|
&header,
|
||||||
|
&dataspace.dimensions,
|
||||||
|
spatial_chunk_dims,
|
||||||
|
elem_size as u32,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
(v, idx) => {
|
||||||
|
return Err(FormatError::ChunkedReadError(format!(
|
||||||
|
"unsupported chunked layout version={v}, index_type={idx:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
cache.populate_chunk_index(&chunks, rank);
|
cache.populate_chunk_index(&chunks, rank);
|
||||||
// Also populate the legacy index for compatibility
|
// Also populate the legacy index for compatibility
|
||||||
if !cache.has_index() {
|
if !cache.has_index() {
|
||||||
@@ -1494,64 +1463,6 @@ fn copy_chunk_to_output(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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) {
|
fn write_offset(buf: &mut Vec<u8>, val: u64, size: u8) {
|
||||||
match size {
|
match size {
|
||||||
4 => buf.extend_from_slice(&(val as u32).to_le_bytes()),
|
4 => buf.extend_from_slice(&(val as u32).to_le_bytes()),
|
||||||
@@ -1746,9 +1657,9 @@ mod tests {
|
|||||||
let chunk_bytes = chunk_size_elems * elem_size; // full chunk allocation
|
let chunk_bytes = chunk_size_elems * elem_size; // full chunk allocation
|
||||||
|
|
||||||
// Write chunk data (full chunk size, padding with zeros)
|
// Write chunk data (full chunk size, padding with zeros)
|
||||||
for (i, value) in values.iter().enumerate().take(end).skip(start) {
|
for i in start..end {
|
||||||
let byte_offset = data_offset + (i - start) * elem_size;
|
let byte_offset = data_offset + (i - start) * elem_size;
|
||||||
file_data[byte_offset..byte_offset + 8].copy_from_slice(&value.to_le_bytes());
|
file_data[byte_offset..byte_offset + 8].copy_from_slice(&values[i].to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
chunk_infos.push(ChunkInfo {
|
chunk_infos.push(ChunkInfo {
|
||||||
@@ -1926,8 +1837,8 @@ mod tests {
|
|||||||
for chunk_idx in 0..2 {
|
for chunk_idx in 0..2 {
|
||||||
let start = chunk_idx * chunk_elems;
|
let start = chunk_idx * chunk_elems;
|
||||||
let mut chunk_bytes = Vec::new();
|
let mut chunk_bytes = Vec::new();
|
||||||
for value in values.iter().skip(start).take(chunk_elems) {
|
for i in start..start + chunk_elems {
|
||||||
chunk_bytes.extend_from_slice(&value.to_le_bytes());
|
chunk_bytes.extend_from_slice(&values[i].to_le_bytes());
|
||||||
}
|
}
|
||||||
let compressed = compress_chunk(&chunk_bytes, &pipeline, elem_size as u32).unwrap();
|
let compressed = compress_chunk(&chunk_bytes, &pipeline, elem_size as u32).unwrap();
|
||||||
|
|
||||||
@@ -2241,23 +2152,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cached_read_second_call_reuses_the_index() {
|
fn cached_read_second_call_uses_cache() {
|
||||||
let values: Vec<f64> = (0..20).map(|i| i as f64).collect();
|
let values: Vec<f64> = (0..20).map(|i| i as f64).collect();
|
||||||
let (file_data, layout, dataspace) = build_1d_chunked_file(&values, 10);
|
let (file_data, layout, dataspace) = build_1d_chunked_file(&values, 10);
|
||||||
let datatype = make_f64_type();
|
let datatype = make_f64_type();
|
||||||
let cache = ChunkCache::new();
|
let cache = ChunkCache::new();
|
||||||
|
|
||||||
// First read — populates the chunk index. These chunks are stored
|
// First read — populates index + decompressed cache
|
||||||
// unfiltered, so they are copied straight from the file bytes and the
|
|
||||||
// decompressed-chunk cache is (deliberately) not involved.
|
|
||||||
let raw1 = read_chunked_data_cached(
|
let raw1 = read_chunked_data_cached(
|
||||||
&file_data, &layout, &dataspace, &datatype, None, 8, 8, &cache,
|
&file_data, &layout, &dataspace, &datatype, None, 8, 8, &cache,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(cache.has_index());
|
assert!(cache.has_index());
|
||||||
assert_eq!(cache.cached_chunk_count(), 0);
|
assert!(cache.cached_chunk_count() > 0);
|
||||||
|
|
||||||
// Second read — reuses the cached index
|
// Second read — should hit the decompressed cache
|
||||||
let raw2 = read_chunked_data_cached(
|
let raw2 = read_chunked_data_cached(
|
||||||
&file_data, &layout, &dataspace, &datatype, None, 8, 8, &cache,
|
&file_data, &layout, &dataspace, &datatype, None, 8, 8, &cache,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use crate::filter_pipeline::{
|
|||||||
FilterDescription, FilterPipeline,
|
FilterDescription, FilterPipeline,
|
||||||
};
|
};
|
||||||
use crate::filters::compress_chunk;
|
use crate::filters::compress_chunk;
|
||||||
|
|
||||||
/// Round a file offset up to the next cache-line boundary.
|
/// Round a file offset up to the next cache-line boundary.
|
||||||
///
|
///
|
||||||
/// This ensures chunk data starts at an address that is a multiple of the
|
/// This ensures chunk data starts at an address that is a multiple of the
|
||||||
@@ -48,38 +49,6 @@ pub struct ChunkOptions {
|
|||||||
pub pcodec: bool,
|
pub pcodec: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Largest chunk the automatic choice produces, in bytes.
|
|
||||||
const AUTO_CHUNK_TARGET_BYTES: u64 = 1 << 20;
|
|
||||||
|
|
||||||
/// Extent assumed for a dimension that is currently empty (an unlimited
|
|
||||||
/// dimension not yet written to) — the same stand-in h5py uses.
|
|
||||||
const AUTO_CHUNK_EMPTY_DIM: u64 = 1024;
|
|
||||||
|
|
||||||
/// Choose chunk dimensions for a dataset nobody specified them for.
|
|
||||||
///
|
|
||||||
/// Asking for compression (or any filter) without chunk dimensions used to
|
|
||||||
/// make the whole dataset one chunk. That defeats the point of chunking: any
|
|
||||||
/// read — even a single row — must decompress everything, and a large dataset
|
|
||||||
/// cannot be decompressed in parallel. Datasets up to the target size stay a
|
|
||||||
/// single chunk, exactly as before; larger ones are split by halving the
|
|
||||||
/// dimensions in turn (so chunks keep roughly the dataset's proportions, the
|
|
||||||
/// approach h5py takes) until a chunk fits the target.
|
|
||||||
pub fn auto_chunk_dims(shape: &[u64], elem_size: usize) -> Vec<u64> {
|
|
||||||
let mut dims: Vec<u64> = shape
|
|
||||||
.iter()
|
|
||||||
.map(|&d| if d == 0 { AUTO_CHUNK_EMPTY_DIM } else { d })
|
|
||||||
.collect();
|
|
||||||
let elem = elem_size.max(1) as u64;
|
|
||||||
let bytes = |dims: &[u64]| dims.iter().fold(elem, |acc, &d| acc.saturating_mul(d));
|
|
||||||
let mut axis = 0;
|
|
||||||
while bytes(&dims) > AUTO_CHUNK_TARGET_BYTES && dims.iter().any(|&d| d > 1) {
|
|
||||||
let i = axis % dims.len();
|
|
||||||
dims[i] = dims[i].div_ceil(2);
|
|
||||||
axis += 1;
|
|
||||||
}
|
|
||||||
dims
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChunkOptions {
|
impl ChunkOptions {
|
||||||
/// Whether any chunking option is enabled.
|
/// Whether any chunking option is enabled.
|
||||||
pub fn is_chunked(&self) -> bool {
|
pub fn is_chunked(&self) -> bool {
|
||||||
@@ -166,17 +135,11 @@ impl ChunkOptions {
|
|||||||
|
|
||||||
/// Determine chunk dimensions, using user-specified or auto-computing.
|
/// Determine chunk dimensions, using user-specified or auto-computing.
|
||||||
pub fn resolve_chunk_dims(&self, shape: &[u64]) -> Vec<u64> {
|
pub fn resolve_chunk_dims(&self, shape: &[u64]) -> Vec<u64> {
|
||||||
// Without the element size, assume 8 bytes (the widest common scalar);
|
if let Some(ref dims) = self.chunk_dims {
|
||||||
// the writer uses `resolve_chunk_dims_for`.
|
dims.clone()
|
||||||
self.resolve_chunk_dims_for(shape, 8)
|
} else {
|
||||||
}
|
// Auto chunk: use the full dataset shape (single chunk)
|
||||||
|
shape.to_vec()
|
||||||
/// Chunk dimensions for a dataset of `shape` whose elements are `elem_size`
|
|
||||||
/// bytes: the caller's if given, otherwise chosen automatically.
|
|
||||||
pub fn resolve_chunk_dims_for(&self, shape: &[u64], elem_size: usize) -> Vec<u64> {
|
|
||||||
match self.chunk_dims {
|
|
||||||
Some(ref dims) => dims.clone(),
|
|
||||||
None => auto_chunk_dims(shape, elem_size),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -927,7 +890,6 @@ pub fn write_selection_to_buffer(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::chunked_read::read_chunked_data;
|
use crate::chunked_read::read_chunked_data;
|
||||||
use crate::data_layout::DataLayout;
|
use crate::data_layout::DataLayout;
|
||||||
@@ -1181,45 +1143,6 @@ mod tests {
|
|||||||
assert_eq!(dims, vec![100, 50]);
|
assert_eq!(dims, vec![100, 50]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auto_chunking_splits_only_large_datasets() {
|
|
||||||
let bytes = |dims: &[u64], elem: u64| dims.iter().product::<u64>() * elem;
|
|
||||||
// Up to the target: one chunk, as before.
|
|
||||||
assert_eq!(auto_chunk_dims(&[100, 50], 8), [100, 50]);
|
|
||||||
assert_eq!(auto_chunk_dims(&[131_072], 8), [131_072]); // exactly 1 MiB
|
|
||||||
// Larger: split, keeping proportions, never above the target.
|
|
||||||
let big = auto_chunk_dims(&[4096, 2048], 8);
|
|
||||||
assert!(bytes(&big, 8) <= AUTO_CHUNK_TARGET_BYTES, "{big:?}");
|
|
||||||
assert!(bytes(&big, 8) > AUTO_CHUNK_TARGET_BYTES / 4, "{big:?}");
|
|
||||||
assert_eq!(big[0] / big[1], 2, "proportions kept: {big:?}");
|
|
||||||
// Every dimension stays within the dataset and at least 1.
|
|
||||||
for shape in [
|
|
||||||
vec![10_000_000u64],
|
|
||||||
vec![3, 5_000_000],
|
|
||||||
vec![1, 1, 9_000_000],
|
|
||||||
vec![7; 9],
|
|
||||||
] {
|
|
||||||
let dims = auto_chunk_dims(&shape, 4);
|
|
||||||
assert!(
|
|
||||||
dims.iter().zip(&shape).all(|(c, s)| *c >= 1 && c <= s),
|
|
||||||
"{shape:?} -> {dims:?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
bytes(&dims, 4) <= AUTO_CHUNK_TARGET_BYTES,
|
|
||||||
"{shape:?} -> {dims:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// An empty (unlimited, unwritten) dimension still gets a usable chunk.
|
|
||||||
let growable = auto_chunk_dims(&[0, 128], 8);
|
|
||||||
assert!(growable[0] >= 1 && bytes(&growable, 8) <= AUTO_CHUNK_TARGET_BYTES);
|
|
||||||
// Explicit dimensions always win.
|
|
||||||
let explicit = ChunkOptions {
|
|
||||||
chunk_dims: Some(vec![10, 10]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert_eq!(explicit.resolve_chunk_dims_for(&[4096, 2048], 8), [10, 10]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chunk_options_pipeline_deflate() {
|
fn chunk_options_pipeline_deflate() {
|
||||||
// Auto-shuffle is applied before compression by default (matches h5py).
|
// Auto-shuffle is applied before compression by default (matches h5py).
|
||||||
@@ -1512,20 +1435,9 @@ mod tests {
|
|||||||
|
|
||||||
// ---- h5py round-trip tests for chunked writes ----
|
// ---- h5py round-trip tests for chunked writes ----
|
||||||
|
|
||||||
/// The Python interpreter to drive interop checks with.
|
|
||||||
///
|
|
||||||
/// `CLAWHDF5_PYTHON` lets these run against a virtualenv holding h5py,
|
|
||||||
/// which on a PEP 668 "externally managed" system is the only place it
|
|
||||||
/// can be installed. Without it the suite silently skips, and a silent
|
|
||||||
/// skip here is how a datatype bug once reached a release.
|
|
||||||
#[cfg(feature = "std")]
|
|
||||||
fn python() -> String {
|
|
||||||
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "std")]
|
#[cfg(feature = "std")]
|
||||||
fn h5py_available() -> bool {
|
fn h5py_available() -> bool {
|
||||||
std::process::Command::new(python())
|
std::process::Command::new("python3")
|
||||||
.args(["-c", "import h5py"])
|
.args(["-c", "import h5py"])
|
||||||
.output()
|
.output()
|
||||||
.map(|o| o.status.success())
|
.map(|o| o.status.success())
|
||||||
@@ -1537,10 +1449,10 @@ mod tests {
|
|||||||
if !h5py_available() {
|
if !h5py_available() {
|
||||||
panic!("h5py not installed — skipping interop test");
|
panic!("h5py not installed — skipping interop test");
|
||||||
}
|
}
|
||||||
let o = std::process::Command::new(python())
|
let o = std::process::Command::new("python3")
|
||||||
.args(["-c", script])
|
.args(["-c", script])
|
||||||
.output()
|
.output()
|
||||||
.expect("python interpreter");
|
.expect("python3");
|
||||||
if !o.status.success() {
|
if !o.status.success() {
|
||||||
panic!("h5py: {}", String::from_utf8_lossy(&o.stderr));
|
panic!("h5py: {}", String::from_utf8_lossy(&o.stderr));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,24 +307,6 @@ pub fn read_raw_data_selection(
|
|||||||
) -> Result<Vec<u8>, FormatError> {
|
) -> Result<Vec<u8>, FormatError> {
|
||||||
use crate::selection::Selection;
|
use crate::selection::Selection;
|
||||||
|
|
||||||
crate::partial_read::validate(selection, &dataspace.dimensions)?;
|
|
||||||
|
|
||||||
// Read only what the selection's bounding box touches when that is
|
|
||||||
// possible; everything below is the decode-everything-then-pick path,
|
|
||||||
// kept for the cases `partial_read` declines.
|
|
||||||
if let Some(selected) = crate::partial_read::read_selection(
|
|
||||||
file_data,
|
|
||||||
layout,
|
|
||||||
dataspace,
|
|
||||||
datatype.type_size() as usize,
|
|
||||||
pipeline,
|
|
||||||
offset_size,
|
|
||||||
length_size,
|
|
||||||
selection,
|
|
||||||
)? {
|
|
||||||
return Ok(selected);
|
|
||||||
}
|
|
||||||
|
|
||||||
match selection {
|
match selection {
|
||||||
Selection::All => {
|
Selection::All => {
|
||||||
return read_raw_data_full(
|
return read_raw_data_full(
|
||||||
@@ -493,10 +475,8 @@ fn read_virtual_data(
|
|||||||
use crate::selection::Selection;
|
use crate::selection::Selection;
|
||||||
|
|
||||||
let elem_size = datatype.type_size() as usize;
|
let elem_size = datatype.type_size() as usize;
|
||||||
let mut out = crate::chunked_read::alloc_output(crate::chunked_read::checked_byte_len(
|
let total_elems = dataspace.num_elements() as usize;
|
||||||
dataspace.checked_num_elements()?,
|
let mut out = vec![0u8; total_elems.saturating_mul(elem_size)];
|
||||||
elem_size,
|
|
||||||
)?)?;
|
|
||||||
|
|
||||||
let virtual_dims = &dataspace.dimensions;
|
let virtual_dims = &dataspace.dimensions;
|
||||||
|
|
||||||
@@ -618,7 +598,7 @@ fn read_named_dataset_raw(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extract selected elements from a full dataset buffer.
|
/// Extract selected elements from a full dataset buffer.
|
||||||
pub fn extract_selection_from_buffer(
|
fn extract_selection_from_buffer(
|
||||||
full_data: &[u8],
|
full_data: &[u8],
|
||||||
dims: &[u64],
|
dims: &[u64],
|
||||||
elem_size: usize,
|
elem_size: usize,
|
||||||
@@ -636,14 +616,12 @@ pub fn extract_selection_from_buffer(
|
|||||||
block,
|
block,
|
||||||
} => {
|
} => {
|
||||||
let rank = dims.len();
|
let rank = dims.len();
|
||||||
let output_elements = count
|
let output_elements: usize = count
|
||||||
.iter()
|
.iter()
|
||||||
.zip(block.iter())
|
.zip(block.iter())
|
||||||
.try_fold(1u64, |acc, (&c, &b)| acc.checked_mul(c.checked_mul(b)?))
|
.map(|(&c, &b)| (c * b) as usize)
|
||||||
.ok_or_else(|| FormatError::Overflow("hyperslab count x block overflows".into()))?;
|
.product();
|
||||||
let mut output = crate::chunked_read::alloc_output(
|
let mut output = vec![0u8; output_elements * elem_size];
|
||||||
crate::chunked_read::checked_byte_len(output_elements, elem_size)?,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Compute dataset strides (row-major)
|
// Compute dataset strides (row-major)
|
||||||
let mut ds_strides = vec![1usize; rank];
|
let mut ds_strides = vec![1usize; rank];
|
||||||
@@ -876,30 +854,6 @@ fn get_size(dt: &Datatype) -> usize {
|
|||||||
dt.type_size() as usize
|
dt.type_size() as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reinterpret little-endian bytes as `count` native values of `T` on a
|
|
||||||
/// little-endian target, in one copy.
|
|
||||||
///
|
|
||||||
/// The buffer is allocated uninitialised and filled by the copy. It used to be
|
|
||||||
/// `vec![0; count]` first, which for a large dataset meant writing every page
|
|
||||||
/// twice (zero it, then overwrite it) — about as expensive as the copy itself.
|
|
||||||
#[cfg(target_endian = "little")]
|
|
||||||
fn native_le_to_vec<T: Copy>(raw: &[u8], count: usize) -> Vec<T> {
|
|
||||||
let bytes = count * core::mem::size_of::<T>();
|
|
||||||
debug_assert!(bytes <= raw.len());
|
|
||||||
let mut result: Vec<T> = Vec::with_capacity(count);
|
|
||||||
// SAFETY: `result` has capacity for `count` values of `T`, i.e. `bytes`
|
|
||||||
// bytes; `raw` holds at least `bytes` bytes (callers derive `count` from
|
|
||||||
// `raw.len() / size_of::<T>()`); the regions cannot overlap because
|
|
||||||
// `result` was just allocated. Every `T` used here (f32/f64/i32/i64) is
|
|
||||||
// valid for any bit pattern, so after the copy all `count` values are
|
|
||||||
// initialised and `set_len` is sound.
|
|
||||||
unsafe {
|
|
||||||
core::ptr::copy_nonoverlapping(raw.as_ptr(), result.as_mut_ptr().cast::<u8>(), bytes);
|
|
||||||
result.set_len(count);
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert raw bytes to `f64` values.
|
/// Convert raw bytes to `f64` values.
|
||||||
pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatError> {
|
pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatError> {
|
||||||
// Array datatypes (e.g. an array-typed compound member) are read as a flat
|
// Array datatypes (e.g. an array-typed compound member) are read as a flat
|
||||||
@@ -927,7 +881,14 @@ pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatEr
|
|||||||
..
|
..
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
return Ok(native_le_to_vec::<f64>(raw, count));
|
let mut result = vec![0.0f64; count];
|
||||||
|
// SAFETY: On LE platforms, f64 in-memory representation matches LE bytes.
|
||||||
|
// We copy raw bytes directly into the f64 buffer.
|
||||||
|
// SAFETY: The byte slice is properly aligned for this type and the length is divisible by size_of::<T>().
|
||||||
|
unsafe {
|
||||||
|
core::ptr::copy_nonoverlapping(raw.as_ptr(), result.as_mut_ptr() as *mut u8, raw.len());
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
let order = get_byte_order(datatype);
|
let order = get_byte_order(datatype);
|
||||||
@@ -1010,7 +971,12 @@ pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatEr
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
return Ok(native_le_to_vec::<i64>(raw, count));
|
let mut result = vec![0i64; count];
|
||||||
|
// SAFETY: The byte slice is properly aligned for this type and the length is divisible by size_of::<T>().
|
||||||
|
unsafe {
|
||||||
|
core::ptr::copy_nonoverlapping(raw.as_ptr(), result.as_mut_ptr() as *mut u8, raw.len());
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
let order = get_byte_order(datatype);
|
let order = get_byte_order(datatype);
|
||||||
@@ -1074,7 +1040,12 @@ pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatEr
|
|||||||
..
|
..
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
return Ok(native_le_to_vec::<f32>(raw, count));
|
let mut result = vec![0.0f32; count];
|
||||||
|
// SAFETY: The byte slice is properly aligned for this type and the length is divisible by size_of::<T>().
|
||||||
|
unsafe {
|
||||||
|
core::ptr::copy_nonoverlapping(raw.as_ptr(), result.as_mut_ptr() as *mut u8, raw.len());
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
let order = get_byte_order(datatype);
|
let order = get_byte_order(datatype);
|
||||||
@@ -1151,7 +1122,12 @@ pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatEr
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
return Ok(native_le_to_vec::<i32>(raw, count));
|
let mut result = vec![0i32; count];
|
||||||
|
// SAFETY: The byte slice is properly aligned for this type and the length is divisible by size_of::<T>().
|
||||||
|
unsafe {
|
||||||
|
core::ptr::copy_nonoverlapping(raw.as_ptr(), result.as_mut_ptr() as *mut u8, raw.len());
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
let order = get_byte_order(datatype);
|
let order = get_byte_order(datatype);
|
||||||
@@ -1427,26 +1403,6 @@ pub fn read_object_references(
|
|||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
Datatype::Reference {
|
|
||||||
ref_type: crate::datatype::ReferenceType::Object2,
|
|
||||||
size,
|
|
||||||
} => {
|
|
||||||
let elem_size = *size as usize;
|
|
||||||
if elem_size == 0 {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
if !raw.len().is_multiple_of(elem_size) {
|
|
||||||
return Err(FormatError::DataSizeMismatch {
|
|
||||||
expected: 0,
|
|
||||||
actual: raw.len(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
raw.chunks_exact(elem_size)
|
|
||||||
.map(|element| {
|
|
||||||
decode_std_object_ref(element).map(|address| ObjectReference { address })
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
_ => Err(FormatError::TypeMismatch {
|
_ => Err(FormatError::TypeMismatch {
|
||||||
expected: "Reference(Object)",
|
expected: "Reference(Object)",
|
||||||
actual: datatype_name(datatype),
|
actual: datatype_name(datatype),
|
||||||
@@ -1454,46 +1410,6 @@ pub fn read_object_references(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode one `H5T_STD_REF` object reference as stored in a dataset:
|
|
||||||
/// `type(1) flags(1) token_size(1) token(token_size)`, zero-padded to the
|
|
||||||
/// element size. For a reference within the same file the token is the target
|
|
||||||
/// object's header address. An all-zero element is a null reference and
|
|
||||||
/// decodes to the undefined address (`u64::MAX`).
|
|
||||||
fn decode_std_object_ref(element: &[u8]) -> Result<u64, FormatError> {
|
|
||||||
const STD_REF_OBJECT: u8 = 2;
|
|
||||||
const FLAG_EXTERNAL: u8 = 0x01;
|
|
||||||
if element.iter().all(|&b| b == 0) {
|
|
||||||
return Ok(u64::MAX);
|
|
||||||
}
|
|
||||||
let [ref_type, flags, token_size, token @ ..] = element else {
|
|
||||||
return Err(FormatError::UnexpectedEof {
|
|
||||||
expected: 3,
|
|
||||||
available: element.len(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if *ref_type != STD_REF_OBJECT {
|
|
||||||
return Err(FormatError::InvalidReferenceType(*ref_type));
|
|
||||||
}
|
|
||||||
if flags & FLAG_EXTERNAL != 0 {
|
|
||||||
// Carries a file name as well; nothing here follows those.
|
|
||||||
return Err(FormatError::TypeMismatch {
|
|
||||||
expected: "object reference within this file",
|
|
||||||
actual: "external object reference",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let n = *token_size as usize;
|
|
||||||
if n == 0 || n > 8 || n > token.len() {
|
|
||||||
return Err(FormatError::UnexpectedEof {
|
|
||||||
expected: 3 + n,
|
|
||||||
available: element.len(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(token[..n]
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.fold(0u64, |addr, &byte| (addr << 8) | u64::from(byte)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read region references from raw bytes.
|
/// Read region references from raw bytes.
|
||||||
///
|
///
|
||||||
/// Region references encode a dataset selection (hyperslab, point list, etc.)
|
/// Region references encode a dataset selection (hyperslab, point list, etc.)
|
||||||
@@ -1847,11 +1763,11 @@ mod tests {
|
|||||||
fn f16_bits(v: f32) -> u16 {
|
fn f16_bits(v: f32) -> u16 {
|
||||||
// Encode a few exact values used by the test.
|
// Encode a few exact values used by the test.
|
||||||
match v {
|
match v {
|
||||||
0.0 => 0x0000,
|
x if x == 0.0 => 0x0000,
|
||||||
1.0 => 0x3c00,
|
x if x == 1.0 => 0x3c00,
|
||||||
-2.0 => 0xc000,
|
x if x == -2.0 => 0xc000,
|
||||||
0.5 => 0x3800,
|
x if x == 0.5 => 0x3800,
|
||||||
65504.0 => 0x7bff, // f16 max
|
x if x == 65504.0 => 0x7bff, // f16 max
|
||||||
_ => panic!("unsupported test value {v}"),
|
_ => panic!("unsupported test value {v}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2270,7 +2186,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
let mut raw = Vec::new();
|
let mut raw = Vec::new();
|
||||||
raw.extend_from_slice(&3.25f64.to_le_bytes());
|
raw.extend_from_slice(&3.14f64.to_le_bytes());
|
||||||
raw.extend_from_slice(&42i32.to_le_bytes());
|
raw.extend_from_slice(&42i32.to_le_bytes());
|
||||||
|
|
||||||
let field = read_compound_field(&raw, &dt, "id").unwrap();
|
let field = read_compound_field(&raw, &dt, "id").unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
//! HDF5 Dataspace message parsing (message type 0x0001).
|
//! HDF5 Dataspace message parsing (message type 0x0001).
|
||||||
|
|
||||||
#[cfg(not(feature = "std"))]
|
|
||||||
use alloc::format;
|
|
||||||
#[cfg(not(feature = "std"))]
|
#[cfg(not(feature = "std"))]
|
||||||
use alloc::vec::Vec;
|
use alloc::vec::Vec;
|
||||||
|
|
||||||
@@ -169,27 +167,6 @@ 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.
|
/// Total number of elements. Scalar = 1, Null = 0.
|
||||||
pub fn num_elements(&self) -> u64 {
|
pub fn num_elements(&self) -> u64 {
|
||||||
match self.space_type {
|
match self.space_type {
|
||||||
@@ -199,12 +176,7 @@ impl Dataspace {
|
|||||||
if self.dimensions.is_empty() {
|
if self.dimensions.is_empty() {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
// Saturate rather than wrap: a wrapped product could
|
self.dimensions.iter().product()
|
||||||
// under-size a buffer. Size-critical callers use
|
|
||||||
// `checked_num_elements`.
|
|
||||||
self.dimensions
|
|
||||||
.iter()
|
|
||||||
.fold(1u64, |acc, &d| acc.saturating_mul(d))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +189,11 @@ mod tests {
|
|||||||
|
|
||||||
fn build_v1_dataspace(rank: u8, flags: u8, dims: &[u64], max_dims: Option<&[u64]>) -> Vec<u8> {
|
fn build_v1_dataspace(rank: u8, flags: u8, dims: &[u64], max_dims: Option<&[u64]>) -> Vec<u8> {
|
||||||
let length_size = 8u8;
|
let length_size = 8u8;
|
||||||
let mut buf = vec![1, rank, flags, 0]; // version, rank, flags, reserved
|
let mut buf = Vec::new();
|
||||||
|
buf.push(1); // version
|
||||||
|
buf.push(rank);
|
||||||
|
buf.push(flags);
|
||||||
|
buf.push(0); // reserved
|
||||||
buf.extend_from_slice(&[0u8; 4]); // reserved(4)
|
buf.extend_from_slice(&[0u8; 4]); // reserved(4)
|
||||||
for &d in dims {
|
for &d in dims {
|
||||||
buf.extend_from_slice(&d.to_le_bytes());
|
buf.extend_from_slice(&d.to_le_bytes());
|
||||||
@@ -238,7 +214,11 @@ mod tests {
|
|||||||
dims: &[u64],
|
dims: &[u64],
|
||||||
max_dims: Option<&[u64]>,
|
max_dims: Option<&[u64]>,
|
||||||
) -> Vec<u8> {
|
) -> Vec<u8> {
|
||||||
let mut buf = vec![2, rank, flags, type_byte]; // version, rank, flags, type
|
let mut buf = Vec::new();
|
||||||
|
buf.push(2); // version
|
||||||
|
buf.push(rank);
|
||||||
|
buf.push(flags);
|
||||||
|
buf.push(type_byte);
|
||||||
for &d in dims {
|
for &d in dims {
|
||||||
buf.extend_from_slice(&d.to_le_bytes());
|
buf.extend_from_slice(&d.to_le_bytes());
|
||||||
}
|
}
|
||||||
@@ -318,7 +298,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn v1_with_4byte_length() {
|
fn v1_with_4byte_length() {
|
||||||
let mut buf = vec![1, 1, 0, 0]; // version, rank, flags, reserved
|
let mut buf = Vec::new();
|
||||||
|
buf.push(1); // version
|
||||||
|
buf.push(1); // rank
|
||||||
|
buf.push(0); // flags
|
||||||
|
buf.push(0); // reserved
|
||||||
buf.extend_from_slice(&[0u8; 4]); // reserved(4)
|
buf.extend_from_slice(&[0u8; 4]); // reserved(4)
|
||||||
buf.extend_from_slice(&10u32.to_le_bytes()); // dim with length_size=4
|
buf.extend_from_slice(&10u32.to_le_bytes()); // dim with length_size=4
|
||||||
let ds = Dataspace::parse(&buf, 4).unwrap();
|
let ds = Dataspace::parse(&buf, 4).unwrap();
|
||||||
|
|||||||
@@ -36,18 +36,8 @@ pub enum CharacterSet {
|
|||||||
/// Reference type.
|
/// Reference type.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum ReferenceType {
|
pub enum ReferenceType {
|
||||||
/// Legacy object reference: the target's object header address.
|
|
||||||
Object,
|
Object,
|
||||||
/// Legacy dataset region reference.
|
|
||||||
DatasetRegion,
|
DatasetRegion,
|
||||||
/// `H5T_STD_REF` object reference (HDF5 1.12+, datatype message version
|
|
||||||
/// 4): a small header followed by an object token. Decoded by
|
|
||||||
/// `data_read::read_object_references`.
|
|
||||||
Object2,
|
|
||||||
/// `H5T_STD_REF` dataset region reference.
|
|
||||||
DatasetRegion2,
|
|
||||||
/// `H5T_STD_REF` attribute reference.
|
|
||||||
Attribute,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A member of a compound datatype.
|
/// A member of a compound datatype.
|
||||||
@@ -214,25 +204,11 @@ 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 {
|
impl Datatype {
|
||||||
/// Parse a datatype message from raw bytes.
|
/// Parse a datatype message from raw bytes.
|
||||||
///
|
///
|
||||||
/// Returns `(Datatype, bytes_consumed)` for recursive parsing.
|
/// Returns `(Datatype, bytes_consumed)` for recursive parsing.
|
||||||
pub fn parse(data: &[u8]) -> Result<(Datatype, usize), FormatError> {
|
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
|
// Minimum header: 4 bytes (class_and_version + 3 bytes bit field) + 4 bytes size = 8
|
||||||
ensure_len(data, 0, 8)?;
|
ensure_len(data, 0, 8)?;
|
||||||
|
|
||||||
@@ -382,8 +358,7 @@ impl Datatype {
|
|||||||
pos += name_len;
|
pos += name_len;
|
||||||
let byte_offset = read_uint(data, pos, ob)?;
|
let byte_offset = read_uint(data, pos, ob)?;
|
||||||
pos += ob;
|
pos += ob;
|
||||||
let (member_dt, consumed) =
|
let (member_dt, consumed) = Datatype::parse(&data[pos..])?;
|
||||||
Self::parse_with_depth(&data[pos..], depth + 1)?;
|
|
||||||
pos += consumed;
|
pos += consumed;
|
||||||
members.push(CompoundMember {
|
members.push(CompoundMember {
|
||||||
name,
|
name,
|
||||||
@@ -392,29 +367,24 @@ impl Datatype {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if version == 1 || version == 2 {
|
} else if version == 1 || version == 2 {
|
||||||
// v1/v2: name (null-terminated, padded to a multiple of 8
|
// v1/v2: name, offset(4), dimensionality(1), reserved(3), dim_perm(4),
|
||||||
// bytes), offset(4), member datatype. v1 additionally
|
// reserved_dims(up to 4*4=16), member datatype
|
||||||
// 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 {
|
for _ in 0..num_members {
|
||||||
let (name, name_len) = read_null_terminated_string(data, pos)?;
|
let (name, name_len) = read_null_terminated_string(data, pos)?;
|
||||||
let padded = name_len.checked_add(7).ok_or(FormatError::UnexpectedEof {
|
pos += name_len;
|
||||||
expected: usize::MAX,
|
// v1: names padded to 8-byte boundary
|
||||||
available: data.len(),
|
if version == 1 {
|
||||||
})? & !7;
|
let total_name_bytes = name_len;
|
||||||
ensure_len(data, pos, padded)?;
|
let padded = (total_name_bytes + 7) & !7;
|
||||||
pos += padded;
|
pos = pos - name_len + padded;
|
||||||
|
}
|
||||||
ensure_len(data, pos, 4)?;
|
ensure_len(data, pos, 4)?;
|
||||||
let byte_offset = LittleEndian::read_u32(&data[pos..pos + 4]) as u64;
|
let byte_offset = LittleEndian::read_u32(&data[pos..pos + 4]) as u64;
|
||||||
pos += 4;
|
pos += 4;
|
||||||
if version == 1 {
|
// dimensionality(1) + reserved(3) + dim_perm(4) + 4 dim slots(16) = 24
|
||||||
ensure_len(data, pos, 28)?;
|
ensure_len(data, pos, 24)?;
|
||||||
pos += 28;
|
pos += 24;
|
||||||
}
|
let (member_dt, consumed) = Datatype::parse(&data[pos..])?;
|
||||||
let (member_dt, consumed) =
|
|
||||||
Self::parse_with_depth(&data[pos..], depth + 1)?;
|
|
||||||
pos += consumed;
|
pos += consumed;
|
||||||
members.push(CompoundMember {
|
members.push(CompoundMember {
|
||||||
name,
|
name,
|
||||||
@@ -434,15 +404,9 @@ impl Datatype {
|
|||||||
7 => {
|
7 => {
|
||||||
// Reference
|
// Reference
|
||||||
let ref_type_val = bf0 & 0x0F;
|
let ref_type_val = bf0 & 0x0F;
|
||||||
// Datatype message version 4 (HDF5 1.12) revised this class:
|
let ref_type = match ref_type_val {
|
||||||
// types 2-4 are the new `H5T_STD_REF` references, and the high
|
0 => ReferenceType::Object,
|
||||||
// nibble of the first flag byte carries their encoding version.
|
1 => ReferenceType::DatasetRegion,
|
||||||
let ref_type = match (ref_type_val, version) {
|
|
||||||
(0, _) => ReferenceType::Object,
|
|
||||||
(1, _) => ReferenceType::DatasetRegion,
|
|
||||||
(2, 4..) => ReferenceType::Object2,
|
|
||||||
(3, 4..) => ReferenceType::DatasetRegion2,
|
|
||||||
(4, 4..) => ReferenceType::Attribute,
|
|
||||||
_ => return Err(FormatError::InvalidReferenceType(ref_type_val)),
|
_ => return Err(FormatError::InvalidReferenceType(ref_type_val)),
|
||||||
};
|
};
|
||||||
Ok((Datatype::Reference { size, ref_type }, pos))
|
Ok((Datatype::Reference { size, ref_type }, pos))
|
||||||
@@ -451,7 +415,7 @@ impl Datatype {
|
|||||||
// Enumeration
|
// Enumeration
|
||||||
let num_members = (bf0 as u16) | ((bf1 as u16) << 8);
|
let num_members = (bf0 as u16) | ((bf1 as u16) << 8);
|
||||||
// Parse base type
|
// Parse base type
|
||||||
let (base_type, base_consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
|
let (base_type, base_consumed) = Datatype::parse(&data[pos..])?;
|
||||||
pos += base_consumed;
|
pos += base_consumed;
|
||||||
let base_size = base_type.type_size();
|
let base_size = base_type.type_size();
|
||||||
let mut members = Vec::with_capacity(num_members as usize);
|
let mut members = Vec::with_capacity(num_members as usize);
|
||||||
@@ -504,7 +468,7 @@ impl Datatype {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let (base_type, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
|
let (base_type, consumed) = Datatype::parse(&data[pos..])?;
|
||||||
pos += consumed;
|
pos += consumed;
|
||||||
Ok((
|
Ok((
|
||||||
Datatype::VariableLength {
|
Datatype::VariableLength {
|
||||||
@@ -530,7 +494,7 @@ impl Datatype {
|
|||||||
}
|
}
|
||||||
// skip permutation indices
|
// skip permutation indices
|
||||||
pos += ndims * 4;
|
pos += ndims * 4;
|
||||||
let (base_type, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
|
let (base_type, consumed) = Datatype::parse(&data[pos..])?;
|
||||||
pos += consumed;
|
pos += consumed;
|
||||||
Ok((
|
Ok((
|
||||||
Datatype::Array {
|
Datatype::Array {
|
||||||
@@ -551,7 +515,7 @@ impl Datatype {
|
|||||||
dimensions.push(LittleEndian::read_u32(&data[pos..pos + 4]));
|
dimensions.push(LittleEndian::read_u32(&data[pos..pos + 4]));
|
||||||
pos += 4;
|
pos += 4;
|
||||||
}
|
}
|
||||||
let (base_type, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
|
let (base_type, consumed) = Datatype::parse(&data[pos..])?;
|
||||||
pos += consumed;
|
pos += consumed;
|
||||||
Ok((
|
Ok((
|
||||||
Datatype::Array {
|
Datatype::Array {
|
||||||
@@ -568,39 +532,27 @@ impl Datatype {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
11 => {
|
11 => {
|
||||||
// Complex number (HDF5 2.0, datatype version 5). The properties
|
// Complex number — store as compound of two floats internally
|
||||||
// are a single base floating-point datatype message; an element
|
// Parse like compound with version 3 and 2 members
|
||||||
// is two consecutive base-type values (real, imaginary). There
|
// But actually class 11 has no special properties beyond class 6 compound.
|
||||||
// is no member list. Surface it as the equivalent two-member
|
// It's just recognized as a separate class. For now parse the 2 members
|
||||||
// compound `{r, i}` — the same shape h5py writes for numpy
|
// as compound.
|
||||||
// complex dtypes — so downstream compound readers work as-is.
|
let num_members = (bf0 as u16) | ((bf1 as u16) << 8);
|
||||||
if version != 5 {
|
let mut members = Vec::with_capacity(num_members as usize);
|
||||||
return Err(FormatError::InvalidDatatypeVersion {
|
let ob = offset_bytes_for_size(size);
|
||||||
class: class_id,
|
for _ in 0..num_members {
|
||||||
version,
|
let (name, name_len) = read_null_terminated_string(data, pos)?;
|
||||||
});
|
pos += name_len;
|
||||||
}
|
let byte_offset = read_uint(data, pos, ob)?;
|
||||||
let (base_type, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
|
pos += ob;
|
||||||
|
let (member_dt, consumed) = Datatype::parse(&data[pos..])?;
|
||||||
pos += consumed;
|
pos += consumed;
|
||||||
let base_size = base_type.type_size();
|
members.push(CompoundMember {
|
||||||
if base_size.checked_mul(2) != Some(size) {
|
name,
|
||||||
return Err(FormatError::DataSizeMismatch {
|
byte_offset,
|
||||||
expected: (base_size as usize).saturating_mul(2),
|
datatype: member_dt,
|
||||||
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))
|
Ok((Datatype::Compound { size, members }, pos))
|
||||||
}
|
}
|
||||||
_ => Err(FormatError::InvalidDatatypeClass(class_id)),
|
_ => Err(FormatError::InvalidDatatypeClass(class_id)),
|
||||||
@@ -862,39 +814,6 @@ mod tests {
|
|||||||
buf
|
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]
|
#[test]
|
||||||
fn test_fixed_point_u8() {
|
fn test_fixed_point_u8() {
|
||||||
let data = build_fixed_point(1, false, false, 0, 8);
|
let data = build_fixed_point(1, false, false, 0, 8);
|
||||||
@@ -1160,156 +1079,6 @@ 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]
|
#[test]
|
||||||
fn test_reference_object() {
|
fn test_reference_object() {
|
||||||
let buf = build_dt_header(7, 1, [0, 0, 0], 8);
|
let buf = build_dt_header(7, 1, [0, 0, 0], 8);
|
||||||
@@ -1579,28 +1348,6 @@ mod tests {
|
|||||||
assert_eq!(err, FormatError::InvalidCharacterSet(2));
|
assert_eq!(err, FormatError::InvalidCharacterSet(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_reference_v4_std_ref_from_hdf5_2_0() {
|
|
||||||
// Datatype message of an H5T_STD_REF dataset written by HDF5 2.0:
|
|
||||||
// class 7, version 4, type 2 (object), encoding version 1, 18 bytes.
|
|
||||||
let bytes = [0x47, 0x12, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00];
|
|
||||||
let (dt, consumed) = Datatype::parse(&bytes).unwrap();
|
|
||||||
assert_eq!(consumed, 8);
|
|
||||||
assert_eq!(
|
|
||||||
dt,
|
|
||||||
Datatype::Reference {
|
|
||||||
size: 18,
|
|
||||||
ref_type: ReferenceType::Object2
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// The new types are only valid from datatype version 4.
|
|
||||||
let old_version = [0x37, 0x12, 0x00, 0x00, 0x12, 0x00, 0x00, 0x00];
|
|
||||||
assert_eq!(
|
|
||||||
Datatype::parse(&old_version).unwrap_err(),
|
|
||||||
FormatError::InvalidReferenceType(2)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_error_invalid_reference_type() {
|
fn test_error_invalid_reference_type() {
|
||||||
let buf = build_dt_header(7, 1, [5, 0, 0], 8);
|
let buf = build_dt_header(7, 1, [5, 0, 0], 8);
|
||||||
|
|||||||
@@ -114,23 +114,6 @@ pub enum FormatError {
|
|||||||
InvalidAttributeInfoVersion(u8),
|
InvalidAttributeInfoVersion(u8),
|
||||||
/// Invalid shared message version.
|
/// Invalid shared message version.
|
||||||
InvalidSharedMessageVersion(u8),
|
InvalidSharedMessageVersion(u8),
|
||||||
/// A message is marked shared but was parsed without access to the file,
|
|
||||||
/// so the reference to the real message could not be followed.
|
|
||||||
UnresolvedSharedMessage,
|
|
||||||
/// A selection does not fit the dataset it was applied to (wrong rank, or
|
|
||||||
/// it reaches past a dimension's extent).
|
|
||||||
SelectionOutOfBounds(String),
|
|
||||||
/// The dataset's raw data is stored in external files (External Data
|
|
||||||
/// Files message), which this reader does not follow.
|
|
||||||
ExternalDataFilesUnsupported,
|
|
||||||
/// The path goes through an external link (a link into another file),
|
|
||||||
/// which this reader does not follow.
|
|
||||||
ExternalLinkUnsupported {
|
|
||||||
/// The file the link points into.
|
|
||||||
filename: String,
|
|
||||||
/// The object path within that file.
|
|
||||||
object_path: String,
|
|
||||||
},
|
|
||||||
/// Invalid SOHM table version.
|
/// Invalid SOHM table version.
|
||||||
InvalidSohmTableVersion(u8),
|
InvalidSohmTableVersion(u8),
|
||||||
/// Invalid SOHM table signature (expected "SMTB").
|
/// Invalid SOHM table signature (expected "SMTB").
|
||||||
@@ -324,25 +307,6 @@ impl fmt::Display for FormatError {
|
|||||||
FormatError::InvalidSharedMessageVersion(v) => {
|
FormatError::InvalidSharedMessageVersion(v) => {
|
||||||
write!(f, "invalid shared message version: {v}")
|
write!(f, "invalid shared message version: {v}")
|
||||||
}
|
}
|
||||||
FormatError::ExternalLinkUnsupported {
|
|
||||||
filename,
|
|
||||||
object_path,
|
|
||||||
} => write!(
|
|
||||||
f,
|
|
||||||
"path goes through an external link to {object_path} in {filename}, which is \
|
|
||||||
not supported"
|
|
||||||
),
|
|
||||||
FormatError::ExternalDataFilesUnsupported => write!(
|
|
||||||
f,
|
|
||||||
"dataset raw data is stored in external file(s), which is not supported"
|
|
||||||
),
|
|
||||||
FormatError::SelectionOutOfBounds(msg) => {
|
|
||||||
write!(f, "selection out of bounds: {msg}")
|
|
||||||
}
|
|
||||||
FormatError::UnresolvedSharedMessage => write!(
|
|
||||||
f,
|
|
||||||
"message is shared but no file data was available to resolve it"
|
|
||||||
),
|
|
||||||
FormatError::InvalidSohmTableVersion(v) => {
|
FormatError::InvalidSohmTableVersion(v) => {
|
||||||
write!(f, "invalid SOHM table version: {v}")
|
write!(f, "invalid SOHM table version: {v}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,19 +54,6 @@ 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 {
|
fn is_undefined_addr(addr: u64, offset_size: u8) -> bool {
|
||||||
match offset_size {
|
match offset_size {
|
||||||
2 => addr == 0xFFFF,
|
2 => addr == 0xFFFF,
|
||||||
@@ -111,7 +98,12 @@ impl ExtensibleArrayHeader {
|
|||||||
// 6 stats fields (each length_size) + index_block_address(offset_size) + checksum(4)
|
// 6 stats fields (each length_size) + index_block_address(offset_size) + checksum(4)
|
||||||
let min_size =
|
let min_size =
|
||||||
4 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 6 * length_size as usize + offset_size as usize + 4;
|
4 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 6 * length_size as usize + offset_size as usize + 4;
|
||||||
ensure_len(file_data, offset, min_size)?;
|
if offset + min_size > file_data.len() {
|
||||||
|
return Err(FormatError::UnexpectedEof {
|
||||||
|
expected: offset + min_size,
|
||||||
|
available: file_data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let d = &file_data[offset..];
|
let d = &file_data[offset..];
|
||||||
if &d[0..4] != b"EAHD" {
|
if &d[0..4] != b"EAHD" {
|
||||||
@@ -283,7 +275,12 @@ fn read_data_block_elements(
|
|||||||
) -> Result<Vec<ChunkInfo>, FormatError> {
|
) -> Result<Vec<ChunkInfo>, FormatError> {
|
||||||
// AEDB: signature(4) + version(1) + client_id(1) + header_address(offset_size)
|
// AEDB: signature(4) + version(1) + client_id(1) + header_address(offset_size)
|
||||||
let db_header_size = 4 + 1 + 1 + offset_size as usize;
|
let db_header_size = 4 + 1 + 1 + offset_size as usize;
|
||||||
ensure_len(file_data, db_offset, db_header_size)?;
|
if db_offset + db_header_size > file_data.len() {
|
||||||
|
return Err(FormatError::UnexpectedEof {
|
||||||
|
expected: db_offset + db_header_size,
|
||||||
|
available: file_data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let d = &file_data[db_offset..];
|
let d = &file_data[db_offset..];
|
||||||
if &d[0..4] != b"EADB" {
|
if &d[0..4] != b"EADB" {
|
||||||
@@ -430,7 +427,12 @@ pub fn read_extensible_array_chunks(
|
|||||||
// Parse index block (AEIB)
|
// Parse index block (AEIB)
|
||||||
let ib_offset = header.index_block_address as usize;
|
let ib_offset = header.index_block_address as usize;
|
||||||
let ib_header_size = 4 + 1 + 1 + offset_size as usize; // sig + ver + client + hdr_addr
|
let ib_header_size = 4 + 1 + 1 + offset_size as usize; // sig + ver + client + hdr_addr
|
||||||
ensure_len(file_data, ib_offset, ib_header_size)?;
|
if ib_offset + ib_header_size > file_data.len() {
|
||||||
|
return Err(FormatError::UnexpectedEof {
|
||||||
|
expected: ib_offset + ib_header_size,
|
||||||
|
available: file_data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let ib = &file_data[ib_offset..];
|
let ib = &file_data[ib_offset..];
|
||||||
if &ib[0..4] != b"EAIB" {
|
if &ib[0..4] != b"EAIB" {
|
||||||
@@ -626,7 +628,12 @@ fn read_super_block(
|
|||||||
|
|
||||||
// AESB: signature(4) + version(1) + client_id(1) + header_address(offset_size)
|
// AESB: signature(4) + version(1) + client_id(1) + header_address(offset_size)
|
||||||
let sb_header_size = 4 + 1 + 1 + os;
|
let sb_header_size = 4 + 1 + 1 + os;
|
||||||
ensure_len(file_data, sb_offset, sb_header_size)?;
|
if sb_offset + sb_header_size > file_data.len() {
|
||||||
|
return Err(FormatError::UnexpectedEof {
|
||||||
|
expected: sb_offset + sb_header_size,
|
||||||
|
available: file_data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if &file_data[sb_offset..sb_offset + 4] != b"EASB" {
|
if &file_data[sb_offset..sb_offset + 4] != b"EASB" {
|
||||||
return Err(FormatError::ChunkedReadError(
|
return Err(FormatError::ChunkedReadError(
|
||||||
@@ -752,33 +759,6 @@ mod tests {
|
|||||||
assert!(result.is_err());
|
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]
|
#[test]
|
||||||
fn parse_header_invalid_version() {
|
fn parse_header_invalid_version() {
|
||||||
let mut buf = vec![0u8; 256];
|
let mut buf = vec![0u8; 256];
|
||||||
|
|||||||
@@ -1221,10 +1221,8 @@ impl FileWriter {
|
|||||||
precompressed: None,
|
precompressed: None,
|
||||||
});
|
});
|
||||||
} else if is_chunked[i] {
|
} else if is_chunked[i] {
|
||||||
|
let chunk_dims = d.chunk_options.resolve_chunk_dims(&d.ds.dimensions);
|
||||||
let elem_size = d.dt.type_size() as usize;
|
let elem_size = d.dt.type_size() as usize;
|
||||||
let chunk_dims = d
|
|
||||||
.chunk_options
|
|
||||||
.resolve_chunk_dims_for(&d.ds.dimensions, elem_size);
|
|
||||||
// Compress once in Pass 1; cache the result so Pass 2 can skip
|
// Compress once in Pass 1; cache the result so Pass 2 can skip
|
||||||
// re-compression and just rebuild the index with real addresses.
|
// re-compression and just rebuild the index with real addresses.
|
||||||
let pre = precompress_chunks(
|
let pre = precompress_chunks(
|
||||||
|
|||||||
@@ -1,407 +0,0 @@
|
|||||||
//! 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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -845,33 +845,9 @@ fn shuffle_decompress(data: &[u8], element_size: usize) -> Result<Vec<u8>, Forma
|
|||||||
let num_elements = data.len() / element_size;
|
let num_elements = data.len() / element_size;
|
||||||
let mut result = vec![0u8; data.len()];
|
let mut result = vec![0u8; data.len()];
|
||||||
|
|
||||||
// The shuffled stream is `element_size` byte planes of `num_elements`
|
for i in 0..num_elements {
|
||||||
// bytes each; un-shuffling interleaves them. This is on the read path of
|
for j in 0..element_size {
|
||||||
// every compressed dataset (shuffle is applied automatically before
|
result[i * element_size + j] = data[j * num_elements + i];
|
||||||
// compression). The naive `result[i * es + j] = data[j * n + i]` form does
|
|
||||||
// a multiply and two bounds checks per byte and defeats vectorisation;
|
|
||||||
// fixed-width plane arrays sliced to a common length let the compiler
|
|
||||||
// hoist the checks and emit interleaves for the common 4- and 8-byte
|
|
||||||
// element sizes.
|
|
||||||
fn interleave<const W: usize>(data: &[u8], n: usize, out: &mut [u8]) {
|
|
||||||
let planes: [&[u8]; W] = core::array::from_fn(|j| &data[j * n..(j + 1) * n]);
|
|
||||||
for (i, element) in out.as_chunks_mut::<W>().0.iter_mut().enumerate() {
|
|
||||||
for (byte, plane) in element.iter_mut().zip(&planes) {
|
|
||||||
*byte = plane[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match element_size {
|
|
||||||
2 => interleave::<2>(data, num_elements, &mut result),
|
|
||||||
4 => interleave::<4>(data, num_elements, &mut result),
|
|
||||||
8 => interleave::<8>(data, num_elements, &mut result),
|
|
||||||
16 => interleave::<16>(data, num_elements, &mut result),
|
|
||||||
_ => {
|
|
||||||
for (i, element) in result.chunks_exact_mut(element_size).enumerate() {
|
|
||||||
for (j, byte) in element.iter_mut().enumerate() {
|
|
||||||
*byte = data[j * num_elements + i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1069,30 +1045,24 @@ fn pcodec_compress(data: &[u8], element_size: usize) -> Result<Vec<u8>, FormatEr
|
|||||||
match element_size {
|
match element_size {
|
||||||
4 => {
|
4 => {
|
||||||
let nums: Vec<f32> = data
|
let nums: Vec<f32> = data
|
||||||
.as_chunks::<4>()
|
.chunks_exact(4)
|
||||||
.0
|
.map(|b| f32::from_le_bytes(b.try_into().unwrap()))
|
||||||
.iter()
|
|
||||||
.map(|b| f32::from_le_bytes(*b))
|
|
||||||
.collect();
|
.collect();
|
||||||
simple_compress(&nums, &config)
|
simple_compress(&nums, &config)
|
||||||
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
|
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
|
||||||
}
|
}
|
||||||
8 => {
|
8 => {
|
||||||
let nums: Vec<f64> = data
|
let nums: Vec<f64> = data
|
||||||
.as_chunks::<8>()
|
.chunks_exact(8)
|
||||||
.0
|
.map(|b| f64::from_le_bytes(b.try_into().unwrap()))
|
||||||
.iter()
|
|
||||||
.map(|b| f64::from_le_bytes(*b))
|
|
||||||
.collect();
|
.collect();
|
||||||
simple_compress(&nums, &config)
|
simple_compress(&nums, &config)
|
||||||
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
|
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let nums: Vec<u32> = data
|
let nums: Vec<u32> = data
|
||||||
.as_chunks::<4>()
|
.chunks_exact(4)
|
||||||
.0
|
.map(|b| u32::from_le_bytes(b.try_into().unwrap()))
|
||||||
.iter()
|
|
||||||
.map(|b| u32::from_le_bytes(*b))
|
|
||||||
.collect();
|
.collect();
|
||||||
simple_compress(&nums, &config)
|
simple_compress(&nums, &config)
|
||||||
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
|
.map_err(|e| FormatError::CompressionError(format!("pco: {e}")))
|
||||||
@@ -1122,7 +1092,11 @@ fn pcodec_decompress(
|
|||||||
} else {
|
} else {
|
||||||
MAX_DECOMPRESS_SIZE
|
MAX_DECOMPRESS_SIZE
|
||||||
};
|
};
|
||||||
let n = limit_bytes.checked_div(element_size).unwrap_or(0);
|
let n = if element_size != 0 {
|
||||||
|
limit_bytes / element_size
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
match element_size {
|
match element_size {
|
||||||
4 => {
|
4 => {
|
||||||
let mut buf = vec![0f32; n];
|
let mut buf = vec![0f32; n];
|
||||||
@@ -1569,10 +1543,8 @@ mod tests {
|
|||||||
|
|
||||||
fn as_f32(bytes: &[u8]) -> Vec<f32> {
|
fn as_f32(bytes: &[u8]) -> Vec<f32> {
|
||||||
bytes
|
bytes
|
||||||
.as_chunks::<4>()
|
.chunks_exact(4)
|
||||||
.0
|
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
|
||||||
.iter()
|
|
||||||
.map(|c| f32::from_le_bytes(*c))
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1606,10 +1578,8 @@ mod tests {
|
|||||||
|
|
||||||
fn as_f64(bytes: &[u8]) -> Vec<f64> {
|
fn as_f64(bytes: &[u8]) -> Vec<f64> {
|
||||||
bytes
|
bytes
|
||||||
.as_chunks::<8>()
|
.chunks_exact(8)
|
||||||
.0
|
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
|
||||||
.iter()
|
|
||||||
.map(|c| f64::from_le_bytes(*c))
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1872,20 +1842,4 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(decompress_chunk(&data, &pipeline, 16, 1).is_err());
|
assert!(decompress_chunk(&data, &pipeline, 16, 1).is_err());
|
||||||
}
|
}
|
||||||
#[test]
|
|
||||||
fn unshuffle_inverts_shuffle_for_every_element_size() {
|
|
||||||
for element_size in [1usize, 2, 3, 4, 5, 8, 12, 16, 24] {
|
|
||||||
for elements in [0usize, 1, 2, 7, 64, 1000] {
|
|
||||||
let original: Vec<u8> = (0..element_size * elements)
|
|
||||||
.map(|i| (i * 31 + 7) as u8)
|
|
||||||
.collect();
|
|
||||||
let shuffled = shuffle_compress(&original, element_size).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
shuffle_decompress(&shuffled, element_size).unwrap(),
|
|
||||||
original,
|
|
||||||
"element_size {element_size}, {elements} elements"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,19 +47,6 @@ fn read_length(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
|
|||||||
read_offset(data, pos, size)
|
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 {
|
fn is_undefined(data: &[u8], pos: usize, size: u8) -> bool {
|
||||||
let s = size as usize;
|
let s = size as usize;
|
||||||
if pos + s > data.len() {
|
if pos + s > data.len() {
|
||||||
@@ -79,7 +66,12 @@ impl FixedArrayHeader {
|
|||||||
// FAHD signature(4) + version(1) + client_id(1) + element_size(1) +
|
// 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)
|
// 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;
|
let min_size = 4 + 1 + 1 + 1 + 1 + length_size as usize + offset_size as usize + 4;
|
||||||
ensure_len(file_data, offset, min_size)?;
|
if offset + min_size > file_data.len() {
|
||||||
|
return Err(FormatError::UnexpectedEof {
|
||||||
|
expected: offset + min_size,
|
||||||
|
available: file_data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let d = &file_data[offset..];
|
let d = &file_data[offset..];
|
||||||
if &d[0..4] != b"FAHD" {
|
if &d[0..4] != b"FAHD" {
|
||||||
@@ -134,7 +126,12 @@ pub fn read_fixed_array_chunks(
|
|||||||
|
|
||||||
// Parse data block header: FADB(4) + version(1) + client_id(1) + header_address(offset_size)
|
// 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;
|
let db_header_size = 4 + 1 + 1 + offset_size as usize;
|
||||||
ensure_len(file_data, db_offset, db_header_size)?;
|
if db_offset + db_header_size > file_data.len() {
|
||||||
|
return Err(FormatError::UnexpectedEof {
|
||||||
|
expected: db_offset + db_header_size,
|
||||||
|
available: file_data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let d = &file_data[db_offset..];
|
let d = &file_data[db_offset..];
|
||||||
if &d[0..4] != b"FADB" {
|
if &d[0..4] != b"FADB" {
|
||||||
@@ -492,29 +489,6 @@ mod tests {
|
|||||||
assert!(r.is_err());
|
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]
|
#[test]
|
||||||
fn parse_fixed_array_header_invalid_version() {
|
fn parse_fixed_array_header_invalid_version() {
|
||||||
let mut buf = vec![0u8; 256];
|
let mut buf = vec![0u8; 256];
|
||||||
|
|||||||
@@ -184,7 +184,9 @@ mod tests {
|
|||||||
buf.extend_from_slice(data);
|
buf.extend_from_slice(data);
|
||||||
// Pad to 8 bytes
|
// Pad to 8 bytes
|
||||||
let padded = pad8(data.len());
|
let padded = pad8(data.len());
|
||||||
buf.resize(buf.len() + (padded - data.len()), 0);
|
for _ in data.len()..padded {
|
||||||
|
buf.push(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Free space marker
|
// Free space marker
|
||||||
|
|||||||
@@ -60,54 +60,6 @@ pub fn resolve_v1_group_entries(
|
|||||||
Ok(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.
|
/// Extract the SymbolTableMessage from an object header's messages.
|
||||||
fn find_symbol_table_message(
|
fn find_symbol_table_message(
|
||||||
obj_header: &ObjectHeader,
|
obj_header: &ObjectHeader,
|
||||||
|
|||||||
@@ -63,15 +63,14 @@ fn resolve_compact_entries(
|
|||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Visit every link in dense storage (fractal heap + B-tree v2 name index).
|
/// Resolve entries from dense storage (fractal heap + B-tree v2).
|
||||||
fn for_each_dense_link(
|
fn resolve_dense_entries(
|
||||||
file_data: &[u8],
|
file_data: &[u8],
|
||||||
link_info: &LinkInfoMessage,
|
link_info: &LinkInfoMessage,
|
||||||
fh_addr: u64,
|
fh_addr: u64,
|
||||||
offset_size: u8,
|
offset_size: u8,
|
||||||
length_size: u8,
|
length_size: u8,
|
||||||
mut visit: impl FnMut(LinkMessage),
|
) -> Result<Vec<GroupEntry>, FormatError> {
|
||||||
) -> Result<(), FormatError> {
|
|
||||||
// Parse fractal heap
|
// Parse fractal heap
|
||||||
let fh = FractalHeapHeader::parse(file_data, fh_addr as usize, offset_size, length_size)?;
|
let fh = FractalHeapHeader::parse(file_data, fh_addr as usize, offset_size, length_size)?;
|
||||||
|
|
||||||
@@ -82,6 +81,7 @@ fn for_each_dense_link(
|
|||||||
let btree_hdr = BTreeV2Header::parse(file_data, btree_addr as usize, offset_size, length_size)?;
|
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 records = collect_btree_v2_records(file_data, &btree_hdr, offset_size, length_size)?;
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
for record in &records {
|
for record in &records {
|
||||||
// For type 5 (name index): hash(4) + heap_id(heap_id_length)
|
// 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)
|
// For type 6 (creation order): creation_order(8) + heap_id(heap_id_length)
|
||||||
@@ -98,27 +98,9 @@ fn for_each_dense_link(
|
|||||||
|
|
||||||
// Read managed object from fractal heap
|
// Read managed object from fractal heap
|
||||||
let link_data = fh.read_managed_object(file_data, id_bytes, offset_size)?;
|
let link_data = fh.read_managed_object(file_data, id_bytes, offset_size)?;
|
||||||
visit(LinkMessage::parse(&link_data, offset_size)?);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve entries from dense storage (fractal heap + B-tree v2).
|
// Parse as Link message
|
||||||
fn resolve_dense_entries(
|
let link = LinkMessage::parse(&link_data, offset_size)?;
|
||||||
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 {
|
if let LinkTarget::Hard {
|
||||||
object_header_address,
|
object_header_address,
|
||||||
} = link.link_target
|
} = link.link_target
|
||||||
@@ -129,63 +111,9 @@ fn resolve_dense_entries(
|
|||||||
cache_type: 0,
|
cache_type: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)?;
|
|
||||||
Ok(entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The soft or external link called `name` in this group, if there is one.
|
Ok(entries)
|
||||||
/// 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.
|
/// Find and parse the Link Info message from an object header.
|
||||||
@@ -230,19 +158,6 @@ pub fn resolve_path_any(
|
|||||||
file_data: &[u8],
|
file_data: &[u8],
|
||||||
superblock: &Superblock,
|
superblock: &Superblock,
|
||||||
path: &str,
|
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> {
|
) -> Result<u64, FormatError> {
|
||||||
let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
if components.is_empty() {
|
if components.is_empty() {
|
||||||
@@ -261,9 +176,7 @@ fn resolve_path_following_links(
|
|||||||
for (i, component) in components.iter().enumerate() {
|
for (i, component) in components.iter().enumerate() {
|
||||||
let entries = resolve_group_entries(file_data, ¤t_header, os, ls)?;
|
let entries = resolve_group_entries(file_data, ¤t_header, os, ls)?;
|
||||||
|
|
||||||
let found = entries
|
let found = entries.iter().find(|e| e.name == *component);
|
||||||
.iter()
|
|
||||||
.find(|e| e.name == *component && e.object_header_address != u64::MAX);
|
|
||||||
match found {
|
match found {
|
||||||
Some(entry) => {
|
Some(entry) => {
|
||||||
if i == components.len() - 1 {
|
if i == components.len() - 1 {
|
||||||
@@ -273,37 +186,7 @@ fn resolve_path_following_links(
|
|||||||
current_header = ObjectHeader::parse(file_data, current_addr as usize, os, ls)?;
|
current_header = ObjectHeader::parse(file_data, current_addr as usize, os, ls)?;
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
return match find_symbolic_link(file_data, ¤t_header, component, os, ls)? {
|
return Err(FormatError::PathNotFound(String::from(*component)));
|
||||||
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,7 +67,6 @@ pub mod ea_writer;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod extensible_array;
|
pub mod extensible_array;
|
||||||
pub mod file_writer;
|
pub mod file_writer;
|
||||||
pub mod fill_value;
|
|
||||||
pub mod filter_pipeline;
|
pub mod filter_pipeline;
|
||||||
pub mod filters;
|
pub mod filters;
|
||||||
mod filters_szip;
|
mod filters_szip;
|
||||||
@@ -89,7 +88,6 @@ pub mod object_header;
|
|||||||
pub mod object_header_writer;
|
pub mod object_header_writer;
|
||||||
#[cfg(feature = "parallel")]
|
#[cfg(feature = "parallel")]
|
||||||
pub mod parallel_read;
|
pub mod parallel_read;
|
||||||
pub mod partial_read;
|
|
||||||
pub mod profiling;
|
pub mod profiling;
|
||||||
pub mod property_list;
|
pub mod property_list;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
|
|||||||
@@ -413,8 +413,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn soft_link() {
|
fn soft_link() {
|
||||||
let target = "/group1/dataset";
|
let target = "/group1/dataset";
|
||||||
// version, flags (bit 3 = link type present, name size = 1 byte), link type = soft, name length = 4
|
let mut data = Vec::new();
|
||||||
let mut data = vec![1, 0x08, 1, 4];
|
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
|
||||||
data.extend_from_slice(b"link");
|
data.extend_from_slice(b"link");
|
||||||
data.extend_from_slice(&(target.len() as u16).to_le_bytes());
|
data.extend_from_slice(&(target.len() as u16).to_le_bytes());
|
||||||
data.extend_from_slice(target.as_bytes());
|
data.extend_from_slice(target.as_bytes());
|
||||||
@@ -452,8 +455,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_link_type() {
|
fn invalid_link_type() {
|
||||||
// version, flags (bit 3 = link type present), invalid link type = 99, name length = 1, name = 'x'
|
let mut data = Vec::new();
|
||||||
let data = vec![1, 0x08, 99, 1, b'x'];
|
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');
|
||||||
let err = LinkMessage::parse(&data, 8).unwrap_err();
|
let err = LinkMessage::parse(&data, 8).unwrap_err();
|
||||||
assert_eq!(err, FormatError::InvalidLinkType(99));
|
assert_eq!(err, FormatError::InvalidLinkType(99));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ pub enum MessageType {
|
|||||||
Datatype,
|
Datatype,
|
||||||
FillValueOld,
|
FillValueOld,
|
||||||
FillValue,
|
FillValue,
|
||||||
/// External Data Files (0x0007): the dataset's raw data lives in other
|
|
||||||
/// files, listed by this message.
|
|
||||||
ExternalDataFiles,
|
|
||||||
Link,
|
Link,
|
||||||
DataLayout,
|
DataLayout,
|
||||||
GroupInfo,
|
GroupInfo,
|
||||||
@@ -39,7 +36,6 @@ impl MessageType {
|
|||||||
0x0004 => MessageType::FillValueOld,
|
0x0004 => MessageType::FillValueOld,
|
||||||
0x0005 => MessageType::FillValue,
|
0x0005 => MessageType::FillValue,
|
||||||
0x0006 => MessageType::Link,
|
0x0006 => MessageType::Link,
|
||||||
0x0007 => MessageType::ExternalDataFiles,
|
|
||||||
0x0008 => MessageType::DataLayout,
|
0x0008 => MessageType::DataLayout,
|
||||||
0x000A => MessageType::GroupInfo,
|
0x000A => MessageType::GroupInfo,
|
||||||
0x000B => MessageType::FilterPipeline,
|
0x000B => MessageType::FilterPipeline,
|
||||||
@@ -64,7 +60,6 @@ impl MessageType {
|
|||||||
MessageType::Datatype => 0x0003,
|
MessageType::Datatype => 0x0003,
|
||||||
MessageType::FillValueOld => 0x0004,
|
MessageType::FillValueOld => 0x0004,
|
||||||
MessageType::FillValue => 0x0005,
|
MessageType::FillValue => 0x0005,
|
||||||
MessageType::ExternalDataFiles => 0x0007,
|
|
||||||
MessageType::Link => 0x0006,
|
MessageType::Link => 0x0006,
|
||||||
MessageType::DataLayout => 0x0008,
|
MessageType::DataLayout => 0x0008,
|
||||||
MessageType::GroupInfo => 0x000A,
|
MessageType::GroupInfo => 0x000A,
|
||||||
@@ -95,7 +90,6 @@ mod tests {
|
|||||||
(0x0003, MessageType::Datatype),
|
(0x0003, MessageType::Datatype),
|
||||||
(0x0004, MessageType::FillValueOld),
|
(0x0004, MessageType::FillValueOld),
|
||||||
(0x0005, MessageType::FillValue),
|
(0x0005, MessageType::FillValue),
|
||||||
(0x0007, MessageType::ExternalDataFiles),
|
|
||||||
(0x0006, MessageType::Link),
|
(0x0006, MessageType::Link),
|
||||||
(0x0008, MessageType::DataLayout),
|
(0x0008, MessageType::DataLayout),
|
||||||
(0x000A, MessageType::GroupInfo),
|
(0x000A, MessageType::GroupInfo),
|
||||||
@@ -125,13 +119,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_type_zero_gap() {
|
fn unknown_type_zero_gap() {
|
||||||
// 0x0009 is reserved for the library's own testing; no file uses it.
|
// 0x0007 is not a defined type
|
||||||
let mt = MessageType::from_u16(0x0009);
|
let mt = MessageType::from_u16(0x0007);
|
||||||
assert_eq!(mt, MessageType::Unknown(0x0009));
|
assert_eq!(mt, MessageType::Unknown(0x0007));
|
||||||
// 0x0007 used to be treated as unknown: it is External Data Files.
|
|
||||||
assert_eq!(
|
|
||||||
MessageType::from_u16(0x0007),
|
|
||||||
MessageType::ExternalDataFiles
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,12 +73,9 @@ pub fn decompress_chunks_lane_partitioned(
|
|||||||
let c_addr = chunk_info.address as usize;
|
let c_addr = chunk_info.address as usize;
|
||||||
let size = chunk_info.chunk_size as usize;
|
let size = chunk_info.chunk_size as usize;
|
||||||
|
|
||||||
if c_addr
|
if c_addr + size > file_data.len() {
|
||||||
.checked_add(size)
|
|
||||||
.is_none_or(|end| end > file_data.len())
|
|
||||||
{
|
|
||||||
return Err(FormatError::UnexpectedEof {
|
return Err(FormatError::UnexpectedEof {
|
||||||
expected: c_addr.saturating_add(size),
|
expected: c_addr + size,
|
||||||
available: file_data.len(),
|
available: file_data.len(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -147,12 +144,9 @@ pub fn decompress_chunks_parallel(
|
|||||||
.map(|(index, chunk_info)| {
|
.map(|(index, chunk_info)| {
|
||||||
let c_addr = chunk_info.address as usize;
|
let c_addr = chunk_info.address as usize;
|
||||||
let size = chunk_info.chunk_size as usize;
|
let size = chunk_info.chunk_size as usize;
|
||||||
if c_addr
|
if c_addr + size > file_data.len() {
|
||||||
.checked_add(size)
|
|
||||||
.is_none_or(|end| end > file_data.len())
|
|
||||||
{
|
|
||||||
return Err(FormatError::UnexpectedEof {
|
return Err(FormatError::UnexpectedEof {
|
||||||
expected: c_addr.saturating_add(size),
|
expected: c_addr + size,
|
||||||
available: file_data.len(),
|
available: file_data.len(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -188,12 +182,9 @@ pub fn decompress_chunks_sequential(
|
|||||||
for chunk_info in chunks {
|
for chunk_info in chunks {
|
||||||
let c_addr = chunk_info.address as usize;
|
let c_addr = chunk_info.address as usize;
|
||||||
let size = chunk_info.chunk_size as usize;
|
let size = chunk_info.chunk_size as usize;
|
||||||
if c_addr
|
if c_addr + size > file_data.len() {
|
||||||
.checked_add(size)
|
|
||||||
.is_none_or(|end| end > file_data.len())
|
|
||||||
{
|
|
||||||
return Err(FormatError::UnexpectedEof {
|
return Err(FormatError::UnexpectedEof {
|
||||||
expected: c_addr.saturating_add(size),
|
expected: c_addr + size,
|
||||||
available: file_data.len(),
|
available: file_data.len(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,359 +0,0 @@
|
|||||||
//! Selection reads that cost what the selection costs, not what the dataset
|
|
||||||
//! costs.
|
|
||||||
//!
|
|
||||||
//! [`crate::data_read::read_raw_data_selection`] used to decode the *entire*
|
|
||||||
//! dataset and then pick elements out of it, so reading a 64x64 window of a
|
|
||||||
//! large dataset took about as long as reading all of it. Here the selection's
|
|
||||||
//! bounding box is materialised instead — only the rows of a contiguous
|
|
||||||
//! dataset, or only the chunks, that overlap it — and the existing extractor
|
|
||||||
//! runs over that small buffer with the selection translated to the box's
|
|
||||||
//! origin. Extraction semantics are therefore exactly the full-read ones.
|
|
||||||
|
|
||||||
#[cfg(not(feature = "std"))]
|
|
||||||
use alloc::string as alloc_or_std;
|
|
||||||
#[cfg(not(feature = "std"))]
|
|
||||||
use alloc::{format, vec, vec::Vec};
|
|
||||||
#[cfg(feature = "std")]
|
|
||||||
use std::string as alloc_or_std;
|
|
||||||
|
|
||||||
use crate::chunked_read::{alloc_output, checked_byte_len, list_chunks};
|
|
||||||
use crate::data_layout::DataLayout;
|
|
||||||
use crate::data_read::extract_selection_from_buffer;
|
|
||||||
use crate::dataspace::Dataspace;
|
|
||||||
use crate::error::FormatError;
|
|
||||||
use crate::filter_pipeline::FilterPipeline;
|
|
||||||
use crate::filters::decompress_chunk;
|
|
||||||
use crate::selection::Selection;
|
|
||||||
|
|
||||||
/// The smallest axis-aligned box containing every selected element, as
|
|
||||||
/// `(start, extent)` per dimension. `None` when there is nothing to gain or
|
|
||||||
/// the selection is not valid for `dims` (the caller's full path then reports
|
|
||||||
/// the error exactly as before).
|
|
||||||
fn bounding_box(selection: &Selection, dims: &[u64]) -> Option<(Vec<u64>, Vec<u64>)> {
|
|
||||||
match selection {
|
|
||||||
Selection::Hyperslab {
|
|
||||||
start,
|
|
||||||
stride,
|
|
||||||
count,
|
|
||||||
block,
|
|
||||||
} => {
|
|
||||||
let rank = dims.len();
|
|
||||||
if [start.len(), stride.len(), count.len(), block.len()] != [rank; 4] {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut extent = Vec::with_capacity(rank);
|
|
||||||
for d in 0..rank {
|
|
||||||
if count[d] == 0 || block[d] == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// Last selected index + 1, relative to start.
|
|
||||||
let span = (count[d] - 1)
|
|
||||||
.checked_mul(stride[d])?
|
|
||||||
.checked_add(block[d])?;
|
|
||||||
if start[d].checked_add(span)? > dims[d] {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
extent.push(span);
|
|
||||||
}
|
|
||||||
Some((start.clone(), extent))
|
|
||||||
}
|
|
||||||
Selection::Points(points) => {
|
|
||||||
let rank = dims.len();
|
|
||||||
let first = points.first()?;
|
|
||||||
if first.len() != rank {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let (mut lo, mut hi) = (first.clone(), first.clone());
|
|
||||||
for p in points {
|
|
||||||
if p.len() != rank {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
for d in 0..rank {
|
|
||||||
if p[d] >= dims[d] {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
lo[d] = lo[d].min(p[d]);
|
|
||||||
hi[d] = hi[d].max(p[d]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let extent = lo.iter().zip(&hi).map(|(l, h)| h - l + 1).collect();
|
|
||||||
Some((lo, extent))
|
|
||||||
}
|
|
||||||
Selection::All | Selection::None => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check that `selection` addresses only elements that exist in a dataset of
|
|
||||||
/// shape `dims`. Without this an out-of-range selection read *something*: a
|
|
||||||
/// hyperslab past the edge came back padded with zeros, and a point whose
|
|
||||||
/// column was out of range wrapped into the next row.
|
|
||||||
pub fn validate(selection: &Selection, dims: &[u64]) -> Result<(), FormatError> {
|
|
||||||
let rank = dims.len();
|
|
||||||
let bad = |msg: alloc_or_std::String| Err(FormatError::SelectionOutOfBounds(msg));
|
|
||||||
match selection {
|
|
||||||
Selection::All | Selection::None => Ok(()),
|
|
||||||
Selection::Hyperslab {
|
|
||||||
start,
|
|
||||||
stride,
|
|
||||||
count,
|
|
||||||
block,
|
|
||||||
} => {
|
|
||||||
if [start.len(), stride.len(), count.len(), block.len()] != [rank; 4] {
|
|
||||||
return bad(format!("hyperslab rank does not match dataset rank {rank}"));
|
|
||||||
}
|
|
||||||
for d in 0..rank {
|
|
||||||
if count[d] == 0 || block[d] == 0 {
|
|
||||||
continue; // selects nothing along this dimension
|
|
||||||
}
|
|
||||||
let end = (count[d] - 1)
|
|
||||||
.checked_mul(stride[d])
|
|
||||||
.and_then(|v| v.checked_add(block[d]))
|
|
||||||
.and_then(|v| v.checked_add(start[d]));
|
|
||||||
if !end.is_some_and(|end| end <= dims[d]) {
|
|
||||||
return bad(format!(
|
|
||||||
"dimension {d}: start {} stride {} count {} block {} exceeds extent {}",
|
|
||||||
start[d], stride[d], count[d], block[d], dims[d]
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if block[d] > stride[d] && count[d] > 1 {
|
|
||||||
return bad(format!(
|
|
||||||
"dimension {d}: block {} larger than stride {} (overlapping blocks)",
|
|
||||||
block[d], stride[d]
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Selection::Points(points) => {
|
|
||||||
for p in points {
|
|
||||||
if p.len() != rank {
|
|
||||||
return bad(format!("point {p:?} does not match dataset rank {rank}"));
|
|
||||||
}
|
|
||||||
if let Some(d) = (0..rank).find(|&d| p[d] >= dims[d]) {
|
|
||||||
return bad(format!(
|
|
||||||
"point {p:?}: coordinate {} exceeds extent {} of dimension {d}",
|
|
||||||
p[d], dims[d]
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The same selection expressed relative to `origin`.
|
|
||||||
fn translate(selection: &Selection, origin: &[u64]) -> Selection {
|
|
||||||
match selection {
|
|
||||||
Selection::Hyperslab {
|
|
||||||
start,
|
|
||||||
stride,
|
|
||||||
count,
|
|
||||||
block,
|
|
||||||
} => Selection::Hyperslab {
|
|
||||||
start: start.iter().zip(origin).map(|(s, o)| s - o).collect(),
|
|
||||||
stride: stride.clone(),
|
|
||||||
count: count.clone(),
|
|
||||||
block: block.clone(),
|
|
||||||
},
|
|
||||||
Selection::Points(points) => Selection::Points(
|
|
||||||
points
|
|
||||||
.iter()
|
|
||||||
.map(|p| p.iter().zip(origin).map(|(c, o)| c - o).collect())
|
|
||||||
.collect(),
|
|
||||||
),
|
|
||||||
other => other.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Copy the part of a source region that overlaps the box into `out` (which
|
|
||||||
/// is the box, row-major).
|
|
||||||
///
|
|
||||||
/// The source region starts at `src_origin` in dataset coordinates, has shape
|
|
||||||
/// `src_shape`, and its elements are in `src` row-major. One `memcpy` per
|
|
||||||
/// overlapping row of the last dimension.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn copy_overlap(
|
|
||||||
src: &[u8],
|
|
||||||
src_origin: &[u64],
|
|
||||||
src_shape: &[u64],
|
|
||||||
out: &mut [u8],
|
|
||||||
box_start: &[u64],
|
|
||||||
box_extent: &[u64],
|
|
||||||
elem_size: usize,
|
|
||||||
) {
|
|
||||||
let rank = box_start.len();
|
|
||||||
// Overlap in dataset coordinates.
|
|
||||||
let mut lo = vec![0u64; rank];
|
|
||||||
let mut hi = vec![0u64; rank];
|
|
||||||
for d in 0..rank {
|
|
||||||
lo[d] = src_origin[d].max(box_start[d]);
|
|
||||||
hi[d] = (src_origin[d] + src_shape[d]).min(box_start[d] + box_extent[d]);
|
|
||||||
if lo[d] >= hi[d] {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let strides = |shape: &[u64]| {
|
|
||||||
let mut s = vec![1u64; rank];
|
|
||||||
for d in (0..rank.saturating_sub(1)).rev() {
|
|
||||||
s[d] = s[d + 1] * shape[d + 1];
|
|
||||||
}
|
|
||||||
s
|
|
||||||
};
|
|
||||||
let (src_strides, out_strides) = (strides(src_shape), strides(box_extent));
|
|
||||||
let last = rank - 1;
|
|
||||||
let run = ((hi[last] - lo[last]) as usize) * elem_size;
|
|
||||||
|
|
||||||
let mut idx = lo.clone();
|
|
||||||
loop {
|
|
||||||
let src_at: u64 = (0..rank)
|
|
||||||
.map(|d| (idx[d] - src_origin[d]) * src_strides[d])
|
|
||||||
.sum();
|
|
||||||
let out_at: u64 = (0..rank)
|
|
||||||
.map(|d| (idx[d] - box_start[d]) * out_strides[d])
|
|
||||||
.sum();
|
|
||||||
let (s, o) = (src_at as usize * elem_size, out_at as usize * elem_size);
|
|
||||||
if let (Some(from), Some(to)) = (src.get(s..s + run), out.get_mut(o..o + run)) {
|
|
||||||
to.copy_from_slice(from);
|
|
||||||
}
|
|
||||||
// Advance over every dimension but the last.
|
|
||||||
let mut d = last;
|
|
||||||
loop {
|
|
||||||
if d == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
d -= 1;
|
|
||||||
idx[d] += 1;
|
|
||||||
if idx[d] < hi[d] {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
idx[d] = lo[d];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read `selection` without materialising the whole dataset, when that is
|
|
||||||
/// possible and worthwhile. `Ok(None)` means "use the full-read path": an
|
|
||||||
/// `All`/`None`/invalid selection, a layout this doesn't handle (compact,
|
|
||||||
/// virtual, storage-less), or a bounding box covering most of the dataset.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn read_selection(
|
|
||||||
file_data: &[u8],
|
|
||||||
layout: &DataLayout,
|
|
||||||
dataspace: &Dataspace,
|
|
||||||
elem_size: usize,
|
|
||||||
pipeline: Option<&FilterPipeline>,
|
|
||||||
offset_size: u8,
|
|
||||||
length_size: u8,
|
|
||||||
selection: &Selection,
|
|
||||||
) -> Result<Option<Vec<u8>>, FormatError> {
|
|
||||||
let dims = &dataspace.dimensions;
|
|
||||||
if dims.is_empty() || elem_size == 0 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let Some((box_start, box_extent)) = bounding_box(selection, dims) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let total = dataspace.checked_num_elements()?;
|
|
||||||
let box_elements = box_extent
|
|
||||||
.iter()
|
|
||||||
.try_fold(1u64, |acc, &e| acc.checked_mul(e))
|
|
||||||
.ok_or_else(|| FormatError::Overflow("selection bounding box overflows".into()))?;
|
|
||||||
// A box covering most of the dataset gains nothing over the full path.
|
|
||||||
if box_elements.saturating_mul(2) > total {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let mut boxed = alloc_output(checked_byte_len(box_elements, elem_size)?)?;
|
|
||||||
|
|
||||||
match layout {
|
|
||||||
DataLayout::Contiguous {
|
|
||||||
address: Some(address),
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let base = usize::try_from(*address)
|
|
||||||
.map_err(|_| FormatError::Overflow("data address exceeds usize".into()))?;
|
|
||||||
let data = file_data
|
|
||||||
.get(base..)
|
|
||||||
.and_then(|d| d.get(..checked_byte_len(total, elem_size).ok()?))
|
|
||||||
.ok_or(FormatError::UnexpectedEof {
|
|
||||||
expected: base,
|
|
||||||
available: file_data.len(),
|
|
||||||
})?;
|
|
||||||
let origin = vec![0u64; dims.len()];
|
|
||||||
copy_overlap(
|
|
||||||
data,
|
|
||||||
&origin,
|
|
||||||
dims,
|
|
||||||
&mut boxed,
|
|
||||||
&box_start,
|
|
||||||
&box_extent,
|
|
||||||
elem_size,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
DataLayout::Chunked {
|
|
||||||
btree_address: Some(_),
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let (chunks, chunk_dims) = list_chunks(
|
|
||||||
file_data,
|
|
||||||
layout,
|
|
||||||
dataspace,
|
|
||||||
elem_size,
|
|
||||||
offset_size,
|
|
||||||
length_size,
|
|
||||||
)?;
|
|
||||||
let rank = dims.len();
|
|
||||||
let chunk_shape: Vec<u64> = chunk_dims.iter().map(|&d| d as u64).collect();
|
|
||||||
let chunk_bytes = crate::chunked_read::checked_chunk_byte_len(&chunk_dims, elem_size)?;
|
|
||||||
for chunk in &chunks {
|
|
||||||
if chunk.offsets.len() < rank || chunk.address == u64::MAX {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let origin = &chunk.offsets[..rank];
|
|
||||||
let overlaps = (0..rank).all(|d| {
|
|
||||||
origin[d] < box_start[d] + box_extent[d]
|
|
||||||
&& origin[d].saturating_add(chunk_shape[d]) > box_start[d]
|
|
||||||
});
|
|
||||||
if !overlaps {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let at = usize::try_from(chunk.address)
|
|
||||||
.map_err(|_| FormatError::Overflow("chunk address exceeds usize".into()))?;
|
|
||||||
let raw = at
|
|
||||||
.checked_add(chunk.chunk_size as usize)
|
|
||||||
.and_then(|end| file_data.get(at..end))
|
|
||||||
.ok_or(FormatError::UnexpectedEof {
|
|
||||||
expected: at.saturating_add(chunk.chunk_size as usize),
|
|
||||||
available: file_data.len(),
|
|
||||||
})?;
|
|
||||||
// Mirrors the full-read path: a non-zero filter mask means the
|
|
||||||
// chunk was stored unfiltered.
|
|
||||||
let decoded;
|
|
||||||
let data: &[u8] = match pipeline {
|
|
||||||
Some(pl) if chunk.filter_mask == 0 => {
|
|
||||||
decoded = decompress_chunk(raw, pl, chunk_bytes, elem_size as u32)?;
|
|
||||||
&decoded
|
|
||||||
}
|
|
||||||
_ => raw,
|
|
||||||
};
|
|
||||||
copy_overlap(
|
|
||||||
data,
|
|
||||||
origin,
|
|
||||||
&chunk_shape,
|
|
||||||
&mut boxed,
|
|
||||||
&box_start,
|
|
||||||
&box_extent,
|
|
||||||
elem_size,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => return Ok(None),
|
|
||||||
}
|
|
||||||
|
|
||||||
extract_selection_from_buffer(
|
|
||||||
&boxed,
|
|
||||||
&box_extent,
|
|
||||||
elem_size,
|
|
||||||
&translate(selection, &box_start),
|
|
||||||
)
|
|
||||||
.map(Some)
|
|
||||||
}
|
|
||||||
@@ -509,7 +509,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selection_slice_1d() {
|
fn selection_slice_1d() {
|
||||||
let sel = Selection::slice(std::slice::from_ref(&(5..15)));
|
let sel = Selection::slice(&[5..15]);
|
||||||
assert_eq!(sel.num_elements(&[100]), 10);
|
assert_eq!(sel.num_elements(&[100]), 10);
|
||||||
assert_eq!(sel.output_shape(&[100]), vec![10]);
|
assert_eq!(sel.output_shape(&[100]), vec![10]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,8 @@
|
|||||||
//! - SMLI list structure: simple list of shared message entries
|
//! - SMLI list structure: simple list of shared message entries
|
||||||
//! - B-tree v2 type 7: indexed shared message entries
|
//! - B-tree v2 type 7: indexed shared message entries
|
||||||
|
|
||||||
#[cfg(not(feature = "std"))]
|
|
||||||
use alloc::borrow::Cow;
|
|
||||||
#[cfg(not(feature = "std"))]
|
#[cfg(not(feature = "std"))]
|
||||||
use alloc::vec::Vec;
|
use alloc::vec::Vec;
|
||||||
#[cfg(feature = "std")]
|
|
||||||
use std::borrow::Cow;
|
|
||||||
|
|
||||||
use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records};
|
use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records};
|
||||||
use crate::error::FormatError;
|
use crate::error::FormatError;
|
||||||
@@ -32,14 +28,6 @@ use crate::object_header::ObjectHeader;
|
|||||||
/// Fractal heap ID length for SOHM entries (fixed at 8 bytes).
|
/// Fractal heap ID length for SOHM entries (fixed at 8 bytes).
|
||||||
const FHEAP_ID_LEN: usize = 8;
|
const FHEAP_ID_LEN: usize = 8;
|
||||||
|
|
||||||
/// Shared-message `type` values (version 3 encoding).
|
|
||||||
/// The message is in the file's shared-message (SOHM) fractal heap.
|
|
||||||
const SHARE_TYPE_SOHM: u8 = 1;
|
|
||||||
/// The message is in another object's header (a committed/named datatype).
|
|
||||||
const SHARE_TYPE_COMMITTED: u8 = 2;
|
|
||||||
/// The message is stored here but is sharable.
|
|
||||||
const SHARE_TYPE_HERE: u8 = 3;
|
|
||||||
|
|
||||||
/// A resolved shared message reference.
|
/// A resolved shared message reference.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SharedMessageRef {
|
pub struct SharedMessageRef {
|
||||||
@@ -47,10 +35,9 @@ pub struct SharedMessageRef {
|
|||||||
pub ref_type: u8,
|
pub ref_type: u8,
|
||||||
/// Version of the shared message encoding.
|
/// Version of the shared message encoding.
|
||||||
pub version: u8,
|
pub version: u8,
|
||||||
/// Address of the object header holding the message (committed). Set for
|
/// Address of the object header containing the shared message (type 1, 3).
|
||||||
/// every v1/v2 reference and for v3 types 2 and 3.
|
|
||||||
pub object_header_address: Option<u64>,
|
pub object_header_address: Option<u64>,
|
||||||
/// Fractal heap ID for a v3 SOHM (type 1) reference.
|
/// Fractal heap ID for type 2 (SOHM) references.
|
||||||
pub heap_id: Option<[u8; FHEAP_ID_LEN]>,
|
pub heap_id: Option<[u8; FHEAP_ID_LEN]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,27 +146,35 @@ pub fn parse_shared_ref(data: &[u8], offset_size: u8) -> Result<SharedMessageRef
|
|||||||
let version = data[0];
|
let version = data[0];
|
||||||
let ref_type = data[1];
|
let ref_type = data[1];
|
||||||
|
|
||||||
// Layouts (HDF5 spec IV.A.2 "Shared Message", and libhdf5's decoder):
|
match version {
|
||||||
// v1: version, type, reserved(6), address — always "committed"
|
1 | 2 => {
|
||||||
// v2: version, type, address — always "committed"
|
// v1/v2: reserved(6) + address(offset_size)
|
||||||
// v3: version, type, then a fractal-heap ID if type == SOHM, otherwise
|
let pos = 2 + 6; // skip reserved bytes
|
||||||
// an address
|
|
||||||
// Verified against h5py/HDF5 2.0 output, which writes `02 02 <address>`
|
|
||||||
// for a dataset using a committed datatype under both default and
|
|
||||||
// `latest` libver bounds.
|
|
||||||
let address_at = |pos: usize| -> Result<SharedMessageRef, FormatError> {
|
|
||||||
ensure_len(data, pos, offset_size as usize)?;
|
ensure_len(data, pos, offset_size as usize)?;
|
||||||
|
let addr = read_offset(data, pos, offset_size)?;
|
||||||
Ok(SharedMessageRef {
|
Ok(SharedMessageRef {
|
||||||
ref_type,
|
ref_type,
|
||||||
version,
|
version,
|
||||||
object_header_address: Some(read_offset(data, pos, offset_size)?),
|
object_header_address: Some(addr),
|
||||||
heap_id: None,
|
heap_id: None,
|
||||||
})
|
})
|
||||||
};
|
}
|
||||||
match version {
|
3 => {
|
||||||
1 => address_at(2 + 6),
|
match ref_type {
|
||||||
2 => address_at(2),
|
1 | 3 => {
|
||||||
3 if ref_type == SHARE_TYPE_SOHM => {
|
// type 1/3: message in another object header
|
||||||
|
// v3 layout: version(1) + type(1) + address(offset_size)
|
||||||
|
ensure_len(data, 2, offset_size as usize)?;
|
||||||
|
let addr = read_offset(data, 2, offset_size)?;
|
||||||
|
Ok(SharedMessageRef {
|
||||||
|
ref_type,
|
||||||
|
version,
|
||||||
|
object_header_address: Some(addr),
|
||||||
|
heap_id: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
// type 2: SOHM table (fractal heap ID)
|
||||||
ensure_len(data, 2, FHEAP_ID_LEN)?;
|
ensure_len(data, 2, FHEAP_ID_LEN)?;
|
||||||
let mut id = [0u8; FHEAP_ID_LEN];
|
let mut id = [0u8; FHEAP_ID_LEN];
|
||||||
id.copy_from_slice(&data[2..2 + FHEAP_ID_LEN]);
|
id.copy_from_slice(&data[2..2 + FHEAP_ID_LEN]);
|
||||||
@@ -190,8 +185,9 @@ pub fn parse_shared_ref(data: &[u8], offset_size: u8) -> Result<SharedMessageRef
|
|||||||
heap_id: Some(id),
|
heap_id: Some(id),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
3 if ref_type == SHARE_TYPE_COMMITTED || ref_type == SHARE_TYPE_HERE => address_at(2),
|
_ => Err(FormatError::InvalidSharedMessageVersion(ref_type)),
|
||||||
3 => Err(FormatError::InvalidSharedMessageVersion(ref_type)),
|
}
|
||||||
|
}
|
||||||
_ => Err(FormatError::InvalidSharedMessageVersion(version)),
|
_ => Err(FormatError::InvalidSharedMessageVersion(version)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -426,35 +422,6 @@ pub fn resolve_sohm_message(
|
|||||||
fh_header.read_managed_object(file_data, heap_id, offset_size)
|
fh_header.read_managed_object(file_data, heap_id, offset_size)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The payload of an object-header message, following the indirection if the
|
|
||||||
/// message is *shared* (header flag bit 1).
|
|
||||||
///
|
|
||||||
/// A shared message's bytes are not the message itself but a reference to
|
|
||||||
/// where it lives — e.g. a dataset created with a committed (named) datatype
|
|
||||||
/// stores only a pointer to that datatype's object header. Every reader of a
|
|
||||||
/// message that may be shared (datatype, dataspace, fill value, filter
|
|
||||||
/// pipeline, attribute) must go through this; parsing the reference bytes as
|
|
||||||
/// the message yields garbage rather than an error.
|
|
||||||
pub fn message_data<'a>(
|
|
||||||
file_data: &[u8],
|
|
||||||
msg: &'a crate::object_header::HeaderMessage,
|
|
||||||
offset_size: u8,
|
|
||||||
length_size: u8,
|
|
||||||
) -> Result<Cow<'a, [u8]>, FormatError> {
|
|
||||||
if !is_shared(msg.flags) {
|
|
||||||
return Ok(Cow::Borrowed(&msg.data));
|
|
||||||
}
|
|
||||||
let shared_ref = parse_shared_ref(&msg.data, offset_size)?;
|
|
||||||
resolve_shared_message(
|
|
||||||
file_data,
|
|
||||||
&shared_ref,
|
|
||||||
msg.msg_type,
|
|
||||||
offset_size,
|
|
||||||
length_size,
|
|
||||||
)
|
|
||||||
.map(Cow::Owned)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a shared message to its actual message data.
|
/// Resolve a shared message to its actual message data.
|
||||||
///
|
///
|
||||||
/// For type 1/3 (shared in another object header), reads the target object header
|
/// For type 1/3 (shared in another object header), reads the target object header
|
||||||
@@ -486,14 +453,14 @@ pub fn resolve_shared_message_with_sohm(
|
|||||||
length_size: u8,
|
length_size: u8,
|
||||||
sohm_table: Option<&SohmTable>,
|
sohm_table: Option<&SohmTable>,
|
||||||
) -> Result<Vec<u8>, FormatError> {
|
) -> Result<Vec<u8>, FormatError> {
|
||||||
// Dispatch on what the reference carries rather than on `ref_type`: v1/v2
|
match shared_ref.ref_type {
|
||||||
// references are always an object-header address whatever their type
|
1 | 3 => {
|
||||||
// byte says.
|
let addr = shared_ref
|
||||||
match (
|
.object_header_address
|
||||||
shared_ref.object_header_address,
|
.ok_or(FormatError::UnexpectedEof {
|
||||||
shared_ref.heap_id.as_ref(),
|
expected: 1,
|
||||||
) {
|
available: 0,
|
||||||
(Some(addr), _) => {
|
})?;
|
||||||
let target_header =
|
let target_header =
|
||||||
ObjectHeader::parse(file_data, addr as usize, offset_size, length_size)?;
|
ObjectHeader::parse(file_data, addr as usize, offset_size, length_size)?;
|
||||||
for msg in &target_header.messages {
|
for msg in &target_header.messages {
|
||||||
@@ -520,7 +487,11 @@ pub fn resolve_shared_message_with_sohm(
|
|||||||
available: 0,
|
available: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
(None, Some(heap_id)) => {
|
2 => {
|
||||||
|
let heap_id = shared_ref
|
||||||
|
.heap_id
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(FormatError::InvalidSharedMessageVersion(2))?;
|
||||||
let table = sohm_table.ok_or(FormatError::InvalidSharedMessageVersion(2))?;
|
let table = sohm_table.ok_or(FormatError::InvalidSharedMessageVersion(2))?;
|
||||||
resolve_sohm_message(
|
resolve_sohm_message(
|
||||||
file_data,
|
file_data,
|
||||||
@@ -531,7 +502,7 @@ pub fn resolve_shared_message_with_sohm(
|
|||||||
length_size,
|
length_size,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
(None, None) => Err(FormatError::InvalidSharedMessageVersion(
|
_ => Err(FormatError::InvalidSharedMessageVersion(
|
||||||
shared_ref.ref_type,
|
shared_ref.ref_type,
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
@@ -551,15 +522,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_v3_committed_ref() {
|
fn parse_v3_type1_ref() {
|
||||||
let mut data = Vec::new();
|
let mut data = Vec::new();
|
||||||
data.push(3); // version
|
data.push(3); // version
|
||||||
data.push(SHARE_TYPE_COMMITTED); // message lives in another object header
|
data.push(1); // type 1 = shared in another OH
|
||||||
data.extend_from_slice(&0x1234u64.to_le_bytes()); // address
|
data.extend_from_slice(&0x1234u64.to_le_bytes()); // address
|
||||||
|
|
||||||
let shared = parse_shared_ref(&data, 8).unwrap();
|
let shared = parse_shared_ref(&data, 8).unwrap();
|
||||||
assert_eq!(shared.version, 3);
|
assert_eq!(shared.version, 3);
|
||||||
assert_eq!(shared.ref_type, SHARE_TYPE_COMMITTED);
|
assert_eq!(shared.ref_type, 1);
|
||||||
assert_eq!(shared.object_header_address, Some(0x1234));
|
assert_eq!(shared.object_header_address, Some(0x1234));
|
||||||
assert!(shared.heap_id.is_none());
|
assert!(shared.heap_id.is_none());
|
||||||
}
|
}
|
||||||
@@ -568,7 +539,7 @@ mod tests {
|
|||||||
fn parse_v3_type3_ref() {
|
fn parse_v3_type3_ref() {
|
||||||
let mut data = Vec::new();
|
let mut data = Vec::new();
|
||||||
data.push(3); // version
|
data.push(3); // version
|
||||||
data.push(SHARE_TYPE_HERE); // stored here but sharable: an address
|
data.push(3); // type 3 = shared in another OH (v3 encoding)
|
||||||
data.extend_from_slice(&0xABCDu64.to_le_bytes());
|
data.extend_from_slice(&0xABCDu64.to_le_bytes());
|
||||||
|
|
||||||
let shared = parse_shared_ref(&data, 8).unwrap();
|
let shared = parse_shared_ref(&data, 8).unwrap();
|
||||||
@@ -592,10 +563,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_v2_ref() {
|
fn parse_v2_ref() {
|
||||||
// v2 dropped v1's six reserved bytes: the address follows the type.
|
|
||||||
let mut data = Vec::new();
|
let mut data = Vec::new();
|
||||||
data.push(2); // version
|
data.push(2); // version
|
||||||
data.push(SHARE_TYPE_COMMITTED);
|
data.push(0); // type
|
||||||
|
data.extend_from_slice(&[0u8; 6]); // reserved
|
||||||
data.extend_from_slice(&0x9000u32.to_le_bytes());
|
data.extend_from_slice(&0x9000u32.to_le_bytes());
|
||||||
|
|
||||||
let shared = parse_shared_ref(&data, 4).unwrap();
|
let shared = parse_shared_ref(&data, 4).unwrap();
|
||||||
@@ -604,26 +575,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_v2_ref_from_hdf5_2_0() {
|
fn parse_v3_type2_sohm() {
|
||||||
// Datatype message of a dataset created with a committed datatype,
|
|
||||||
// as written by h5py 3.16 / HDF5 2.0 (libver='latest'): header flags
|
|
||||||
// 0x03 (shared), payload `02 02 <8-byte object header address>`.
|
|
||||||
let data = [0x02, 0x02, 0xb3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
|
||||||
let shared = parse_shared_ref(&data, 8).unwrap();
|
|
||||||
assert_eq!(shared.object_header_address, Some(0xb3));
|
|
||||||
assert!(shared.heap_id.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_v3_sohm_ref() {
|
|
||||||
let mut data = Vec::new();
|
let mut data = Vec::new();
|
||||||
data.push(3); // version
|
data.push(3); // version
|
||||||
data.push(SHARE_TYPE_SOHM); // message lives in the SOHM fractal heap
|
data.push(2); // type 2 = SOHM heap
|
||||||
data.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44]);
|
data.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44]);
|
||||||
|
|
||||||
let shared = parse_shared_ref(&data, 8).unwrap();
|
let shared = parse_shared_ref(&data, 8).unwrap();
|
||||||
assert_eq!(shared.version, 3);
|
assert_eq!(shared.version, 3);
|
||||||
assert_eq!(shared.ref_type, SHARE_TYPE_SOHM);
|
assert_eq!(shared.ref_type, 2);
|
||||||
assert_eq!(shared.object_header_address, None);
|
assert_eq!(shared.object_header_address, None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
shared.heap_id,
|
shared.heap_id,
|
||||||
@@ -632,10 +592,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_v3_sohm_too_short() {
|
fn parse_v3_type2_too_short() {
|
||||||
let mut data = Vec::new();
|
let mut data = Vec::new();
|
||||||
data.push(3); // version
|
data.push(3); // version
|
||||||
data.push(SHARE_TYPE_SOHM);
|
data.push(2); // type 2 = SOHM heap
|
||||||
data.extend_from_slice(&[0xAA, 0xBB]); // only 2 bytes, need 8
|
data.extend_from_slice(&[0xAA, 0xBB]); // only 2 bytes, need 8
|
||||||
|
|
||||||
let err = parse_shared_ref(&data, 8).unwrap_err();
|
let err = parse_shared_ref(&data, 8).unwrap_err();
|
||||||
@@ -660,7 +620,7 @@ mod tests {
|
|||||||
fn parse_four_byte_offsets() {
|
fn parse_four_byte_offsets() {
|
||||||
let mut data = Vec::new();
|
let mut data = Vec::new();
|
||||||
data.push(3); // version
|
data.push(3); // version
|
||||||
data.push(SHARE_TYPE_COMMITTED);
|
data.push(1); // type 1
|
||||||
data.extend_from_slice(&0x1000u32.to_le_bytes());
|
data.extend_from_slice(&0x1000u32.to_le_bytes());
|
||||||
|
|
||||||
let shared = parse_shared_ref(&data, 4).unwrap();
|
let shared = parse_shared_ref(&data, 4).unwrap();
|
||||||
|
|||||||
@@ -80,12 +80,9 @@ impl SymbolTableNode {
|
|||||||
offset_size: u8,
|
offset_size: u8,
|
||||||
) -> Result<SymbolTableNode, FormatError> {
|
) -> Result<SymbolTableNode, FormatError> {
|
||||||
// signature(4) + version(1) + reserved(1) + number_of_symbols(2) = 8
|
// signature(4) + version(1) + reserved(1) + number_of_symbols(2) = 8
|
||||||
if offset
|
if offset + 8 > file_data.len() {
|
||||||
.checked_add(8)
|
|
||||||
.is_none_or(|end| end > file_data.len())
|
|
||||||
{
|
|
||||||
return Err(FormatError::UnexpectedEof {
|
return Err(FormatError::UnexpectedEof {
|
||||||
expected: offset.saturating_add(8),
|
expected: offset + 8,
|
||||||
available: file_data.len(),
|
available: file_data.len(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -106,12 +103,7 @@ impl SymbolTableNode {
|
|||||||
// Each entry: link_name_offset(os) + obj_hdr_addr(os) + cache_type(4) + reserved(4) + scratch(16)
|
// Each entry: link_name_offset(os) + obj_hdr_addr(os) + cache_type(4) + reserved(4) + scratch(16)
|
||||||
let entry_size = os + os + 4 + 4 + 16;
|
let entry_size = os + os + 4 + 4 + 16;
|
||||||
let entries_start = offset + 8;
|
let entries_start = offset + 8;
|
||||||
let needed = entries_start.checked_add(num_symbols * entry_size).ok_or(
|
let needed = entries_start + num_symbols * entry_size;
|
||||||
FormatError::UnexpectedEof {
|
|
||||||
expected: usize::MAX,
|
|
||||||
available: file_data.len(),
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
if needed > file_data.len() {
|
if needed > file_data.len() {
|
||||||
return Err(FormatError::UnexpectedEof {
|
return Err(FormatError::UnexpectedEof {
|
||||||
expected: needed,
|
expected: needed,
|
||||||
@@ -236,24 +228,4 @@ mod tests {
|
|||||||
let err = SymbolTableNode::parse(&data, 0, 8).unwrap_err();
|
let err = SymbolTableNode::parse(&data, 0, 8).unwrap_err();
|
||||||
assert_eq!(err, FormatError::InvalidSymbolTableNodeVersion(2));
|
assert_eq!(err, FormatError::InvalidSymbolTableNodeVersion(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A near-`usize::MAX` SNOD offset must error cleanly, not overflow/panic.
|
|
||||||
#[test]
|
|
||||||
fn parse_snod_rejects_offset_overflow() {
|
|
||||||
let data = build_snod(&[], 8);
|
|
||||||
let result = SymbolTableNode::parse(&data, usize::MAX - 4, 8);
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A huge symbol count combined with a large entries_start must not
|
|
||||||
/// overflow the `needed` size computation.
|
|
||||||
#[test]
|
|
||||||
fn parse_snod_rejects_entries_size_overflow() {
|
|
||||||
let mut data = build_snod(&[], 8);
|
|
||||||
// num_symbols at offset 6..8 — set to max to blow up entries_start + num_symbols*entry_size
|
|
||||||
data[6] = 0xFF;
|
|
||||||
data[7] = 0xFF;
|
|
||||||
let result = SymbolTableNode::parse(&data, usize::MAX / 2, 8);
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,43 +279,6 @@ pub(crate) fn build_attr_message(name: &str, value: &AttrValue) -> AttributeMess
|
|||||||
dataspace: scalar_ds(),
|
dataspace: scalar_ds(),
|
||||||
raw_data: v.to_le_bytes().to_vec(),
|
raw_data: v.to_le_bytes().to_vec(),
|
||||||
},
|
},
|
||||||
AttrValue::U64Array(arr) => {
|
|
||||||
let mut raw = Vec::with_capacity(arr.len() * 8);
|
|
||||||
for v in arr {
|
|
||||||
raw.extend_from_slice(&v.to_le_bytes());
|
|
||||||
}
|
|
||||||
AttributeMessage {
|
|
||||||
name: name.to_string(),
|
|
||||||
datatype: Datatype::FixedPoint {
|
|
||||||
size: 8,
|
|
||||||
byte_order: DatatypeByteOrder::LittleEndian,
|
|
||||||
signed: false,
|
|
||||||
bit_offset: 0,
|
|
||||||
bit_precision: 64,
|
|
||||||
},
|
|
||||||
dataspace: simple_1d(arr.len() as u64),
|
|
||||||
raw_data: raw,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AttrValue::Raw {
|
|
||||||
datatype,
|
|
||||||
shape,
|
|
||||||
data,
|
|
||||||
} => AttributeMessage {
|
|
||||||
name: name.to_string(),
|
|
||||||
datatype: datatype.clone(),
|
|
||||||
dataspace: if shape.is_empty() {
|
|
||||||
scalar_ds()
|
|
||||||
} else {
|
|
||||||
Dataspace {
|
|
||||||
space_type: DataspaceType::Simple,
|
|
||||||
rank: shape.len() as u8,
|
|
||||||
dimensions: shape.clone(),
|
|
||||||
max_dimensions: None,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
raw_data: data.clone(),
|
|
||||||
},
|
|
||||||
AttrValue::String(s) => {
|
AttrValue::String(s) => {
|
||||||
let bytes = s.as_bytes();
|
let bytes = s.as_bytes();
|
||||||
AttributeMessage {
|
AttributeMessage {
|
||||||
@@ -371,7 +334,7 @@ pub(crate) fn simple_1d(n: u64) -> Dataspace {
|
|||||||
|
|
||||||
// ---- Attribute values ----
|
// ---- Attribute values ----
|
||||||
|
|
||||||
/// Attribute values, for both the write API and what reading returns.
|
/// Convenient attribute values for the write API.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AttrValue {
|
pub enum AttrValue {
|
||||||
F64(f64),
|
F64(f64),
|
||||||
@@ -379,21 +342,8 @@ pub enum AttrValue {
|
|||||||
I64(i64),
|
I64(i64),
|
||||||
I64Array(Vec<i64>),
|
I64Array(Vec<i64>),
|
||||||
U64(u64),
|
U64(u64),
|
||||||
/// Unsigned integers, kept unsigned so values above `i64::MAX` survive.
|
|
||||||
U64Array(Vec<u64>),
|
|
||||||
String(String),
|
String(String),
|
||||||
StringArray(Vec<String>),
|
StringArray(Vec<String>),
|
||||||
/// An attribute whose datatype has no dedicated variant above (compound,
|
|
||||||
/// general enum, complex, reference, opaque, array, ...), carried verbatim
|
|
||||||
/// so it is never silently lost: the datatype, the dataspace dimensions
|
|
||||||
/// (empty for a scalar) and the element bytes exactly as stored. Decode
|
|
||||||
/// `data` with `clawhdf5_format::data_read` (e.g. `read_compound_fields`)
|
|
||||||
/// against `datatype`. Writing a `Raw` value stores it back unchanged.
|
|
||||||
Raw {
|
|
||||||
datatype: Datatype,
|
|
||||||
shape: Vec<u64>,
|
|
||||||
data: Vec<u8>,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Dataset builder ----
|
// ---- Dataset builder ----
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
"""Generate std_ref_hdf5_2_0.h5: a dataset of H5T_STD_REF (the reference
|
|
||||||
datatype introduced in HDF5 1.12, datatype message version 4) holding two
|
|
||||||
object references — to /target (a dataset) and /grp (a group).
|
|
||||||
|
|
||||||
h5py has no API for this type, so the file is written by calling the libhdf5
|
|
||||||
bundled in the h5py wheel directly through ctypes. Written with h5py 3.16.0 /
|
|
||||||
HDF5 2.0.0. Re-run only if the fixture ever needs regenerating:
|
|
||||||
|
|
||||||
python gen_std_ref.py std_ref_hdf5_2_0.h5
|
|
||||||
"""
|
|
||||||
import ctypes
|
|
||||||
import glob
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import h5py
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
libdir = os.path.join(os.path.dirname(os.path.dirname(h5py.__file__)), "h5py.libs")
|
|
||||||
libs = [p for p in glob.glob(os.path.join(libdir, "libhdf5*.so*")) if "_hl" not in os.path.basename(p)]
|
|
||||||
lib = ctypes.CDLL(libs[0])
|
|
||||||
lib.H5open()
|
|
||||||
hid = ctypes.c_int64
|
|
||||||
std_ref = hid.in_dll(lib, "H5T_STD_REF_g").value
|
|
||||||
|
|
||||||
lib.H5Screate_simple.restype = hid
|
|
||||||
lib.H5Screate_simple.argtypes = [ctypes.c_int, ctypes.POINTER(ctypes.c_uint64), ctypes.POINTER(ctypes.c_uint64)]
|
|
||||||
lib.H5Dcreate2.restype = hid
|
|
||||||
lib.H5Dcreate2.argtypes = [hid, ctypes.c_char_p, hid, hid, hid, hid, hid]
|
|
||||||
lib.H5Rcreate_object.argtypes = [hid, ctypes.c_char_p, hid, ctypes.c_void_p]
|
|
||||||
lib.H5Dwrite.argtypes = [hid, hid, hid, hid, hid, ctypes.c_void_p]
|
|
||||||
lib.H5Dclose.argtypes = [hid]
|
|
||||||
|
|
||||||
with h5py.File(sys.argv[1], "w", libver="latest") as f:
|
|
||||||
f.create_dataset("target", data=np.arange(5, dtype="<i4"))
|
|
||||||
f.create_group("grp")
|
|
||||||
fid = f.id.id
|
|
||||||
sid = lib.H5Screate_simple(1, (ctypes.c_uint64 * 1)(2), None)
|
|
||||||
did = lib.H5Dcreate2(fid, b"refs", std_ref, sid, 0, 0, 0)
|
|
||||||
refs = ((ctypes.c_ubyte * 64) * 2)() # H5R_ref_t is a 64-byte buffer
|
|
||||||
assert lib.H5Rcreate_object(fid, b"/target", 0, ctypes.byref(refs[0])) == 0
|
|
||||||
assert lib.H5Rcreate_object(fid, b"/grp", 0, ctypes.byref(refs[1])) == 0
|
|
||||||
assert lib.H5Dwrite(did, std_ref, 0, 0, 0, ctypes.byref(refs)) == 0
|
|
||||||
lib.H5Dclose(did)
|
|
||||||
Binary file not shown.
@@ -343,11 +343,7 @@ fn attrs_h5_dataset_scale() {
|
|||||||
let scale_attr = find_attribute(&attrs, "scale").expect("scale attr not found");
|
let scale_attr = find_attribute(&attrs, "scale").expect("scale attr not found");
|
||||||
let vals = scale_attr.read_as_f64().unwrap();
|
let vals = scale_attr.read_as_f64().unwrap();
|
||||||
assert_eq!(vals.len(), 1);
|
assert_eq!(vals.len(), 1);
|
||||||
// 3.14 here is the literal value baked into the binary fixture (fixtures/attrs.h5),
|
assert!((vals[0] - 3.14).abs() < 1e-10);
|
||||||
// not an arbitrary sample value, so it cannot be swapped for another constant.
|
|
||||||
#[allow(clippy::approx_constant)]
|
|
||||||
let expected = 3.14;
|
|
||||||
assert!((vals[0] - expected).abs() < 1e-10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -560,8 +556,8 @@ fn chunked_deflate_read_values() {
|
|||||||
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
|
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
|
||||||
let values = read_as_f64(&raw, &datatype).unwrap();
|
let values = read_as_f64(&raw, &datatype).unwrap();
|
||||||
assert_eq!(values.len(), 100);
|
assert_eq!(values.len(), 100);
|
||||||
for (i, &v) in values.iter().enumerate() {
|
for i in 0..100 {
|
||||||
assert_eq!(v, i as f64, "mismatch at index {i}");
|
assert_eq!(values[i], i as f64, "mismatch at index {i}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,8 +567,8 @@ fn chunked_shuffle_deflate_read_values() {
|
|||||||
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
|
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
|
||||||
let values = read_as_f64(&raw, &datatype).unwrap();
|
let values = read_as_f64(&raw, &datatype).unwrap();
|
||||||
assert_eq!(values.len(), 100);
|
assert_eq!(values.len(), 100);
|
||||||
for (i, &v) in values.iter().enumerate() {
|
for i in 0..100 {
|
||||||
assert_eq!(v, i as f64, "mismatch at index {i}");
|
assert_eq!(values[i], i as f64, "mismatch at index {i}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,8 +578,8 @@ fn chunked_fletcher32_read_values() {
|
|||||||
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
|
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
|
||||||
let values = read_as_f64(&raw, &datatype).unwrap();
|
let values = read_as_f64(&raw, &datatype).unwrap();
|
||||||
assert_eq!(values.len(), 100);
|
assert_eq!(values.len(), 100);
|
||||||
for (i, &v) in values.iter().enumerate() {
|
for i in 0..100 {
|
||||||
assert_eq!(v, i as f64, "mismatch at index {i}");
|
assert_eq!(values[i], i as f64, "mismatch at index {i}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,10 +589,11 @@ fn chunked_2d_read_values() {
|
|||||||
let (raw, datatype, _) = read_chunked_dataset(file_data, "matrix");
|
let (raw, datatype, _) = read_chunked_dataset(file_data, "matrix");
|
||||||
let values = read_as_f32(&raw, &datatype).unwrap();
|
let values = read_as_f32(&raw, &datatype).unwrap();
|
||||||
assert_eq!(values.len(), 60);
|
assert_eq!(values.len(), 60);
|
||||||
for (i, &v) in values.iter().enumerate() {
|
for i in 0..60 {
|
||||||
assert!(
|
assert!(
|
||||||
(v - i as f32).abs() < 1e-6,
|
(values[i] - i as f32).abs() < 1e-6,
|
||||||
"mismatch at index {i}: got {v}"
|
"mismatch at index {i}: got {}",
|
||||||
|
values[i]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -607,8 +604,8 @@ fn chunked_large_read_values() {
|
|||||||
let (raw, datatype, _) = read_chunked_dataset(file_data, "big");
|
let (raw, datatype, _) = read_chunked_dataset(file_data, "big");
|
||||||
let values = read_as_i32(&raw, &datatype).unwrap();
|
let values = read_as_i32(&raw, &datatype).unwrap();
|
||||||
assert_eq!(values.len(), 1000);
|
assert_eq!(values.len(), 1000);
|
||||||
for (i, &v) in values.iter().enumerate() {
|
for i in 0..1000 {
|
||||||
assert_eq!(v, i as i32, "mismatch at index {i}");
|
assert_eq!(values[i], i as i32, "mismatch at index {i}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,8 +615,8 @@ fn chunked_nofilter_read_values() {
|
|||||||
let (raw, datatype, _) = read_chunked_dataset(file_data, "raw");
|
let (raw, datatype, _) = read_chunked_dataset(file_data, "raw");
|
||||||
let values = read_as_f64(&raw, &datatype).unwrap();
|
let values = read_as_f64(&raw, &datatype).unwrap();
|
||||||
assert_eq!(values.len(), 50);
|
assert_eq!(values.len(), 50);
|
||||||
for (i, &v) in values.iter().enumerate() {
|
for i in 0..50 {
|
||||||
assert_eq!(v, i as f64, "mismatch at index {i}");
|
assert_eq!(values[i], i as f64, "mismatch at index {i}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,8 +646,8 @@ fn v4_implicit_read() {
|
|||||||
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
|
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
|
||||||
let values = read_as_f64(&raw, &datatype).unwrap();
|
let values = read_as_f64(&raw, &datatype).unwrap();
|
||||||
assert_eq!(values.len(), 100);
|
assert_eq!(values.len(), 100);
|
||||||
for (i, &v) in values.iter().enumerate() {
|
for i in 0..100 {
|
||||||
assert_eq!(v, i as f64, "mismatch at index {i}");
|
assert_eq!(values[i], i as f64, "mismatch at index {i}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,8 +657,8 @@ fn v4_fixed_array_read() {
|
|||||||
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
|
let (raw, datatype, _) = read_chunked_dataset(file_data, "data");
|
||||||
let values = read_as_f64(&raw, &datatype).unwrap();
|
let values = read_as_f64(&raw, &datatype).unwrap();
|
||||||
assert_eq!(values.len(), 100);
|
assert_eq!(values.len(), 100);
|
||||||
for (i, &v) in values.iter().enumerate() {
|
for i in 0..100 {
|
||||||
assert_eq!(v, i as f64, "mismatch at index {i}");
|
assert_eq!(values[i], i as f64, "mismatch at index {i}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -874,10 +871,11 @@ fn v4_2d_fixed_array_read() {
|
|||||||
let (raw, datatype, _) = read_chunked_dataset(file_data, "matrix");
|
let (raw, datatype, _) = read_chunked_dataset(file_data, "matrix");
|
||||||
let values = read_as_f32(&raw, &datatype).unwrap();
|
let values = read_as_f32(&raw, &datatype).unwrap();
|
||||||
assert_eq!(values.len(), 60);
|
assert_eq!(values.len(), 60);
|
||||||
for (i, &v) in values.iter().enumerate() {
|
for i in 0..60 {
|
||||||
assert!(
|
assert!(
|
||||||
(v - i as f32).abs() < 1e-6,
|
(values[i] - i as f32).abs() < 1e-6,
|
||||||
"mismatch at index {i}: got {v}"
|
"mismatch at index {i}: got {}",
|
||||||
|
values[i]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1274,7 +1272,7 @@ fn write_roundtrip_scalar_f64_attr() {
|
|||||||
let mut fw = FileWriter::new();
|
let mut fw = FileWriter::new();
|
||||||
fw.create_dataset("data")
|
fw.create_dataset("data")
|
||||||
.with_f64_data(&[1.0])
|
.with_f64_data(&[1.0])
|
||||||
.set_attr("scale", AttrValue::F64(3.25));
|
.set_attr("scale", AttrValue::F64(3.14));
|
||||||
let bytes = fw.finish().unwrap();
|
let bytes = fw.finish().unwrap();
|
||||||
|
|
||||||
let sig = find_signature(&bytes).unwrap();
|
let sig = find_signature(&bytes).unwrap();
|
||||||
@@ -1285,7 +1283,7 @@ fn write_roundtrip_scalar_f64_attr() {
|
|||||||
let scale = find_attribute(&attrs, "scale").expect("scale attr not found");
|
let scale = find_attribute(&attrs, "scale").expect("scale attr not found");
|
||||||
let vals = scale.read_as_f64().unwrap();
|
let vals = scale.read_as_f64().unwrap();
|
||||||
assert_eq!(vals.len(), 1);
|
assert_eq!(vals.len(), 1);
|
||||||
assert!((vals[0] - 3.25).abs() < 1e-10);
|
assert!((vals[0] - 3.14).abs() < 1e-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2,15 +2,6 @@
|
|||||||
|
|
||||||
use clawhdf5_format::data_read::{read_object_references, read_region_references};
|
use clawhdf5_format::data_read::{read_object_references, read_region_references};
|
||||||
use clawhdf5_format::datatype::{Datatype, ReferenceType};
|
use clawhdf5_format::datatype::{Datatype, ReferenceType};
|
||||||
/// The Python interpreter to drive interop checks with.
|
|
||||||
///
|
|
||||||
/// `CLAWHDF5_PYTHON` lets these run against a virtualenv holding h5py, which
|
|
||||||
/// on a PEP 668 "externally managed" system is the only place it can be
|
|
||||||
/// installed. Without it the suite silently skips, and a silent skip here is
|
|
||||||
/// how a datatype bug once reached a release.
|
|
||||||
fn python() -> String {
|
|
||||||
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn object_ref_single_valid() {
|
fn object_ref_single_valid() {
|
||||||
@@ -182,27 +173,22 @@ print('ok')
|
|||||||
"#,
|
"#,
|
||||||
path.display()
|
path.display()
|
||||||
);
|
);
|
||||||
let output = std::process::Command::new(python())
|
let output = std::process::Command::new("python3")
|
||||||
.args(["-c", &script])
|
.args(["-c", &script])
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
let output = match output {
|
let output = match output {
|
||||||
Ok(o) if o.status.success() => o,
|
Ok(o) if o.status.success() => o,
|
||||||
_ => {
|
_ => {
|
||||||
// CI sets CLAWHDF5_REQUIRE_INTEROP=1 so this can't silently skip.
|
|
||||||
assert!(
|
|
||||||
!std::env::var("CLAWHDF5_REQUIRE_INTEROP").is_ok_and(|v| v == "1"),
|
|
||||||
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
|
|
||||||
);
|
|
||||||
eprintln!("skipping h5py_object_reference_roundtrip: python3+h5py not available");
|
eprintln!("skipping h5py_object_reference_roundtrip: python3+h5py not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||||
assert!(
|
if !stdout.trim().contains("ok") {
|
||||||
stdout.trim().contains("ok"),
|
eprintln!("skipping h5py_object_reference_roundtrip: h5py script failed");
|
||||||
"h5py reference-file generator did not report ok: {stdout}"
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
// Read the file and parse object references
|
// Read the file and parse object references
|
||||||
let file_data = std::fs::read(&path).unwrap();
|
let file_data = std::fs::read(&path).unwrap();
|
||||||
@@ -325,97 +311,3 @@ print('ok')
|
|||||||
// Clean up
|
// Clean up
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// H5T_STD_REF (HDF5 1.12+ references, datatype message version 4)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `fixtures/std_ref_hdf5_2_0.h5` (see `gen_std_ref.py`) holds a dataset of
|
|
||||||
/// `H5T_STD_REF` with two object references, written by HDF5 2.0 itself. The
|
|
||||||
/// datatype used to be rejected with `InvalidReferenceType(2)`.
|
|
||||||
#[test]
|
|
||||||
fn std_ref_object_references_from_hdf5_2_0() {
|
|
||||||
use clawhdf5_format::data_layout::DataLayout;
|
|
||||||
use clawhdf5_format::dataspace::Dataspace;
|
|
||||||
use clawhdf5_format::group_v2::resolve_path_any;
|
|
||||||
use clawhdf5_format::message_type::MessageType;
|
|
||||||
use clawhdf5_format::object_header::ObjectHeader;
|
|
||||||
use clawhdf5_format::signature::find_signature;
|
|
||||||
use clawhdf5_format::superblock::Superblock;
|
|
||||||
|
|
||||||
let bytes: &[u8] = include_bytes!("fixtures/std_ref_hdf5_2_0.h5");
|
|
||||||
let sb = Superblock::parse(bytes, find_signature(bytes).unwrap()).unwrap();
|
|
||||||
let (os, ls) = (sb.offset_size, sb.length_size);
|
|
||||||
|
|
||||||
let refs_addr = resolve_path_any(bytes, &sb, "refs").unwrap();
|
|
||||||
let header = ObjectHeader::parse(bytes, refs_addr as usize, os, ls).unwrap();
|
|
||||||
let message = |t: MessageType| {
|
|
||||||
&header
|
|
||||||
.messages
|
|
||||||
.iter()
|
|
||||||
.find(|m| m.msg_type == t)
|
|
||||||
.unwrap()
|
|
||||||
.data
|
|
||||||
};
|
|
||||||
|
|
||||||
let (datatype, _) = Datatype::parse(message(MessageType::Datatype)).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
datatype,
|
|
||||||
Datatype::Reference {
|
|
||||||
size: 18,
|
|
||||||
ref_type: ReferenceType::Object2
|
|
||||||
}
|
|
||||||
);
|
|
||||||
let dataspace = Dataspace::parse(message(MessageType::Dataspace), ls).unwrap();
|
|
||||||
let layout = DataLayout::parse(message(MessageType::DataLayout), os, ls).unwrap();
|
|
||||||
let raw =
|
|
||||||
clawhdf5_format::data_read::read_raw_data(bytes, &layout, &dataspace, &datatype).unwrap();
|
|
||||||
assert_eq!(raw.len(), 2 * 18);
|
|
||||||
|
|
||||||
// The references point at the objects they were created from.
|
|
||||||
let refs = read_object_references(&raw, &datatype, os).unwrap();
|
|
||||||
let addresses: Vec<u64> = refs.iter().map(|r| r.address).collect();
|
|
||||||
assert_eq!(
|
|
||||||
addresses,
|
|
||||||
[
|
|
||||||
resolve_path_any(bytes, &sb, "target").unwrap(),
|
|
||||||
resolve_path_any(bytes, &sb, "grp").unwrap(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
// And what they point at is a real object header.
|
|
||||||
for address in addresses {
|
|
||||||
ObjectHeader::parse(bytes, address as usize, os, ls).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn std_ref_decoding_rejects_malformed_elements() {
|
|
||||||
let dt = Datatype::Reference {
|
|
||||||
size: 18,
|
|
||||||
ref_type: ReferenceType::Object2,
|
|
||||||
};
|
|
||||||
let mut good = vec![0u8; 18];
|
|
||||||
good[..4].copy_from_slice(&[2, 0, 8, 0xb3]);
|
|
||||||
assert_eq!(
|
|
||||||
read_object_references(&good, &dt, 8).unwrap()[0].address,
|
|
||||||
0xb3
|
|
||||||
);
|
|
||||||
|
|
||||||
// Null reference.
|
|
||||||
assert_eq!(
|
|
||||||
read_object_references(&[0u8; 18], &dt, 8).unwrap()[0].address,
|
|
||||||
u64::MAX
|
|
||||||
);
|
|
||||||
for (what, patch) in [
|
|
||||||
("wrong reference type", (0usize, 3u8)),
|
|
||||||
("external flag", (1, 1)),
|
|
||||||
("token longer than the element", (2, 200)),
|
|
||||||
("zero-length token", (2, 0)),
|
|
||||||
] {
|
|
||||||
let mut bad = good.clone();
|
|
||||||
bad[patch.0] = patch.1;
|
|
||||||
assert!(read_object_references(&bad, &dt, 8).is_err(), "{what}");
|
|
||||||
}
|
|
||||||
// Not a whole number of elements.
|
|
||||||
assert!(read_object_references(&good[..17], &dt, 8).is_err());
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,18 +4,9 @@
|
|||||||
//! (and vice versa). They require python3 + h5py to be installed.
|
//! (and vice versa). They require python3 + h5py to be installed.
|
||||||
|
|
||||||
use clawhdf5_format::file_writer::{AttrValue, CompoundTypeBuilder, EnumTypeBuilder, FileWriter};
|
use clawhdf5_format::file_writer::{AttrValue, CompoundTypeBuilder, EnumTypeBuilder, FileWriter};
|
||||||
/// The Python interpreter to drive interop checks with.
|
|
||||||
///
|
|
||||||
/// `CLAWHDF5_PYTHON` lets these run against a virtualenv holding h5py, which
|
|
||||||
/// on a PEP 668 "externally managed" system is the only place it can be
|
|
||||||
/// installed. Without it the suite silently skips, and a silent skip here is
|
|
||||||
/// how a datatype bug once reached a release.
|
|
||||||
fn python() -> String {
|
|
||||||
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn h5py_available() -> bool {
|
fn h5py_available() -> bool {
|
||||||
std::process::Command::new(python())
|
std::process::Command::new("python3")
|
||||||
.args(["-c", "import h5py"])
|
.args(["-c", "import h5py"])
|
||||||
.output()
|
.output()
|
||||||
.map(|o| o.status.success())
|
.map(|o| o.status.success())
|
||||||
@@ -26,10 +17,10 @@ fn h5py_read(_path: &std::path::Path, script: &str) -> String {
|
|||||||
if !h5py_available() {
|
if !h5py_available() {
|
||||||
panic!("h5py not installed — skipping interop test");
|
panic!("h5py not installed — skipping interop test");
|
||||||
}
|
}
|
||||||
let o = std::process::Command::new(python())
|
let o = std::process::Command::new("python3")
|
||||||
.args(["-c", script])
|
.args(["-c", script])
|
||||||
.output()
|
.output()
|
||||||
.expect("python interpreter");
|
.expect("python3");
|
||||||
if !o.status.success() {
|
if !o.status.success() {
|
||||||
panic!("h5py: {}", String::from_utf8_lossy(&o.stderr));
|
panic!("h5py: {}", String::from_utf8_lossy(&o.stderr));
|
||||||
}
|
}
|
||||||
@@ -244,31 +235,17 @@ fn h5py_reads_our_array_dataset() {
|
|||||||
#[test]
|
#[test]
|
||||||
#[ignore = "requires Python h5py module"]
|
#[ignore = "requires Python h5py module"]
|
||||||
fn read_h5py_generated_compound() {
|
fn read_h5py_generated_compound() {
|
||||||
check_h5py_generated_compound("latest", ", libver='latest'");
|
let path = std::env::temp_dir().join("clawhdf5_h5py_compound.h5");
|
||||||
}
|
|
||||||
|
|
||||||
/// Same file written with h5py's default format bounds. HDF5 2.0 raised the
|
|
||||||
/// default low bound to 1.8, so "default" files exercise different on-disk
|
|
||||||
/// structures than both `libver='latest'` and pre-2.0 defaults.
|
|
||||||
#[test]
|
|
||||||
#[ignore = "requires Python h5py module"]
|
|
||||||
fn read_h5py_generated_compound_default_libver() {
|
|
||||||
check_h5py_generated_compound("default", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_h5py_generated_compound(tag: &str, libver_kw: &str) {
|
|
||||||
let path = std::env::temp_dir().join(format!("clawhdf5_h5py_compound_{tag}.h5"));
|
|
||||||
let gen_script = format!(
|
let gen_script = format!(
|
||||||
r#"
|
r#"
|
||||||
import h5py, numpy as np
|
import h5py, numpy as np
|
||||||
dt = np.dtype([('x', 'f8'), ('y', 'f8'), ('id', 'i4')])
|
dt = np.dtype([('x', 'f8'), ('y', 'f8'), ('id', 'i4')])
|
||||||
data = np.array([(1.0, 2.0, 10), (3.0, 4.0, 20)], dtype=dt)
|
data = np.array([(1.0, 2.0, 10), (3.0, 4.0, 20)], dtype=dt)
|
||||||
f = h5py.File('{}', 'w'{})
|
f = h5py.File('{}', 'w', libver='latest')
|
||||||
f.create_dataset('particles', data=data)
|
f.create_dataset('particles', data=data)
|
||||||
f.close()
|
f.close()
|
||||||
"#,
|
"#,
|
||||||
path.display(),
|
path.display()
|
||||||
libver_kw
|
|
||||||
);
|
);
|
||||||
h5py_read(&path, &gen_script);
|
h5py_read(&path, &gen_script);
|
||||||
|
|
||||||
@@ -315,102 +292,20 @@ f.close()
|
|||||||
assert_eq!(x_vals, vec![1.0, 3.0]);
|
assert_eq!(x_vals, vec![1.0, 3.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[ignore = "requires Python h5py module"]
|
|
||||||
fn read_h5py_generated_native_complex() {
|
|
||||||
// HDF5 2.0 native complex (datatype class 11, version 5), written through
|
|
||||||
// h5py's low-level API. Skips when the linked HDF5 predates 2.0.
|
|
||||||
let path = std::env::temp_dir().join("clawhdf5_h5py_native_complex.h5");
|
|
||||||
let gen_script = format!(
|
|
||||||
r#"
|
|
||||||
import h5py, numpy as np
|
|
||||||
from h5py import h5t, h5s, h5d, h5f, h5p
|
|
||||||
if not getattr(h5py.get_config(), 'has_native_complex', False):
|
|
||||||
print('SKIP')
|
|
||||||
else:
|
|
||||||
fapl = h5p.create(h5p.FILE_ACCESS)
|
|
||||||
fapl.set_libver_bounds(h5f.LIBVER_LATEST, h5f.LIBVER_LATEST)
|
|
||||||
fid = h5f.create(b'{}', h5f.ACC_TRUNC, fapl=fapl)
|
|
||||||
t = h5t.COMPLEX_IEEE_F64LE
|
|
||||||
d = h5d.create(fid, b'z', t, h5s.create_simple((2,)))
|
|
||||||
d.write(h5s.ALL, h5s.ALL, np.array([1+2j, 3+4j], dtype=np.complex128), mtype=t)
|
|
||||||
fid.close()
|
|
||||||
"#,
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
if h5py_read(&path, &gen_script) == "SKIP" {
|
|
||||||
eprintln!("HDF5 < 2.0: no native complex support, skipping");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = std::fs::read(&path).unwrap();
|
|
||||||
let sig = clawhdf5_format::signature::find_signature(&bytes).unwrap();
|
|
||||||
let sb = clawhdf5_format::superblock::Superblock::parse(&bytes, sig).unwrap();
|
|
||||||
let addr = clawhdf5_format::group_v2::resolve_path_any(&bytes, &sb, "z").unwrap();
|
|
||||||
let hdr = clawhdf5_format::object_header::ObjectHeader::parse(
|
|
||||||
&bytes,
|
|
||||||
addr as usize,
|
|
||||||
sb.offset_size,
|
|
||||||
sb.length_size,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let msg = |t: clawhdf5_format::message_type::MessageType| {
|
|
||||||
&hdr.messages.iter().find(|m| m.msg_type == t).unwrap().data
|
|
||||||
};
|
|
||||||
let (dt, _) = clawhdf5_format::datatype::Datatype::parse(msg(
|
|
||||||
clawhdf5_format::message_type::MessageType::Datatype,
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
let ds = clawhdf5_format::dataspace::Dataspace::parse(
|
|
||||||
msg(clawhdf5_format::message_type::MessageType::Dataspace),
|
|
||||||
sb.length_size,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let dl = clawhdf5_format::data_layout::DataLayout::parse(
|
|
||||||
msg(clawhdf5_format::message_type::MessageType::DataLayout),
|
|
||||||
sb.offset_size,
|
|
||||||
sb.length_size,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let raw = clawhdf5_format::data_read::read_raw_data(&bytes, &dl, &ds, &dt).unwrap();
|
|
||||||
let fields = clawhdf5_format::data_read::read_compound_fields(&raw, &dt).unwrap();
|
|
||||||
assert_eq!(fields.len(), 2);
|
|
||||||
let re =
|
|
||||||
clawhdf5_format::data_read::read_as_f64(&fields[0].raw_data, &fields[0].datatype).unwrap();
|
|
||||||
let im =
|
|
||||||
clawhdf5_format::data_read::read_as_f64(&fields[1].raw_data, &fields[1].datatype).unwrap();
|
|
||||||
assert_eq!((fields[0].name.as_str(), re), ("r", vec![1.0, 3.0]));
|
|
||||||
assert_eq!((fields[1].name.as_str(), im), ("i", vec![2.0, 4.0]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "requires Python h5py module"]
|
#[ignore = "requires Python h5py module"]
|
||||||
fn read_h5py_generated_enum() {
|
fn read_h5py_generated_enum() {
|
||||||
check_h5py_generated_enum("latest", ", libver='latest'");
|
let path = std::env::temp_dir().join("clawhdf5_h5py_enum.h5");
|
||||||
}
|
|
||||||
|
|
||||||
/// Same file written with h5py's default format bounds. HDF5 2.0 raised the
|
|
||||||
/// default low bound to 1.8, so "default" files exercise different on-disk
|
|
||||||
/// structures than both `libver='latest'` and pre-2.0 defaults.
|
|
||||||
#[test]
|
|
||||||
#[ignore = "requires Python h5py module"]
|
|
||||||
fn read_h5py_generated_enum_default_libver() {
|
|
||||||
check_h5py_generated_enum("default", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_h5py_generated_enum(tag: &str, libver_kw: &str) {
|
|
||||||
let path = std::env::temp_dir().join(format!("clawhdf5_h5py_enum_{tag}.h5"));
|
|
||||||
let gen_script = format!(
|
let gen_script = format!(
|
||||||
r#"
|
r#"
|
||||||
import h5py, numpy as np
|
import h5py, numpy as np
|
||||||
dt = h5py.enum_dtype({{"RED": 0, "GREEN": 1, "BLUE": 2}}, basetype=np.int32)
|
dt = h5py.enum_dtype({{"RED": 0, "GREEN": 1, "BLUE": 2}}, basetype=np.int32)
|
||||||
data = np.array([1, 0, 2, 1], dtype=np.int32)
|
data = np.array([1, 0, 2, 1], dtype=np.int32)
|
||||||
f = h5py.File('{}', 'w'{})
|
f = h5py.File('{}', 'w', libver='latest')
|
||||||
f.create_dataset('colors', data=data, dtype=dt)
|
f.create_dataset('colors', data=data, dtype=dt)
|
||||||
f.close()
|
f.close()
|
||||||
"#,
|
"#,
|
||||||
path.display(),
|
path.display()
|
||||||
libver_kw
|
|
||||||
);
|
);
|
||||||
h5py_read(&path, &gen_script);
|
h5py_read(&path, &gen_script);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-gpu"
|
name = "clawhdf5-gpu"
|
||||||
version = "2.6.0"
|
version = "2.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders"
|
description = "GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["hdf5", "gpu", "wgpu", "compute"]
|
keywords = ["hdf5", "gpu", "wgpu", "compute"]
|
||||||
categories = ["science", "graphics"]
|
categories = ["science", "graphics"]
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ use crate::shaders;
|
|||||||
use bytemuck::Pod;
|
use bytemuck::Pod;
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
/// Upper bound on a single GPU→CPU readback wait.
|
|
||||||
const READBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
|
||||||
|
|
||||||
/// GPU-accelerated vector search engine.
|
/// GPU-accelerated vector search engine.
|
||||||
///
|
///
|
||||||
/// Upload vectors once, then run many searches against them.
|
/// Upload vectors once, then run many searches against them.
|
||||||
@@ -1036,12 +1033,10 @@ impl GpuAccelerator {
|
|||||||
slice.map_async(wgpu::MapMode::Read, move |result| {
|
slice.map_async(wgpu::MapMode::Read, move |result| {
|
||||||
let _ = tx.send(result);
|
let _ = tx.send(result);
|
||||||
});
|
});
|
||||||
// Bounded wait: a wedged driver must surface as an error, not hang
|
|
||||||
// the caller forever.
|
|
||||||
self.device
|
self.device
|
||||||
.poll(wgpu::PollType::Wait {
|
.poll(wgpu::PollType::Wait {
|
||||||
submission_index: None,
|
submission_index: None,
|
||||||
timeout: Some(READBACK_TIMEOUT),
|
timeout: None,
|
||||||
})
|
})
|
||||||
.map_err(|e| GpuError::BufferMap(format!("device poll failed: {e}")))?;
|
.map_err(|e| GpuError::BufferMap(format!("device poll failed: {e}")))?;
|
||||||
rx.recv()
|
rx.recv()
|
||||||
|
|||||||
@@ -6,41 +6,9 @@
|
|||||||
mod tests {
|
mod tests {
|
||||||
use clawhdf5_gpu::{GpuAccelerator, GpuError};
|
use clawhdf5_gpu::{GpuAccelerator, GpuError};
|
||||||
|
|
||||||
/// Serialises GPU access across tests. The harness runs tests on many
|
fn skip_if_no_gpu() -> Option<GpuAccelerator> {
|
||||||
/// threads; letting each create its own wgpu instance + device (with
|
|
||||||
/// adapter-maximum limits) at the same time can wedge the driver and hang
|
|
||||||
/// the whole suite, so every test holds this lock while it owns a device.
|
|
||||||
static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
||||||
|
|
||||||
fn gpu_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
||||||
// A panicking test poisons the lock; the guarded state is `()`.
|
|
||||||
GPU_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `GpuAccelerator` plus the lock that keeps other tests off the GPU.
|
|
||||||
/// Field order matters: the device is dropped before the lock is released.
|
|
||||||
struct LockedGpu {
|
|
||||||
gpu: GpuAccelerator,
|
|
||||||
_guard: std::sync::MutexGuard<'static, ()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::ops::Deref for LockedGpu {
|
|
||||||
type Target = GpuAccelerator;
|
|
||||||
fn deref(&self) -> &GpuAccelerator {
|
|
||||||
&self.gpu
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::ops::DerefMut for LockedGpu {
|
|
||||||
fn deref_mut(&mut self) -> &mut GpuAccelerator {
|
|
||||||
&mut self.gpu
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn skip_if_no_gpu() -> Option<LockedGpu> {
|
|
||||||
let guard = gpu_lock();
|
|
||||||
match GpuAccelerator::new() {
|
match GpuAccelerator::new() {
|
||||||
Ok(gpu) => Some(LockedGpu { gpu, _guard: guard }),
|
Ok(gpu) => Some(gpu),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
eprintln!("SKIPPED: no GPU available");
|
eprintln!("SKIPPED: no GPU available");
|
||||||
None
|
None
|
||||||
@@ -101,7 +69,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_gpu_availability_detection() {
|
fn test_gpu_availability_detection() {
|
||||||
// Should not panic regardless of GPU presence
|
// Should not panic regardless of GPU presence
|
||||||
let _guard = gpu_lock();
|
|
||||||
let available = GpuAccelerator::is_available();
|
let available = GpuAccelerator::is_available();
|
||||||
eprintln!("GPU available: {available}");
|
eprintln!("GPU available: {available}");
|
||||||
}
|
}
|
||||||
@@ -458,7 +425,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_graceful_no_gpu_fallback() {
|
fn test_graceful_no_gpu_fallback() {
|
||||||
// This test just demonstrates the pattern — it always passes
|
// This test just demonstrates the pattern — it always passes
|
||||||
let _guard = gpu_lock();
|
|
||||||
match GpuAccelerator::new() {
|
match GpuAccelerator::new() {
|
||||||
Ok(gpu) => {
|
Ok(gpu) => {
|
||||||
eprintln!("GPU found: {}", gpu.device_info());
|
eprintln!("GPU found: {}", gpu.device_info());
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-io"
|
name = "clawhdf5-io"
|
||||||
version = "2.6.0"
|
version = "2.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "I/O abstraction layer for rustyhdf5"
|
description = "I/O abstraction layer for rustyhdf5"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["hdf5", "io", "science", "data"]
|
keywords = ["hdf5", "io", "science", "data"]
|
||||||
categories = ["filesystem", "science"]
|
categories = ["filesystem", "science"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0" }
|
||||||
memmap2 = { version = "0.9", optional = true }
|
memmap2 = { version = "0.9", optional = true }
|
||||||
libc = { version = "0.2", optional = true }
|
libc = { version = "0.2", optional = true }
|
||||||
tokio = { version = "1", features = ["fs", "io-util"], optional = true }
|
tokio = { version = "1", features = ["fs", "io-util"], optional = true }
|
||||||
|
|||||||
@@ -59,16 +59,11 @@ pub trait AsyncHDF5Read: Send + Sync {
|
|||||||
|
|
||||||
/// Async file-backed reader using tokio for non-blocking I/O.
|
/// Async file-backed reader using tokio for non-blocking I/O.
|
||||||
///
|
///
|
||||||
/// Opens a file and reads it asynchronously. The underlying file handle is
|
/// Opens a file and reads it asynchronously. The file is read into memory
|
||||||
/// opened once (lazily, on first access) and cached for the lifetime of this
|
/// on first access, making subsequent operations fast.
|
||||||
/// reader, so repeated granular `read_at` calls reuse the open descriptor
|
|
||||||
/// and cached length instead of paying an open+stat syscall pair every time.
|
|
||||||
/// The handle is guarded by a mutex, which also correctly serializes the
|
|
||||||
/// seek-then-read pairs of concurrent callers sharing the one file position.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct AsyncFileReader {
|
pub struct AsyncFileReader {
|
||||||
path: std::path::PathBuf,
|
path: std::path::PathBuf,
|
||||||
handle: tokio::sync::Mutex<Option<(tokio::fs::File, u64)>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncFileReader {
|
impl AsyncFileReader {
|
||||||
@@ -78,7 +73,6 @@ impl AsyncFileReader {
|
|||||||
pub fn new<P: AsRef<Path>>(path: P) -> Self {
|
pub fn new<P: AsRef<Path>>(path: P) -> Self {
|
||||||
Self {
|
Self {
|
||||||
path: path.as_ref().to_path_buf(),
|
path: path.as_ref().to_path_buf(),
|
||||||
handle: tokio::sync::Mutex::new(None),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,33 +89,23 @@ impl AsyncFileReader {
|
|||||||
|
|
||||||
impl AsyncHDF5Read for AsyncFileReader {
|
impl AsyncHDF5Read for AsyncFileReader {
|
||||||
async fn read_at(&self, offset: u64, len: usize) -> io::Result<Vec<u8>> {
|
async fn read_at(&self, offset: u64, len: usize) -> io::Result<Vec<u8>> {
|
||||||
let mut guard = self.handle.lock().await;
|
let mut file = tokio::fs::File::open(&self.path).await?;
|
||||||
if guard.is_none() {
|
let metadata = file.metadata().await?;
|
||||||
let file = tokio::fs::File::open(&self.path).await?;
|
let file_len = metadata.len();
|
||||||
let file_len = file.metadata().await?.len();
|
|
||||||
*guard = Some((file, file_len));
|
|
||||||
}
|
|
||||||
let (file, file_len) = guard.as_mut().expect("just populated above");
|
|
||||||
let file_len = *file_len;
|
|
||||||
if offset >= file_len {
|
if offset >= file_len {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let available = (file_len - offset) as usize;
|
let available = (file_len - offset) as usize;
|
||||||
let to_read = len.min(available);
|
let to_read = len.min(available);
|
||||||
tokio::io::AsyncSeekExt::seek(file, io::SeekFrom::Start(offset)).await?;
|
tokio::io::AsyncSeekExt::seek(&mut file, io::SeekFrom::Start(offset)).await?;
|
||||||
let mut buf = vec![0u8; to_read];
|
let mut buf = vec![0u8; to_read];
|
||||||
file.read_exact(&mut buf).await?;
|
file.read_exact(&mut buf).await?;
|
||||||
Ok(buf)
|
Ok(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn len(&self) -> io::Result<u64> {
|
async fn len(&self) -> io::Result<u64> {
|
||||||
let mut guard = self.handle.lock().await;
|
let metadata = tokio::fs::metadata(&self.path).await?;
|
||||||
if guard.is_none() {
|
Ok(metadata.len())
|
||||||
let file = tokio::fs::File::open(&self.path).await?;
|
|
||||||
let file_len = file.metadata().await?.len();
|
|
||||||
*guard = Some((file, file_len));
|
|
||||||
}
|
|
||||||
Ok(guard.as_ref().expect("just populated above").1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-migrate"
|
name = "clawhdf5-migrate"
|
||||||
version = "2.6.0"
|
version = "2.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "CLI to migrate SQLite agent memory databases to HDF5 format"
|
description = "CLI to migrate SQLite agent memory databases to HDF5 format"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://github.com/redclawsystems/clawhdf5"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["sqlite", "hdf5", "migration", "agent", "memory"]
|
keywords = ["sqlite", "hdf5", "migration", "agent", "memory"]
|
||||||
categories = ["command-line-utilities", "database"]
|
categories = ["command-line-utilities", "database"]
|
||||||
@@ -14,9 +14,9 @@ name = "clawhdf5-migrate"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.6.0" }
|
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.1.0" }
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.1.0" }
|
||||||
clawhdf5 = { path = "../clawhdf5", version = "2.6.0" }
|
clawhdf5 = { path = "../clawhdf5", version = "2.1.0" }
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
half = { workspace = true }
|
half = { workspace = true }
|
||||||
|
|||||||
@@ -49,10 +49,6 @@ pub fn read_hdf5(path: &str) -> Result<SqliteData, BoxErr> {
|
|||||||
entities,
|
entities,
|
||||||
relations,
|
relations,
|
||||||
embedding_dim,
|
embedding_dim,
|
||||||
// Not a SQLite read — the caller (incremental migration) carries
|
|
||||||
// forward the current run's actual `source_path` from the fresh
|
|
||||||
// SQLite read instead of using this placeholder.
|
|
||||||
source_path: String::new(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user