Compare commits
201
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb78d70b99 | ||
|
|
a7de15534c | ||
|
|
10d1029ead | ||
|
|
883980f2bd | ||
|
|
d6e426e6d5 | ||
|
|
256e7b89e4 | ||
|
|
f2e704abf3 | ||
|
|
61f36516d7 | ||
|
|
45720fe5a6 | ||
|
|
adf961c883 | ||
|
|
b4a44a2e66 | ||
|
|
17fa783dce | ||
|
|
90e050944f | ||
|
|
a6e90f3ee3 | ||
|
|
0555794850 | ||
|
|
efc2dc53c9 | ||
|
|
5c2f656fe7 | ||
|
|
945b13a1f1 | ||
|
|
9179aa356e | ||
|
|
e94a52a88b | ||
|
|
d54a0f4737 | ||
|
|
aadfd18d4c | ||
|
|
2c6c6c176e | ||
|
|
190918a478 | ||
|
|
38d0d4de02 | ||
|
|
1c85986079 | ||
|
|
c7092722aa | ||
|
|
36356ba8a1 | ||
|
|
85eb7f5ce2 | ||
|
|
8196fab72a | ||
|
|
8ebd488d9e | ||
|
|
42b81d9f1c | ||
|
|
72b9cfb1e1 | ||
|
|
650f355219 | ||
|
|
7f5cfee281 | ||
|
|
c5302e587e | ||
|
|
e1115bc92a | ||
|
|
36d7a6f234 | ||
|
|
4b23ad697c | ||
|
|
e7f2d8575d | ||
|
|
7c1968a34a | ||
|
|
6db13c60b8 | ||
|
|
d99426be94 | ||
|
|
f5505fb03d | ||
|
|
95dcb04454 | ||
|
|
1dba7b465a | ||
|
|
57e938c438 | ||
|
|
3000b40cf3 | ||
|
|
bc820fbd8c | ||
|
|
9066d34eaa | ||
|
|
540fa08907 | ||
|
|
14876b8ae5 | ||
|
|
5935e13866 | ||
|
|
8c3ef996ea | ||
|
|
74fdf0582b | ||
|
|
44f5f8b5c5 | ||
|
|
2f252df084 | ||
|
|
c8c2930fc0 | ||
|
|
417c9516ca | ||
|
|
53dbddb07b | ||
|
|
081341b433 | ||
|
|
d074385944 | ||
|
|
4a1876faf2 | ||
|
|
be88e3fec7 | ||
|
|
e162c013fd | ||
|
|
06dda26d85 | ||
|
|
585e14d5e2 | ||
|
|
183d96ee26 | ||
|
|
bba1560416 | ||
|
|
b36998ef01 | ||
|
|
aef8e766ae | ||
|
|
9ea44d473d | ||
|
|
46203ea761 | ||
|
|
75bdb53342 | ||
|
|
dd5b3f6633 | ||
|
|
79dfa78e8f | ||
|
|
87d64588e5 | ||
|
|
bdadf3447c | ||
|
|
0c65a27b00 | ||
|
|
db9af7972c | ||
|
|
7706697feb | ||
|
|
4ecac65f22 | ||
|
|
c0f704c381 | ||
|
|
00b0cb0035 | ||
|
|
a7920bd4b3 | ||
|
|
7e43b5366c | ||
|
|
dce5559ff2 | ||
|
|
1b3bbb054a | ||
|
|
a8fb758489 | ||
|
|
73bb068264 | ||
|
|
5c8323cb1e | ||
|
|
dbaf3f505d | ||
|
|
c470244a6f | ||
|
|
d0db83812b | ||
|
|
5e4aa1c6bf | ||
|
|
1cceb930b2 | ||
|
|
fc7ae6549a | ||
|
|
735db117a7 | ||
|
|
e9b37a9602 | ||
|
|
4bed8b3765 | ||
|
|
36d689bc2c | ||
|
|
e7c08e06b4 | ||
|
|
c5049eb734 | ||
|
|
6f6bc97850 | ||
|
|
0cb72e8a60 | ||
|
|
e338d58ad5 | ||
|
|
8b85d9364b | ||
|
|
6598a7d02f | ||
|
|
114a2dfcba | ||
|
|
56a8c2f3d0 | ||
|
|
7b16dc90d6 | ||
|
|
4a5544da1d | ||
|
|
f4c6d43a3f | ||
|
|
e5e087f9ab | ||
|
|
16c9ee0554 | ||
|
|
1d767e3b93 | ||
|
|
a91df3f1c3 | ||
|
|
b41272487a | ||
|
|
0bc7a293ae | ||
|
|
dea02f5214 | ||
|
|
97e65f2adf | ||
|
|
fb58300b3f | ||
|
|
eb196e824f | ||
|
|
367faad7f7 | ||
|
|
0901fb1499 | ||
|
|
e9aeb110b7 | ||
|
|
5889b378e9 | ||
|
|
18dc35f7e5 | ||
|
|
e17ab0ceef | ||
|
|
105cf13347 | ||
|
|
0529f72a2c | ||
|
|
a29c1b224b | ||
|
|
c0a9206703 | ||
|
|
57756e69ec | ||
|
|
6ad8ceb426 | ||
|
|
2e7e0456c1 | ||
|
|
dc0113d015 | ||
|
|
8ea455bbcb | ||
|
|
1e18ff5a86 | ||
|
|
306a35347c | ||
|
|
4c60398b30 | ||
|
|
7155409202 | ||
|
|
64d9c5f171 | ||
|
|
84a39ef3c5 | ||
|
|
55ed87d2e8 | ||
|
|
6531158d9f | ||
|
|
aa92fef7bb | ||
|
|
29baabbed2 | ||
|
|
b23946e62d | ||
|
|
52cfcf20b2 | ||
|
|
05c665a898 | ||
|
|
b36c6ec2af | ||
|
|
f3d63dbdcd | ||
|
|
0addf328bc | ||
|
|
d668e45ab5 | ||
|
|
c6a7bbfc67 | ||
|
|
3027380979 | ||
|
|
f507803ec1 | ||
|
|
8803d0754b | ||
|
|
42c3872ec9 | ||
|
|
c19199f3eb | ||
|
|
41db450c92 | ||
|
|
db4a067fe8 | ||
|
|
4aa3c5a1ca | ||
|
|
26e06cc5fd | ||
|
|
390a2e3836 | ||
|
|
f15bf2eb22 | ||
|
|
09480747aa | ||
|
|
39bf2bebf4 | ||
|
|
0ee698accd | ||
|
|
2bfbb7fb4b | ||
|
|
61424d1418 | ||
|
|
65d219c409 | ||
|
|
eb99de1020 | ||
|
|
a3ad548f84 | ||
|
|
0876796432 | ||
|
|
91d46a3813 | ||
|
|
97ab658c11 | ||
|
|
5dd95a6cf8 | ||
|
|
a0ff8ef32c | ||
|
|
24afcdc70f | ||
|
|
8f62cb44e0 | ||
|
|
e38c8133bc | ||
|
|
12847c6c66 | ||
|
|
81e8294048 | ||
|
|
0eca8574f5 | ||
|
|
005f37e846 | ||
|
|
bf8bbec87e | ||
|
|
6e84f31ed6 | ||
|
|
3ed0489faa | ||
|
|
0744d52639 | ||
|
|
99b907be04 | ||
|
|
4f2975d7e3 | ||
|
|
d4f2d3e7b5 | ||
|
|
6848494647 | ||
|
|
943b9141e3 | ||
|
|
a9f78ca5a1 | ||
|
|
a3f7c6fe89 | ||
|
|
bbe1baa208 | ||
|
|
706189c3ef | ||
|
|
926dc457e0 |
+69
-9
@@ -9,18 +9,78 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: rust:latest
|
container: rust:latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
# Plain git rather than actions/checkout: that is a JavaScript action,
|
||||||
- name: Cache cargo registry/target
|
# and rust:latest has no `node`, so it failed with exit 127 before any
|
||||||
uses: actions/cache@v4
|
# code was built — on every push. actions/cache went for the same reason.
|
||||||
with:
|
- name: Check out
|
||||||
path: |
|
run: |
|
||||||
~/.cargo/registry
|
git init -q .
|
||||||
~/.cargo/git
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
target
|
for i in 1 2 3; do git fetch -q --depth 1 origin "${GITHUB_SHA}" && break; sleep 5; done
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
git checkout -q FETCH_HEAD
|
||||||
- name: Install rustfmt & clippy components
|
- name: Install rustfmt & clippy components
|
||||||
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
|
||||||
|
# cmake builds libz-ng-sys for the opt-in `fast-deflate` (zlib-ng)
|
||||||
|
# steps in ci-test.sh; rust:latest does not ship it. The default
|
||||||
|
# build (pure-Rust zlib-rs) does not need it.
|
||||||
|
apt-get install -y --no-install-recommends python3 python3-venv cmake
|
||||||
|
python3 -m venv /opt/interop
|
||||||
|
/opt/interop/bin/pip install --no-cache-dir h5py numpy netCDF4 xarray hdf5plugin
|
||||||
|
echo "/opt/interop/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: Show interop library versions
|
||||||
|
run: /opt/interop/bin/python -c "import h5py, netCDF4, hdf5plugin; print('h5py', h5py.__version__, 'HDF5', h5py.version.hdf5_version, 'netCDF4', netCDF4.__version__, 'hdf5plugin', hdf5plugin.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
|
||||||
|
|
||||||
|
test-arm64:
|
||||||
|
# The aarch64 kernels in clawhdf5-accel — NEON `dot_i8`, including the
|
||||||
|
# SDOT path, and the f32 NEON kernels — are cfg'd out on x86, so the job
|
||||||
|
# above never compiles, lints or tests them.
|
||||||
|
#
|
||||||
|
# `linux_arm64` is served by two runners that execute differently:
|
||||||
|
# vision-01 runs steps on the host (Rust already installed) and vision-02
|
||||||
|
# runs them in docker.gitea.com/runner-images. So the steps work in both:
|
||||||
|
# no `container:`, no JavaScript actions (they are fetched from GitHub,
|
||||||
|
# which not every runner reliably reaches), and an explicit `+stable`
|
||||||
|
# toolchain rather than whatever a host happens to default to.
|
||||||
|
runs-on: linux_arm64
|
||||||
|
env:
|
||||||
|
CARGO_NET_RETRY: "10"
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
steps:
|
||||||
|
- name: Check out
|
||||||
|
run: |
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
for i in 1 2 3; do git fetch -q --depth 1 origin "${GITHUB_SHA}" && break; sleep 5; done
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
- name: Rust stable
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
command -v rustup >/dev/null || curl -sSf --retry 5 https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none
|
||||||
|
rustup toolchain install stable --profile minimal --component clippy
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: Confirm aarch64
|
||||||
|
run: |
|
||||||
|
test "$(uname -m)" = aarch64
|
||||||
|
if grep -q asimddp /proc/cpuinfo; then echo "dot-product extension present: SDOT kernel runs"; else echo "no dot-product extension: plain NEON kernel runs"; fi
|
||||||
|
- name: Clippy (aarch64 kernels)
|
||||||
|
run: cargo +stable clippy -p clawhdf5-accel --all-targets -- -D warnings
|
||||||
|
- name: Test
|
||||||
|
run: cargo +stable test -p clawhdf5-accel -p clawhdf5-ann -p clawhdf5-format
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
name: Conformance
|
||||||
|
# Nightly: read every file of the pinned public HDF5 corpora with clawhdf5 and
|
||||||
|
# with h5py/libhdf5 and compare (conformance/run.sh; CONFORMANCE.md explains
|
||||||
|
# the method). Fails on any panic, hang, crash or out-of-memory in clawhdf5,
|
||||||
|
# and when the ok count drops below conformance/baseline.json or a file the
|
||||||
|
# baseline lists as ok stops being ok. The report is printed into the job log;
|
||||||
|
# nothing is uploaded (artifact actions are JavaScript, which rust:latest
|
||||||
|
# cannot run — see CLAUDE.md).
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
jobs:
|
||||||
|
conformance:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: rust:latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
CARGO_NET_RETRY: "10"
|
||||||
|
steps:
|
||||||
|
# Plain git, not actions/checkout (a JavaScript action; see ci.yml).
|
||||||
|
- name: Check out
|
||||||
|
run: |
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
for i in 1 2 3; do git fetch -q --depth 1 origin "${GITHUB_SHA}" && break; sleep 5; done
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
- name: Install h5py, h5dump and the probe's codec libraries
|
||||||
|
# hdf5-tools: h5dump for the CVE-corpus comparison. libaec-dev and
|
||||||
|
# pkg-config: the probe builds clawhdf5-format with `szip` (the core
|
||||||
|
# crates' default build needs neither).
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends python3 python3-venv hdf5-tools libaec-dev pkg-config
|
||||||
|
python3 -m venv /opt/conformance
|
||||||
|
/opt/conformance/bin/pip install --no-cache-dir -r conformance/requirements.txt
|
||||||
|
/opt/conformance/bin/python -c "import h5py, hdf5plugin; print('h5py', h5py.__version__, 'HDF5', h5py.version.hdf5_version, 'hdf5plugin', hdf5plugin.version)"
|
||||||
|
h5dump --version
|
||||||
|
- name: Probe unit tests
|
||||||
|
run: cargo test --release --manifest-path conformance/probe/Cargo.toml
|
||||||
|
env:
|
||||||
|
CARGO_TARGET_DIR: conformance/.cache/target
|
||||||
|
- name: Sweep
|
||||||
|
# The corpora come from GitHub (pinned commits, conformance/corpus.txt),
|
||||||
|
# so this job needs a runner that reaches github.com.
|
||||||
|
env:
|
||||||
|
CLAWHDF5_PYTHON: /opt/conformance/bin/python
|
||||||
|
run: bash conformance/run.sh
|
||||||
|
- name: Report
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f CONFORMANCE.md ]; then cat CONFORMANCE.md; else echo "no report was generated"; fi
|
||||||
|
if [ -f conformance/.cache/results/summary.md ]; then
|
||||||
|
echo; echo "---- per-file detail (conformance/.cache/results/summary.md) ----"
|
||||||
|
cat conformance/.cache/results/summary.md
|
||||||
|
fi
|
||||||
@@ -4,3 +4,4 @@ benchmarks/longmemeval/*.json
|
|||||||
|
|
||||||
# Local model weights (MiniLM etc.) — large, not committed
|
# Local model weights (MiniLM etc.) — large, not committed
|
||||||
weights/
|
weights/
|
||||||
|
.venv
|
||||||
|
|||||||
+1398
-140
File diff suppressed because it is too large
Load Diff
+1102
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
# clawhdf5
|
# clawhdf5
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Pure-Rust HDF5 format implementation with HNSW vector search, WAL-backed persistence, agent memory storage, and GPU-accelerated I/O. Used by ZeroClaw as its persistent memory and knowledge graph backend.
|
Pure-Rust HDF5 format implementation with HNSW vector search, WAL-backed persistence, agent memory storage, and GPU-accelerated vector search. A standalone library. Its one verified consumer is ClawBrainHub (`.brain` files); no agent framework integrates it (OpenClaw and ZeroClaw claims were withdrawn on 2026-09-25 — neither was ever true).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -11,15 +11,15 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
|
|||||||
|-------|------|
|
|-------|------|
|
||||||
| `clawhdf5-format` | HDF5 binary spec parser (superblock, B-tree, heap) — also holds shared type definitions and physical constants |
|
| `clawhdf5-format` | HDF5 binary spec parser (superblock, B-tree, heap) — also holds shared type definitions and physical constants |
|
||||||
| `clawhdf5-io` | Read/write implementation |
|
| `clawhdf5-io` | Read/write implementation |
|
||||||
| `clawhdf5-filters` | Compression filters (gzip, LZ4, Zstd, Blosc) |
|
| `clawhdf5-filters` | Deflate backends (zlib-rs, zlib-ng, Apple Compression); the HDF5 filter pipeline and the other codecs (LZ4, Zstd, SZIP, N-Bit, scale-offset, pcodec) live in `clawhdf5-format`. No Blosc. |
|
||||||
| `clawhdf5-derive` | Proc-macro derive for HDF5-serializable structs |
|
| `clawhdf5-derive` | Proc-macro derive for HDF5-serializable structs |
|
||||||
| `clawhdf5` | Main facade crate |
|
| `clawhdf5` | Main facade crate |
|
||||||
| `clawhdf5-netcdf4` | NetCDF-4 compatibility layer |
|
| `clawhdf5-netcdf4` | NetCDF-4 compatibility layer |
|
||||||
| `clawhdf5-ann` | HNSW approximate nearest-neighbor vector index |
|
| `clawhdf5-ann` | HNSW approximate nearest-neighbor vector index |
|
||||||
| `clawhdf5-agent` | Agent memory, session history, knowledge graph storage |
|
| `clawhdf5-agent` | Agent memory, session history, knowledge graph storage |
|
||||||
| `clawhdf5-gpu` | GPU-accelerated I/O via wgpu (hand-written WGSL compute shaders) |
|
| `clawhdf5-gpu` | GPU vector distance computation via wgpu (hand-written WGSL compute shaders) — not dataset I/O |
|
||||||
| `clawhdf5-accel` | CPU SIMD acceleration path |
|
| `clawhdf5-accel` | CPU SIMD acceleration path |
|
||||||
| `clawhdf5-migrate` | Schema migration engine |
|
| `clawhdf5-migrate` | SQLite → HDF5 agent-memory migration |
|
||||||
| `clawhdf5-android` | Android JNI bindings |
|
| `clawhdf5-android` | Android JNI bindings |
|
||||||
| `clawhdf5-cli` | Command-line interface |
|
| `clawhdf5-cli` | Command-line interface |
|
||||||
| `clawhdf5-napi` | Node.js native addon bindings |
|
| `clawhdf5-napi` | Node.js native addon bindings |
|
||||||
@@ -27,12 +27,42 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
|
|||||||
| `clawhdf5-bench` | Benchmark suite |
|
| `clawhdf5-bench` | Benchmark suite |
|
||||||
|
|
||||||
## Key Features
|
## Key Features
|
||||||
- Zero-dependency HDF5 read/write (no libhdf5 C library required)
|
- Zero-C-dependency HDF5 read/write: no libhdf5, and deflate defaults to
|
||||||
|
pure-Rust zlib-rs (`fast-deflate` opts into zlib-ng, which needs cmake).
|
||||||
|
`ci-test.sh` fails if a C-building crate enters the core crates' default
|
||||||
|
tree. flate2 must keep `runtime_detection` with zlib-rs — without it zlib-rs
|
||||||
|
loses SIMD and inflates 3.5x slower. MSRV is 1.92 (`rust-version`, checked
|
||||||
|
in CI).
|
||||||
- HNSW vector index for semantic similarity search over agent memories — the
|
- HNSW vector index for semantic similarity search over agent memories — the
|
||||||
`clawhdf5-agent` `hnsw` feature is **on by default**, so `hybrid_search` uses
|
`clawhdf5-agent` `hnsw` feature is **on by default**, so `hybrid_search` uses
|
||||||
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
|
||||||
|
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` (**on by
|
||||||
|
default** for new stores, persisted; stores predating the setting load as
|
||||||
|
`false` and keep their f32 index — guarded by
|
||||||
|
`tests/fixtures/store_v2_5_0.h5`; CLI opt-out is `create --f32-index`)
|
||||||
|
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. It is also
|
||||||
|
faster at equal recall: 1.63x the QPS on x86-64 (AVX2) and 1.18x on a
|
||||||
|
Raspberry Pi 5 (`clawhdf5_accel::dot_i8`, NEON `SDOT` via inline asm since
|
||||||
|
the intrinsic is unstable; plain NEON on pre-dotprod cores). The aarch64
|
||||||
|
code is `cfg`'d out on x86, so x86 CI never compiles or lints it — test it
|
||||||
|
on real ARM (`rpivision02`, 10.0.2.3, is a Pi 5). `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
|
- 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
|
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
|
corrupted, reordered, duplicated, or spliced entry stops replay cleanly
|
||||||
@@ -40,6 +70,68 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
|
|||||||
format (v2) is still fully readable; the oldest no-CRC format (v1) is only
|
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
|
reachable through the one-time migration path in `HDF5Memory::open`, not
|
||||||
through the public `WalFile::read_entries`.
|
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::float16` (**on by default** for new stores, persisted;
|
||||||
|
existing stores keep their recorded `false` — guarded by the v2.5.0
|
||||||
|
fixture in `tests/float16_store.rs`; CLI opt-out is `create --f32`) writes
|
||||||
|
`/memory/embeddings` as IEEE half precision (48% smaller file at 100K;
|
||||||
|
LongMemEval with real MiniLM embeddings identical to f32).
|
||||||
|
`MemoryCache::half_precision` rounds each embedding as it enters the cache (push, update, WAL replay, and on load of a store still
|
||||||
|
`f32` on disk), so memory and file agree bit for bit; the conversions live
|
||||||
|
in `clawhdf5_format::float16` and must stay the single implementation.
|
||||||
|
Values beyond ±65504 are `MemoryError::InvalidEntry`. Interop: every file
|
||||||
|
must open in h5py — `f32` datasets and empty datasets did not until
|
||||||
|
2026-09-23 (see `docs/known-issues.md`); the agent's `h5py_interop` test
|
||||||
|
guards a whole store.
|
||||||
|
- `HDF5Memory::search(query_emb, text, &SearchOptions)` is the full search
|
||||||
|
path: optional source-channel filter (applied before ranking; exact scan of
|
||||||
|
the allowed records whenever cheaper than `pool × M` index distance
|
||||||
|
evaluations, and as the fallback when the pool comes back short), fusion,
|
||||||
|
activation scaling, optional re-ranking and confidence rejection.
|
||||||
|
`hybrid_search`/`hybrid_search_with` are thin wrappers; `ClawhdfBackend`
|
||||||
|
(the `openclaw` module) is `search` with re-rank + confidence on.
|
||||||
|
- **OpenClaw is not supported** (decided 2026-09-25): clawhdf5 is not an
|
||||||
|
OpenClaw memory plugin and never was — the old `memory.backend = "clawhdf5"`
|
||||||
|
config was never valid. Don't reintroduce OpenClaw claims; `docs/openclaw.md`
|
||||||
|
records what a real plugin would need.
|
||||||
|
- **ZeroClaw does not use clawhdf5** (checked 2026-09-25 against upstream
|
||||||
|
v0.8.5 and the `osobh/zeroclaw` fork, and their full history): no
|
||||||
|
`clawhdf5` feature or backend exists; ZeroClaw's memory backends are
|
||||||
|
sqlite/lucid/postgres/qdrant/markdown/none behind its own `Memory` trait.
|
||||||
|
`clawhdf5-migrate`'s default SQLite layout (`memory_chunks`, `sessions`,
|
||||||
|
`entities`, `relations`) is not ZeroClaw's schema either (ZeroClaw's is a
|
||||||
|
`memories` table). Don't reintroduce integration claims without an
|
||||||
|
integration and a test against the real consumer. Measure changes with
|
||||||
|
`search_harness --options-study`.
|
||||||
|
- `MemoryConfig::compression` is off by default; when on, embeddings are
|
||||||
|
deflate-compressed, or Zstd with the agent's `zstd` feature (links libzstd).
|
||||||
|
- Signed checkpoints (`clawhdf5-agent` `signing` module): with
|
||||||
|
`HDF5Memory::set_signing_key` every checkpoint stores an Ed25519-signed
|
||||||
|
manifest (SHA-256 per record in a Merkle tree + settings/sessions/graph
|
||||||
|
hashes; per-record hashes in `/integrity/record_hashes`);
|
||||||
|
`HDF5Memory::verify(path, &pk)` locates edits. The hashes must cover exactly
|
||||||
|
what the file persists in the form the loader returns it (strings lose
|
||||||
|
trailing NULs; an empty WAL mark is not written) or untouched stores stop
|
||||||
|
verifying — `tests/signed_store.rs` round-trips awkward strings. The key is
|
||||||
|
never persisted; a signed store refuses to checkpoint without it
|
||||||
|
(`MemoryError::SigningKeyRequired`, and `MemoryError` is `#[non_exhaustive]`).
|
||||||
|
WAL entries after the checkpoint are not covered.
|
||||||
- `Dataset::verify_provenance()` (clawhdf5 facade, `provenance` feature, on by
|
- `Dataset::verify_provenance()` (clawhdf5 facade, `provenance` feature, on by
|
||||||
default) recomputes a dataset's SHA-256 and compares it against the
|
default) recomputes a dataset's SHA-256 and compares it against the
|
||||||
`_provenance_sha256` attribute written automatically on save when
|
`_provenance_sha256` attribute written automatically on save when
|
||||||
@@ -56,7 +148,7 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
|
|||||||
Alerts never block a save — drain them with `HDF5Memory::take_anomaly_alerts`.
|
Alerts never block a save — drain them with `HDF5Memory::take_anomaly_alerts`.
|
||||||
`MemorySource` for this bookkeeping is inferred from the caller-supplied
|
`MemorySource` for this bookkeeping is inferred from the caller-supplied
|
||||||
`source_channel` string (a heuristic, not an authenticated trust boundary).
|
`source_channel` string (a heuristic, not an authenticated trust boundary).
|
||||||
- GPU-accelerated batch I/O for large dataset processing
|
- GPU-accelerated vector distance computation (`clawhdf5-gpu`, wgpu); HDF5 I/O itself is CPU-only
|
||||||
- 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
|
||||||
|
|
||||||
@@ -72,6 +164,24 @@ cargo build --release
|
|||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### CI
|
||||||
|
`.gitea/workflows/ci.yml` has two jobs, both green as of 2026-09-22:
|
||||||
|
- **`test`** (`ubuntu-latest`, in `rust:latest`) runs `scripts/ci-test.sh` with
|
||||||
|
the h5py/netCDF4 interop suites required (`CLAWHDF5_REQUIRE_INTEROP=1`).
|
||||||
|
Served by the `tank` and `architect` runners.
|
||||||
|
- **`test-arm64`** (`linux_arm64`) lints and tests the aarch64 code — the NEON
|
||||||
|
kernels are `cfg`'d out on x86, so this is the only place they are built.
|
||||||
|
Served by `vision-01` (host mode) and `vision-02` (Docker), so steps must
|
||||||
|
work in both.
|
||||||
|
|
||||||
|
Keep workflows free of JavaScript actions (`actions/checkout`, `actions/cache`,
|
||||||
|
…): `rust:latest` has no `node`, and not every runner reaches GitHub, where
|
||||||
|
they are fetched from. Check out with plain `git` instead. The `test` job
|
||||||
|
installs `cmake` for the opt-in `fast-deflate` (zlib-ng) steps; the default
|
||||||
|
build needs no C toolchain, so `test-arm64` does not.
|
||||||
|
All runners are on `gitea-runner` 3.5.0, from `docker.gitea.com/act_runner`
|
||||||
|
— `gitea/act_runner:latest` on Docker Hub is frozen at 0.6.1.
|
||||||
|
|
||||||
### CLI
|
### CLI
|
||||||
```bash
|
```bash
|
||||||
cargo run -p clawhdf5-cli -- --help
|
cargo run -p clawhdf5-cli -- --help
|
||||||
@@ -86,4 +196,12 @@ python -c "import clawhdf5; print(clawhdf5.__version__)"
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Integration
|
## Integration
|
||||||
ZeroClaw imports this as a Cargo feature (`clawhdf5` feature flag) to persist agent memory with HNSW vector search for context retrieval.
|
- **ClawBrainHub** (`clawverse/clawbrainhub` on git.redclaw.dev) is the one
|
||||||
|
verified consumer: `cbh-core` reads and writes `.brain` files through the
|
||||||
|
facade (`File`, `FileBuilder`, `AttrValue`, `Selection`), `cbh-scanner`
|
||||||
|
uses the facade, and `cbh-cli` uses `clawhdf5_agent::bm25::BM25Index`. It
|
||||||
|
depends on this repo by path (`../clawhdf5`), so it builds against whatever
|
||||||
|
is checked out — changes to those APIs reach it directly. Verified
|
||||||
|
2026-09-25 against main: builds, and its 204 tests pass.
|
||||||
|
- OpenClaw and ZeroClaw were both described as consumers; neither integrates
|
||||||
|
clawhdf5 (see Key Features and `docs/openclaw.md`).
|
||||||
|
|||||||
+300
@@ -0,0 +1,300 @@
|
|||||||
|
# clawhdf5 conformance report
|
||||||
|
|
||||||
|
Every HDF5 file of eight public corpora (pinned by commit) is read twice — by
|
||||||
|
clawhdf5 (`conformance/probe`, the same `clawhdf5-format` calls the facade
|
||||||
|
makes) and by h5py/libhdf5 (`conformance/ref.py`) — and the two readings are
|
||||||
|
compared object by object: the set of hard-linked objects, each dataset's and
|
||||||
|
attribute's shape, and a SHA-256 of its values in a canonical encoding. The
|
||||||
|
CVE corpus is also run through `h5dump`. Each side runs under a timeout and an
|
||||||
|
address-space limit, so a hang, crash or runaway allocation is recorded, not
|
||||||
|
fatal. This file is generated by `conformance/run.sh`; do not edit it by hand.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| date | 2026-09-26 03:46 UTC |
|
||||||
|
| clawhdf5 commit | `10d1029ead524e2fe64c2cd7f61b28067d9e449c` |
|
||||||
|
| machine | `tank`: AMD Ryzen 7 7800X3D 8-Core Processor, 16 CPUs, 61 GiB, Linux 7.0.0-34-generic x86_64 |
|
||||||
|
| command | `conformance/run.sh --no-fetch --update-baseline` |
|
||||||
|
| rustc | rustc 1.98.1 (48a229cea 2026-09-01) |
|
||||||
|
| reference | h5py 3.16.0, HDF5 2.0.0, numpy 2.5.3, hdf5plugin 7.1.0, Python 3.14.4 |
|
||||||
|
| h5dump | Version 1.14.6 (CVE corpus only) |
|
||||||
|
| limits | 20 s timeout (SIGKILL), 4096 MiB address space, per process; 16 files in parallel |
|
||||||
|
| runtime | 22 s probing + comparing (0 s fetch/build before it) |
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
A file's class is the first that applies:
|
||||||
|
|
||||||
|
- **panic / hang / crash / oom** — clawhdf5 panicked (caught per object or not), hit the timeout, died on a signal, or failed an allocation. The CI gate fails on any of these.
|
||||||
|
- **h5py-cannot-read** — libhdf5 could not open the file (or itself crashed or hung). Nothing to compare against; most are the deliberately malformed CVE reproducers.
|
||||||
|
- **our-error** — clawhdf5 returned an error for something h5py reads.
|
||||||
|
- **mismatch** — both read it, but the shapes, values, object set or attribute set differ.
|
||||||
|
- **ok** — every object h5py reads, clawhdf5 reads identically.
|
||||||
|
|
||||||
|
| corpus | files | ok | our-error | mismatch | h5py-cannot-read | panic | hang | crash | oom |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| NCAS-CMS_pyfive | 33 | 32 | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
|
||||||
|
| cve_hdf5 | 147 | 100 | 6 | 9 | 32 | 0 | 0 | 0 | 0 |
|
||||||
|
| h5py_data | 4 | 4 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
|
||||||
|
| hdf5 | 466 | 386 | 8 | 12 | 60 | 0 | 0 | 0 | 0 |
|
||||||
|
| netcdf-c | 20 | 20 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
|
||||||
|
| netcdf4-python | 18 | 18 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
|
||||||
|
| usnistgov_h5wasm | 5 | 5 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
|
||||||
|
| xarray-data | 4 | 4 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
|
||||||
|
| **all** | **697** | **569** | **14** | **22** | **92** | **0** | **0** | **0** | **0** |
|
||||||
|
|
||||||
|
2 of the 22 mismatches are a known h5py bug, not ours (see *Known not-our-bug*).
|
||||||
|
|
||||||
|
Corpora (fetched by `conformance/fetch-corpus.sh` into the gitignored `conformance/.cache/`):
|
||||||
|
|
||||||
|
| corpus | source | commit |
|
||||||
|
|---|---|---|
|
||||||
|
| hdf5 | https://github.com/HDFGroup/hdf5 | `a3cf1ea82cc7` |
|
||||||
|
| cve_hdf5 | https://github.com/HDFGroup/cve_hdf5 | `3fd1f5ae3869` |
|
||||||
|
| netcdf-c | https://github.com/Unidata/netcdf-c | `beb7b9585273` |
|
||||||
|
| NCAS-CMS_pyfive | https://github.com/NCAS-CMS/pyfive | `8cf07b874913` |
|
||||||
|
| usnistgov_h5wasm | https://github.com/usnistgov/h5wasm | `02f6336527d2` |
|
||||||
|
| netcdf4-python | https://github.com/Unidata/netcdf4-python | `6e67576d39ae` |
|
||||||
|
| xarray-data | https://github.com/pydata/xarray-data | `a35297e9da2c` |
|
||||||
|
| h5py_data | https://github.com/h5py/h5py (`h5py/tests/data_files`) | `b2f0347c4200` |
|
||||||
|
|
||||||
|
## Panics, hangs, crashes, out-of-memory
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Our-error root causes
|
||||||
|
|
||||||
|
Grouped by normalised error message. *files* counts files whose class this cause affects.
|
||||||
|
|
||||||
|
| files | objects | error | examples |
|
||||||
|
|---:|---:|---|---|
|
||||||
|
| 6 | 6 | `UnsupportedFilter(N)` | `hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_blosc.h5`, `hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_blosc2.h5`, `hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_bshuf.h5` (+3 more) |
|
||||||
|
| 3 | 3 | `DataSizeMismatch { expected: N, actual: N }` | `cve_hdf5/cvefiles/cve-2020-18494.h5`, `cve_hdf5/cvefiles/cve-2024-32623.h5`, `cve_hdf5/cvefiles/cve-2025-2309.h5` |
|
||||||
|
| 2 | 2 | `ChunkedReadError("…")` | `cve_hdf5/cvefiles/cve-2025-2308.h5`, `hdf5/test/testfiles/bad_nbit_parms_walk.h5` |
|
||||||
|
| 1 | 1 | `UnexpectedEof { expected: N, available: N }` | `cve_hdf5/cvefiles/cve-2019-9151.h5` |
|
||||||
|
| 1 | 1 | `MissingMessage(Dataspace)` | `cve_hdf5/cvefiles/cve-2024-33874.h5` |
|
||||||
|
| 1 | 1 | `InvalidObjectHeaderVersion(N)` | `hdf5/tools/test/testfiles/h5clear_mdc_image.h5` |
|
||||||
|
|
||||||
|
## Mismatch root causes
|
||||||
|
|
||||||
|
| files | objects | cause | examples |
|
||||||
|
|---:|---:|---|---|
|
||||||
|
| 13 | 14 | `missing-object` | `cve_hdf5/cvefiles/cve-2019-8397.h5`, `cve_hdf5/cvefiles/cve-2019-8398.h5`, `cve_hdf5/cvefiles/cve-2021-46243.h5` (+10 more) |
|
||||||
|
| 3 | 7 | `extra-attr` | `cve_hdf5/cvefiles/cve-2018-17438`, `cve_hdf5/cvefiles/cve-2018-17439`, `cve_hdf5/cvefiles/cve-2024-33874.h5` |
|
||||||
|
| 3 | 6 | `extra-object` | `cve_hdf5/cvefiles/cve-2021-46244.h5`, `hdf5/tools/test/testfiles/h5clear_fsm_persist_less.h5`, `hdf5/tools/test/testfiles/h5stat_err_refcount.h5` |
|
||||||
|
| 1 | 1 | `attr-values: ours=vlen(>u8) h5py=object layout=- filters=-` | `NCAS-CMS_pyfive/tests/data/attr_datatypes.hdf5` |
|
||||||
|
| 1 | 1 | `values: ours=<f4 h5py=float32 layout=chunked filters=-` | `cve_hdf5/cvefiles/cve-2025-44904.h5` |
|
||||||
|
| 1 | 1 | `values: ours=>i2 h5py=>i2 layout=chunked filters=[6]` | `cve_hdf5/cvefiles/cve-2025-44905.h5` |
|
||||||
|
| 1 | 1 | `values: ours=>f4 h5py=>f4 layout=chunked filters=[2]` | `cve_hdf5/cvefiles/cve-2025-44905.h5` |
|
||||||
|
| 1 | 1 | `values: ours=<f4 h5py=float32 layout=chunked filters=[2]` | `cve_hdf5/cvefiles/cve-2025-44905.h5` |
|
||||||
|
| 1 | 1 | `values: ours=((<i4)[6, 3])[4] h5py=(('<i4', (6, 3)), (4,)) layout=contiguous filters=-` | `hdf5/tools/test/testfiles/tarray3.h5` |
|
||||||
|
| 1 | 1 | `values: ours=vlen({r:>f4,i:>f4}8) h5py=object layout=contiguous filters=-` | `hdf5/tools/test/testfiles/tcomplex_be.h5` |
|
||||||
|
|
||||||
|
## CVE corpus: clawhdf5 vs h5dump vs h5py
|
||||||
|
|
||||||
|
The 147 files of [HDFGroup/cve_hdf5](https://github.com/HDFGroup/cve_hdf5) — reproducers for
|
||||||
|
published libhdf5 CVEs and fuzzer finds. *read* = produced output (possibly with per-object
|
||||||
|
errors), *error* = refused cleanly. h5dump exits non-zero on any error anywhere in a file, so
|
||||||
|
its read/error split is not comparable with the other two rows; the panic, crash, hang and oom
|
||||||
|
columns are.
|
||||||
|
|
||||||
|
| tool | read | error | panic | crash | hang | oom |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|
|
||||||
|
| clawhdf5 | 142 | 5 | 0 | 0 | 0 | 0 |
|
||||||
|
| h5dump 1.14.6 | 16 | 129 | 0 | 2 | 0 | 0 |
|
||||||
|
| h5py 3.16.0 / HDF5 2.0.0 | 115 | 31 | 0 | 1 | 0 | 0 |
|
||||||
|
|
||||||
|
<details><summary>Per-file outcomes</summary>
|
||||||
|
|
||||||
|
| file | h5dump | h5py | clawhdf5 | class |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| cvefiles/cve-2016-4330.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2016-4331.h5 | error exit | read 25 obj, 1 errors | read 25 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2016-4332-mtime-new.h5 | error exit | read 25 obj, 1 errors | read 25 obj | ok |
|
||||||
|
| cvefiles/cve-2016-4332-mtime.h5 | error exit | read 4 obj, 3 errors | read 4 obj | ok |
|
||||||
|
| cvefiles/cve-2016-4332-stab.h5 | error exit | open error | read 65 obj | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2016-4333.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2017-17505.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2017-17506.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2017-17507.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2017-17508.h5 | error exit | read 2 obj, 1 errors | read 2 obj | ok |
|
||||||
|
| cvefiles/cve-2017-17509.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-11202.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-11203.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-11204.h5 | error exit | read 2 obj, 1 errors | read 2 obj | ok |
|
||||||
|
| cvefiles/cve-2018-11205.h5 | error exit | read 2 obj, 1 errors | read 2 obj | ok |
|
||||||
|
| cvefiles/cve-2018-11206-new.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-11206-old.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-11207.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-13866.h5 | error exit | open error | open error | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2018-13867.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-13868.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-13869.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-13870.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-13871.h5 | error exit | read 2 obj | read 2 obj | ok |
|
||||||
|
| cvefiles/cve-2018-13872.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-13873.h5 | error exit | read 1 obj, 1 errors | read 1 obj | ok |
|
||||||
|
| cvefiles/cve-2018-13874.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2018-13875.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-13876.h5 | error exit | open error | read 2 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2018-14031.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-14033.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-14034.h5 | error exit | read 1 obj, 2 errors | read 1 obj | ok |
|
||||||
|
| cvefiles/cve-2018-14035.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-14460.h5 | error exit | read 3 obj, 2 errors | read 3 obj, 2 errors | ok |
|
||||||
|
| cvefiles/cve-2018-15671.h5 | ok | read 1 obj | read 1 obj | ok |
|
||||||
|
| cvefiles/cve-2018-15672.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-16438.h5 | error exit | read 1 obj, 1 errors | read 1 obj | ok |
|
||||||
|
| cvefiles/cve-2018-17233.h5 | error exit | read 6 obj, 1 errors | read 6 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-17234.h5 | error exit | read 6 obj, 1 errors | read 6 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-17237.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-17432.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-17433 | error exit | open error | open error | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2018-17434.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-17435.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-17436 | error exit | open error | open error | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2018-17437.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2018-17438 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | mismatch |
|
||||||
|
| cvefiles/cve-2018-17439 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | mismatch |
|
||||||
|
| cvefiles/cve-2019-8396.h5 | error exit | read 3 obj, 2 errors | read 3 obj, 2 errors | ok |
|
||||||
|
| cvefiles/cve-2019-8397.h5 | error exit | read 3 obj, 2 errors | read 2 obj, 1 errors | mismatch |
|
||||||
|
| cvefiles/cve-2019-8398.h5 | error exit | read 3 obj, 2 errors | read 2 obj, 1 errors | mismatch |
|
||||||
|
| cvefiles/cve-2019-9151.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 2 errors | our-error |
|
||||||
|
| cvefiles/cve-2019-9152.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2020-10809 | error exit | open error | open error | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2020-10810.h5 | error exit | open error | read 2 obj | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2020-10811.h5 | error exit | read 25 obj, 1 errors | read 25 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2020-10812.h5 | error exit | open error | read 2 obj | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2020-18232.h5 | error exit | read 3 obj, 2 errors | read 3 obj, 2 errors | ok |
|
||||||
|
| cvefiles/cve-2020-18494.h5 | ok | read 2 obj | read 2 obj, 1 errors | our-error |
|
||||||
|
| cvefiles/cve-2021-36977.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2021-37501.h5 | error exit | read 18 obj, 1 errors | read 18 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2021-45829.h5 | error exit | read 1 obj, 2 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2021-45830.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2021-45833.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2021-46242.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2021-46243.h5 | error exit | read 3 obj, 2 errors | read 2 obj, 1 errors | mismatch |
|
||||||
|
| cvefiles/cve-2021-46244.h5 | error exit | read 2 obj, 1 errors | read 6 obj, 3 errors | mismatch |
|
||||||
|
| cvefiles/cve-2024-29157.h5 | error exit | read 4 obj, 7 errors | read 4 obj, 7 errors | ok |
|
||||||
|
| cvefiles/cve-2024-29158.h5 | ok | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-29159.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-29160.h5 | error exit | read 4 obj, 1 errors | read 4 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-29161.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 2 errors | ok |
|
||||||
|
| cvefiles/cve-2024-29162.h5 | error exit | read 17 obj, 4 errors | read 17 obj, 3 errors | ok |
|
||||||
|
| cvefiles/cve-2024-29163.h5 | error exit | read 7 obj, 1 errors | read 7 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-29164.h5 | ok | read 3 obj | read 3 obj | ok |
|
||||||
|
| cvefiles/cve-2024-29165.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-29166.h5 | error exit | read 17 obj, 2 errors | read 17 obj | ok |
|
||||||
|
| cvefiles/cve-2024-32605.h5 | ok | read 6 obj, 1 errors | read 6 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-32606.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-32607-1.h5 | ok | read 10 obj | read 10 obj | ok |
|
||||||
|
| cvefiles/cve-2024-32607-2.h5 | error exit | read 9 obj, 1 errors | read 9 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-32608.h5 | error exit | read 6 obj, 1 errors | read 6 obj | ok |
|
||||||
|
| cvefiles/cve-2024-32609.h5 | error exit | SIGSEGV | read 3 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2024-32610.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-32611.h5 | ok | read 6 obj | read 6 obj | ok |
|
||||||
|
| cvefiles/cve-2024-32612.h5 | ok | read 3 obj | read 3 obj | ok |
|
||||||
|
| cvefiles/cve-2024-32613.h5 | error exit | read 7 obj, 1 errors | read 7 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-32614.h5 | error exit | read 25 obj, 2 errors | read 25 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-32615.h5 | error exit | read 4 obj, 1 errors | read 4 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-32616.h5 | error exit | read 10 obj, 7 errors | read 10 obj, 5 errors | ok |
|
||||||
|
| cvefiles/cve-2024-32617.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-32618.h5 | error exit | read 4 obj, 2 errors | read 3 obj | mismatch |
|
||||||
|
| cvefiles/cve-2024-32619.h5 | error exit | read 3 obj, 2 errors | read 3 obj | ok |
|
||||||
|
| cvefiles/cve-2024-32620.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-32621.h5 | ok | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-32622.h5 | ok | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-32623.h5 | ok | read 6 obj | read 6 obj, 1 errors | our-error |
|
||||||
|
| cvefiles/cve-2024-32624.h5 | error exit | read 6 obj, 1 errors | read 6 obj | ok |
|
||||||
|
| cvefiles/cve-2024-33873.h5 | error exit | read 4 obj, 1 errors | read 4 obj | ok |
|
||||||
|
| cvefiles/cve-2024-33874.h5 | ok | read 6 obj, 1 errors | read 6 obj, 1 errors | our-error |
|
||||||
|
| cvefiles/cve-2024-33875.h5 | ok | read 2 obj | read 2 obj | ok |
|
||||||
|
| cvefiles/cve-2024-33876.h5 | ok | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2024-33877.h5 | error exit | read 8 obj, 1 errors | read 8 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2025-2153.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-2308.h5 | error exit | read 25 obj, 1 errors | read 25 obj, 2 errors | our-error |
|
||||||
|
| cvefiles/cve-2025-2309.h5 | ok | read 6 obj, 1 errors | read 6 obj, 1 errors | our-error |
|
||||||
|
| cvefiles/cve-2025-2310.h5 | error exit | read 24 obj, 8 errors | read 24 obj, 8 errors | ok |
|
||||||
|
| cvefiles/cve-2025-2912.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-2913.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-2914.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-2915.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-2923.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-2924.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2025-2925.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2025-2926.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-44904.h5 | error exit | read 25 obj, 1 errors | read 25 obj, 1 errors | mismatch |
|
||||||
|
| cvefiles/cve-2025-44905.h5 | error exit | read 25 obj, 3 errors | read 25 obj, 3 errors | mismatch |
|
||||||
|
| cvefiles/cve-2025-6269-1.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2025-6269-2.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2025-6269-3.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2025-6269-4.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2025-6270-1.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-6270-2.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-6270-3.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-6516.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2025-6750.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-6816.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-6817.h5 | error exit | open error | read 1 obj | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-6818.h5 | error exit | open error | read 1 obj | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-6856.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-6857.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2025-6858.h5 | SIGSEGV | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-7067.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2025-7068.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2025-7069.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| cvefiles/cve-2026-26200.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| cvefiles/cve-2026-34734.h5 | error exit | read 2 obj, 1 errors | read 2 obj | ok |
|
||||||
|
| cvefiles/cve-2026-92627.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
|
| cvefiles/unknown-1.h5 | error exit | read 11 obj, 1 errors | read 11 obj, 1 errors | ok |
|
||||||
|
| fuzzerfiles/gh-4431-poc-03.h5 | error exit | read 1 obj | read 1 obj | ok |
|
||||||
|
| fuzzerfiles/gh-4432-poc-05.h5 | SIGSEGV | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| fuzzerfiles/gh-4433-poc-08.h5 | error exit | read 1 obj, 1 errors | read 1 obj | ok |
|
||||||
|
| fuzzerfiles/gh-4434-poc-09.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
|
||||||
|
| fuzzerfiles/gh-4435-poc-10.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
|
||||||
|
| fuzzerfiles/gh-4585.h5 | error exit | open error | open error | h5py-cannot-read |
|
||||||
|
| fuzzerfiles/gh_2649_flawed.h5 | error exit | read 9 obj, 1 errors | read 9 obj, 1 errors | ok |
|
||||||
|
| fuzzerfiles/gh_2649_plain_model.h5 | ok | read 10 obj | read 10 obj | ok |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Known not-our-bug
|
||||||
|
|
||||||
|
- **h5py big-endian variable-length sequences.** h5py returns the elements of a VL sequence
|
||||||
|
whose base type is big-endian with the file's big-endian bytes but a native (little-endian)
|
||||||
|
numpy dtype, so the values it reports are byte-swapped garbage; `h5dump` prints the values
|
||||||
|
clawhdf5 reads. Reproducer: `h5py.vlen_dtype(np.dtype('>f4'))` dataset holding `[1.0, 2.0]`
|
||||||
|
reads back in h5py as `[4.6e-41, 9.0e-44]`. Affected here: `NCAS-CMS_pyfive/tests/data/attr_datatypes.hdf5`, `hdf5/tools/test/testfiles/tcomplex_be.h5`.
|
||||||
|
- **Non-IEEE floats and partial-precision integers (N-Bit).** libhdf5 converts a float whose
|
||||||
|
bit layout is not IEEE (e.g. `H5Tset_precision` for the N-Bit filter) or an integer with a
|
||||||
|
bit offset / reduced precision into the plain numpy type of the same size. The probe
|
||||||
|
compares such values as converted numbers, not raw file bytes (before 2026-09-25 it compared
|
||||||
|
raw bytes, which reported every N-Bit float dataset as a mismatch).
|
||||||
|
- **Types h5py widens.** Where h5py reads a type into a numpy type of a different size
|
||||||
|
(FP8 -> float16, bfloat16 -> float32, x87 long double -> float128) the values are not
|
||||||
|
compared (shape and presence still are): dataset file type size 1 -> numpy float16 (2) (15x), attr file type size 1 -> numpy float16 (2) (15x), dataset file type size 2 -> numpy float32 (4) (2x), dataset file type size 8 -> numpy float128 (16) (1x), dataset file type size 12 -> numpy float128 (16) (1x), attr file type size 2 -> numpy float32 (4) (1x), dataset file type size 2 -> numpy >f4 (4) (1x), attr file type size 2 -> numpy >f4 (4) (1x).
|
||||||
|
- **References** are compared by presence only (`R`), not by target.
|
||||||
|
|
||||||
|
## Objects h5py fails on but clawhdf5 reads
|
||||||
|
|
||||||
|
- 19 x `KeyError: '…'`
|
||||||
|
- 19 x `OSError: Can't synchronously read data (no appropriate function for conversion path)`
|
||||||
|
- 1 x `TypeError: unhandled dtype kind M (dtype('…'))`
|
||||||
|
- 1 x `OSError: Can't synchronously read data (bad coordinate offset)`
|
||||||
|
- 1 x `TypeError: No NumPy equivalent for TypeTimeID exists`
|
||||||
|
- 1 x `KeyError: "…"`
|
||||||
|
- 1 x `ValueError: Insufficient precision in available types to represent (N, N, N, N, N)`
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# needs: Rust, python3 with h5py numpy hdf5plugin (conformance/requirements.txt), h5dump (hdf5-tools), git
|
||||||
|
CLAWHDF5_PYTHON=/path/to/venv/bin/python conformance/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The corpus (about 450 MB of sparse checkouts) is cached in `conformance/.cache/`; results for
|
||||||
|
every file, both sides' raw JSON and stderr, are in `conformance/.cache/results/`.
|
||||||
|
`conformance/baseline.json` holds the ok files the nightly CI job (`.gitea/workflows/conformance.yml`)
|
||||||
|
must keep; `conformance/run.sh --update-baseline` rewrites it.
|
||||||
+4
-1
@@ -21,8 +21,11 @@ members = [
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "2.2.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
# Oldest toolchain that builds the whole workspace; CI checks it. wgpu (in
|
||||||
|
# clawhdf5-gpu) requires 1.92.
|
||||||
|
rust-version = "1.92"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||||
|
|
||||||
|
|||||||
@@ -3,24 +3,102 @@
|
|||||||
**The memory layer AI agents deserve. One file. Pure Rust. Zero C dependencies.**
|
**The memory layer AI agents deserve. One file. Pure Rust. Zero C dependencies.**
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.rust-lang.org)
|
[](https://www.rust-lang.org)
|
||||||
[](#performance)
|
[](#building)
|
||||||
[](BENCHMARKS.md#longmemeval-results)
|
[](BENCHMARKS.md#longmemeval-results)
|
||||||
[](BENCHMARKS.md#memory-footprint)
|
[](BENCHMARKS.md#memory-footprint-1)
|
||||||
|
|
||||||
ClawHDF5 is a pure-Rust HDF5 implementation combined with a research-grade agent memory engine. It gives AI agents persistent, searchable, cryptographically verifiable memory — all stored in a single portable file.
|
ClawHDF5 is a pure-Rust HDF5 implementation combined with a research-grade agent memory engine. It gives AI agents persistent, searchable, cryptographically verifiable memory (Ed25519-signed checkpoints) — all stored in a single portable file.
|
||||||
|
|
||||||
> **Two things live here:**
|
> **Two things live here:**
|
||||||
> - **A general-purpose, pure-Rust HDF5 library** — zero C dependencies, NetCDF-4 support, SIMD/GPU acceleration. See the **[Crate Map](#crate-map)** and **[BENCHMARKS.md](BENCHMARKS.md)** for the libhdf5 head-to-head numbers.
|
> - **A general-purpose, pure-Rust HDF5 library** — zero C dependencies, NetCDF-4 support, SIMD/GPU acceleration. See the **[Crate Map](#crate-map)** and **[BENCHMARKS.md](BENCHMARKS.md)** for the libhdf5 head-to-head numbers.
|
||||||
> - **An agent memory layer built on top of it** — vector search, knowledge graph, hippocampal-style consolidation, in `clawhdf5-agent`.
|
> - **An agent memory layer built on top of it** — vector search, knowledge graph, hippocampal-style consolidation, in `clawhdf5-agent`.
|
||||||
|
|
||||||
```
|
The crates are not on crates.io yet, so depend on them from git:
|
||||||
cargo add clawhdf5 # core HDF5 read/write, no agent layer
|
|
||||||
cargo add clawhdf5-agent --features agent # + agent memory layer
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
clawhdf5 = { git = "https://git.redclaw.dev/quantumclaw/clawhdf5" } # core HDF5 read/write
|
||||||
|
clawhdf5-agent = { git = "https://git.redclaw.dev/quantumclaw/clawhdf5" } # + agent memory layer
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **C dependencies, precisely:** the core crates (`clawhdf5`, `clawhdf5-agent`,
|
||||||
|
> `-format`, `-io`, `-filters`, `-ann`, `-accel`, `-netcdf4`, `-cli`) build no C
|
||||||
|
> code by default — no libhdf5, and deflate is the pure-Rust
|
||||||
|
> [zlib-rs](https://github.com/trifectatechfoundation/zlib-rs), which matches
|
||||||
|
> zlib-ng on HDF5 reads and writes and produces byte-identical output
|
||||||
|
> ([BENCHMARKS.md § Deflate backend](BENCHMARKS.md#deflate-backend-zlib-rs-vs-zlib-ng)).
|
||||||
|
> CI fails if a C-building crate enters their default dependency tree. C comes
|
||||||
|
> in only when you ask for it: `fast-deflate` (zlib-ng, needs cmake), `zstd`,
|
||||||
|
> `szip`, the BLAS backends, `clawhdf5-migrate` (bundled SQLite) and the
|
||||||
|
> Node.js bindings.
|
||||||
|
|
||||||
> **New here?** Start with the **[Quickstart Guide](docs/QUICKSTART.md)** · See **[Use Cases](docs/USE_CASES.md)** · Read **[Benchmarks](BENCHMARKS.md)**
|
> **New here?** Start with the **[Quickstart Guide](docs/QUICKSTART.md)** · See **[Use Cases](docs/USE_CASES.md)** · Read **[Benchmarks](BENCHMARKS.md)**
|
||||||
|
|
||||||
|
## What's new (v2.2 → v2.7, and unreleased)
|
||||||
|
|
||||||
|
Five releases in September 2026. Details, including upgrade notes and every
|
||||||
|
breaking change, are in [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
|
**HDF5 correctness (read these if you read files with an earlier release)**
|
||||||
|
- **Extensible Array chunk indexes returned wrong data** past the 36th chunk —
|
||||||
|
any dataset with one unlimited dimension. Silent: plausible numbers from the
|
||||||
|
wrong chunks. Fixed in v2.7.0; re-read affected data.
|
||||||
|
- Fixed and Extensible Array checksums are now verified, so a corrupt chunk
|
||||||
|
index is `ChecksumMismatch` instead of wrong data (v2.7.0).
|
||||||
|
- Compound datatypes written with default libver bounds (plain
|
||||||
|
`h5py.File(path, 'w')`) were mis-parsed; HDF5 2.0 compound v5 and native
|
||||||
|
complex (class 11) types now parse (v2.2.0–v2.3.0).
|
||||||
|
- Committed datatypes, fill values, soft links and `H5T_STD_REF` references now
|
||||||
|
read correctly; external links and external raw data are explicit errors;
|
||||||
|
`attrs()` no longer silently drops attributes (v2.3.0–v2.5.0).
|
||||||
|
- Datasets indexed by a version-2 B-tree now read (v2.5.0).
|
||||||
|
|
||||||
|
**Security and robustness**
|
||||||
|
- A crafted file could abort any reader via B-tree v2 recursion or explode it
|
||||||
|
via shared children; both are now fast errors (v2.7.0).
|
||||||
|
- Virtual-dataset source paths are confined to the file's directory; chunked
|
||||||
|
reads use overflow-checked sizes and fallible allocation, and the facade
|
||||||
|
writes files atomically (v2.3.0).
|
||||||
|
- Agent store: single-writer lock plus `open_read_only`; a crash between
|
||||||
|
checkpoint and WAL truncate no longer duplicates entries; unreadable WALs are
|
||||||
|
quarantined instead of blocking `open()` (v2.3.0).
|
||||||
|
|
||||||
|
**Search quality and speed**
|
||||||
|
- HNSW neighbour selection now uses the paper's diversity heuristic: recall@10
|
||||||
|
at 100K went from 0.31 to 0.98 (v2.4.0).
|
||||||
|
- `hybrid_search` is 79–190× faster than v2.3.0 (p50 0.07 ms at 1K, 4.65 ms at
|
||||||
|
100K). It no longer rebuilds BM25 or rewrites the store per query, and the
|
||||||
|
HNSW graph is persisted (v2.4.0).
|
||||||
|
- Default fusion weights are now the measured 0.4 / 0.6 (v2.5.0). Re-ranking had
|
||||||
|
been discarding the retrieval score, costing the Markdown backend 40.6pp of
|
||||||
|
Hit@1; fixed in v2.6.0.
|
||||||
|
- Selection reads decode only the chunks they touch (a 64×64 window: 105 ms to
|
||||||
|
0.39 ms), and full reads are 1.2–1.9× faster (v2.5.0).
|
||||||
|
|
||||||
|
**Memory**
|
||||||
|
- A loaded store holds ~30% less (embeddings stored once, v2.6.0), and the
|
||||||
|
int8 HNSW index, **on by default for new stores** (unreleased), brings a
|
||||||
|
100K × 384 store to 1.74× the raw vectors. At equal recall it is also faster
|
||||||
|
than `f32`: 1.63× QPS on AVX2, 1.18× on a Raspberry Pi 5 (NEON `SDOT`).
|
||||||
|
|
||||||
|
**Interop and search (unreleased)**
|
||||||
|
- **Files we write now open in h5py and libhdf5.** Every `f32` dataset —
|
||||||
|
including every agent store's embeddings — and every empty dataset was
|
||||||
|
refused by libhdf5. Both were write-side bugs in every release; agent stores
|
||||||
|
fix themselves at their next checkpoint. See
|
||||||
|
[docs/known-issues.md](docs/known-issues.md).
|
||||||
|
- `MemoryConfig::float16` now stores half-precision embeddings (it was
|
||||||
|
ignored), and is on by default for new stores: 48% smaller files, and
|
||||||
|
identical LongMemEval retrieval on real embeddings.
|
||||||
|
- `HDF5Memory::search` with `SearchOptions`: filter by source channel (exact
|
||||||
|
filtered top-k, never slower than unfiltered), and opt-in re-ranking and
|
||||||
|
confidence rejection, which used to be reachable only through `ClawhdfBackend`.
|
||||||
|
|
||||||
|
**Tooling**
|
||||||
|
- CI now runs the h5py/netCDF4 interop suites for real (they had been skipping
|
||||||
|
silently) and runs an aarch64 job for the NEON kernels.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Why ClawhDF5?
|
## Why ClawhDF5?
|
||||||
@@ -33,16 +111,16 @@ Every AI agent needs memory. Today that means scattered Markdown files, SQLite d
|
|||||||
| Keyword search | Separate FTS engine | Integrated BM25 |
|
| Keyword search | Separate FTS engine | Integrated BM25 |
|
||||||
| Knowledge graph | Neo4j or none | In-file graph with spreading activation |
|
| Knowledge graph | Neo4j or none | In-file graph with spreading activation |
|
||||||
| Memory consolidation | Manual pruning | Hippocampal-inspired automatic tiers |
|
| Memory consolidation | Manual pruning | Hippocampal-inspired automatic tiers |
|
||||||
| Temporal queries | Custom code | Native temporal index (716ns) |
|
| Temporal queries | Custom code | Native temporal index (622 ns range query over 10K) |
|
||||||
| Multi-modal | Multiple stores | Unified cross-modal search |
|
| Multi-modal | Multiple stores | Unified cross-modal search (exact scan: 842 µs over 1K records) |
|
||||||
| Security | Hope for the best | Provenance tracking + anomaly detection |
|
| Integrity | Hope for the best | Ed25519-signed checkpoints that pinpoint any edited record, chained-CRC WAL, checksummed chunk indexes, write-anomaly alerts |
|
||||||
| Portability | Config + DB + files | **One `.h5` file. Copy it anywhere.** |
|
| Portability | Config + DB + files | **One `.h5` file. Copy it anywhere.** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
Vector search and agent-memory operations below are benchmarked on Intel i7-12650H (10C/16T), 384-dim embeddings, Criterion.rs. The HDF5 Core I/O table immediately below is from a separate, independently reproduced run (see its own hardware note).
|
The brute-force/IVF vector search, agent-memory, on-disk footprint and consolidation figures below were measured 2026-09-24 on tank (AMD Ryzen 7 7800X3D, 8C/16T), commit 5c8323c, 384-dim embeddings; the commands are in [BENCHMARKS.md](BENCHMARKS.md). Exceptions are marked where they appear: the HDF5 Core I/O table immediately below is from a separate, independently reproduced run (see its own hardware note), and the HNSW `f32`/`i8` table and the in-memory `i8` column were not re-measured on 2026-09-24.
|
||||||
|
|
||||||
### HDF5 Core I/O (vs libhdf5 1.14.6)
|
### HDF5 Core I/O (vs libhdf5 1.14.6)
|
||||||
|
|
||||||
@@ -58,31 +136,63 @@ Figures below are from an independent reproduction run on a second machine (AMD
|
|||||||
| Sequential read (100K f32) | 23.3 µs | 63.6 µs | **2.7×** |
|
| Sequential read (100K f32) | 23.3 µs | 63.6 µs | **2.7×** |
|
||||||
| Sequential write (100K f32) | 210 µs | 189 µs | **≈ tie** |
|
| Sequential write (100K f32) | 210 µs | 189 µs | **≈ tie** |
|
||||||
|
|
||||||
|
The chunked-write row was re-measured on the same machine on 2026-09-23, after
|
||||||
|
the default deflate backend became pure-Rust zlib-rs: 1.46 ms against
|
||||||
|
libhdf5's 51.4 ms (**35×**), and 1.48 ms with zlib-ng. libhdf5's own time on
|
||||||
|
that machine moved from 65.0 to 51.4 ms between the two dates, which is most
|
||||||
|
of the difference from 45×; compare same-day numbers only.
|
||||||
|
|
||||||
### Vector Search
|
### Vector Search
|
||||||
|
|
||||||
| Scale | Flat | IVF (nprobe=10) | IVF-PQ | vs MemX¹ |
|
**HNSW (the default backend for `hybrid_search`)** — `search_harness`, clustered
|
||||||
|-------|------|-----------------|--------|----------|
|
384-dim data, M = 16, ef_construction = 64, recall measured against an exact scan.
|
||||||
| 1K | **54 µs** | — | — | — |
|
See [BENCHMARKS.md § Search harness](BENCHMARKS.md#search-harness-baseline-v230)
|
||||||
| 10K | 753 µs | **27 µs** | — | — |
|
and [§ Quantising the index copy](BENCHMARKS.md#quantising-the-index-copy-quantized_index):
|
||||||
| 100K | 11.4 ms | 1.32 ms | **1.19 ms** | ~8–76× (see caveat) |
|
|
||||||
|
|
||||||
> Reproduced on the same second machine (Ryzen 7 7800X3D) with a corrected,
|
| N = 100K, ef = 64 | recall@10 | QPS | build |
|
||||||
> apples-to-apples SIMD/scalar/parallel comparison methodology — see
|
|---|---:|---:|---:|
|
||||||
> [BENCHMARKS.md § Independent Validation: tank — LongMemEval & Vector
|
| `f32` index | 0.9945 | 13 399 | 3.2 s |
|
||||||
> Search](BENCHMARKS.md#independent-validation-tank--longmemeval--vector-search-ryzen-7-7800x3d-2026-08-05).
|
| `i8` index + exact re-score (**default for new stores**) | 0.9940 | **21 848** | **1.8 s** |
|
||||||
|
|
||||||
|
Before the v2.4.0 neighbour-selection fix, recall@10 at 100K was 0.31. These
|
||||||
|
two rows are a paired comparison (medians of alternating runs, same binary).
|
||||||
|
A single `f32` run on 2026-09-24 measured recall 0.9945, 19 001 QPS and a
|
||||||
|
2.7 s build; the int8 row was not re-run, so the pair has not been re-checked
|
||||||
|
([§ Quantising the index copy](BENCHMARKS.md#quantising-the-index-copy-quantized_index)).
|
||||||
|
|
||||||
|
**Brute-force and IVF paths** (Criterion, tank, 2026-09-24):
|
||||||
|
|
||||||
|
| Scale | Flat | IVF (nprobe=10) | IVF-PQ | MemX¹ (claimed, end-to-end) |
|
||||||
|
|-------|------|-----------------|--------|----------|
|
||||||
|
| 1K | **47.4 µs** | — | — | — |
|
||||||
|
| 10K | 500.5 µs | **24.8 µs** | — | — |
|
||||||
|
| 100K | 6.58 ms | 592 µs | **869 µs** | <90 ms |
|
||||||
|
|
||||||
|
> These replace figures from the original i7-12650H run (flat 54 µs / 753 µs /
|
||||||
|
> 11.4 ms); a 2026-08-05 run on tank had already matched the new ones — see
|
||||||
|
> [BENCHMARKS.md § Vector Search Latency](BENCHMARKS.md#vector-search-latency).
|
||||||
|
|
||||||
### Agent Memory Operations
|
### Agent Memory Operations
|
||||||
|
|
||||||
| Operation | Latency | Scale |
|
| Operation | Latency | Scale |
|
||||||
|-----------|---------|-------|
|
|-----------|---------|-------|
|
||||||
| Hybrid search (RRF) | **222 µs** | 1K records |
|
| Hybrid search (`HDF5Memory::hybrid_search`, p50) | **0.07 ms** / 0.49 ms / 4.69 ms | 1K / 10K / 100K records |
|
||||||
| BM25 keyword search | **67 µs** | 1K records |
|
| BM25 keyword search | **20.4 µs** | 1K records |
|
||||||
| Knowledge graph BFS | **24 µs** | 1K entities |
|
| Knowledge graph BFS | **23.1 µs** | 1K entities |
|
||||||
| Spreading activation | **17 µs** | 100 entities |
|
| Spreading activation | **10.1 µs** | 100 entities |
|
||||||
| Temporal range query | **716 ns** | 10K timestamps |
|
| Temporal range query | **622 ns** | 10K timestamps |
|
||||||
| Consolidation cycle | **164 µs** | 1K records |
|
| Consolidation cycle | **115.2 µs** | 1K records |
|
||||||
| Memory write (WAL) | **18 µs** | per record (group-commit append; HDF5 batched at flush) |
|
| Cross-modal search (exact scan, 2 embeddings per record) | **842.0 µs** / 8.44 ms | 1K / 10K records |
|
||||||
| Importance gate | **61 ns** | per record |
|
| Memory write (WAL) | **26.1 µs** | per record (group-commit append; HDF5 batched at flush) |
|
||||||
|
| Importance gate | **57.6 ns** | per record (trivial skip) |
|
||||||
|
|
||||||
|
The old 18 µs WAL write was undated, from another machine: v2.3.0 measures
|
||||||
|
24.3 µs on the same hardware as this table, the same as an `f32` store today.
|
||||||
|
`float16` stores (the new default) add ~2 µs for rounding; the int8 index adds
|
||||||
|
nothing. See [BENCHMARKS.md § Write Path](BENCHMARKS.md#write-path).
|
||||||
|
Knowledge-graph traversal was briefly 6.5x slower (155 µs) until this re-run
|
||||||
|
found and fixed an adjacency index rebuilt on every traversal; see
|
||||||
|
[§ Knowledge Graph](BENCHMARKS.md#knowledge-graph).
|
||||||
|
|
||||||
### Chunked Write Throughput (codec comparison)
|
### Chunked Write Throughput (codec comparison)
|
||||||
|
|
||||||
@@ -97,7 +207,7 @@ by default (AoS→SoA byte transpose, +157–204% throughput for float data):
|
|||||||
|
|
||||||
Use `.with_zstd(3)` or `.with_deflate(6)` for write-heavy workloads — both now perform at ~720–750 MiB/s on large matrices. Use `.with_pcodec()` for write-once/read-many workloads where compression ratio matters more than encode speed. Disable auto-shuffle with `.without_shuffle()` for byte arrays that don't benefit from AoS→SoA transposition.
|
Use `.with_zstd(3)` or `.with_deflate(6)` for write-heavy workloads — both now perform at ~720–750 MiB/s on large matrices. Use `.with_pcodec()` for write-once/read-many workloads where compression ratio matters more than encode speed. Disable auto-shuffle with `.without_shuffle()` for byte arrays that don't benefit from AoS→SoA transposition.
|
||||||
|
|
||||||
> ¹ MemX ([arxiv:2603.16171](https://arxiv.org/abs/2603.16171), March 2026): Rust + libSQL, claims <90ms at 100K records. **Not like-for-like:** MemX's figure is *end-to-end* (embeddings + FTS5 + four-factor re-ranking); ours is a *single component* (raw vector search). The ratio overstates the real advantage by an unquantified margin — order-of-magnitude indication only. See [BENCHMARKS.md](BENCHMARKS.md#comparison-to-memx-arxiv260316171).
|
> ¹ MemX ([arxiv:2603.16171](https://arxiv.org/abs/2603.16171), March 2026): Rust + libSQL, claims <90ms at 100K records. **Not like-for-like:** MemX's figure is *end-to-end* (embeddings + FTS5 + four-factor re-ranking); ours is a *single component* (raw vector search), so the two columns are not comparable and no ratio is given. See [BENCHMARKS.md](BENCHMARKS.md#comparison-to-memx-arxiv260316171).
|
||||||
|
|
||||||
### LongMemEval Retrieval Recall
|
### LongMemEval Retrieval Recall
|
||||||
|
|
||||||
@@ -115,13 +225,17 @@ declaration:
|
|||||||
|
|
||||||
Hybrid is the strongest configuration, which is what running two retrieval stages
|
Hybrid is the strongest configuration, which is what running two retrieval stages
|
||||||
is for. The weights matter more than the stages: a sweep of `vector_weight` from
|
is for. The weights matter more than the stages: a sweep of `vector_weight` from
|
||||||
0.0 to 1.0 found the long-standing `0.7/0.3` default is **strictly dominated** by
|
0.0 to 1.0 found the old `0.7/0.3` default is **strictly dominated** by
|
||||||
`0.4/0.6` — better on Hit@1, Hit@5, Hit@10 and MRR at both granularities. Use
|
`0.4/0.6` — better on Hit@1, Hit@5, Hit@10 and MRR at both granularities. Since
|
||||||
`0.4/0.6`, or `0.3/0.7` if rank-1 precision matters most. See
|
v2.5.0 `0.4/0.6` is the default (`hybrid::DEFAULT_FUSION`, used by
|
||||||
[BENCHMARKS.md § Weight sweep](BENCHMARKS.md#longmemeval-results).
|
`unified_search`, `hybrid_search_with` and `ClawhdfBackend`); callers that
|
||||||
|
pass weights to `hybrid_search` explicitly choose their own. Use `0.3/0.7` if
|
||||||
|
rank-1 precision matters most. Reciprocal rank fusion is selectable
|
||||||
|
(`hybrid::Fusion::Rrf`) but measured worse than the weighted sum. See
|
||||||
|
[BENCHMARKS.md § Weight sweep](BENCHMARKS.md#weight-sweep--full-haystack-n500).
|
||||||
|
|
||||||
Vector embeddings require `--features embeddings`; without it the vector stage is
|
The benchmark's vector stage requires `clawhdf5-bench`'s `embeddings` feature
|
||||||
inert and only the BM25 row is produced, which is what every previously published
|
(real MiniLM embeddings); without it the vector stage is inert and only the BM25 row is produced, which is what every previously published
|
||||||
number here measured.
|
number here measured.
|
||||||
|
|
||||||
On the easier `longmemeval_oracle` variant (evidence sessions only) the same
|
On the easier `longmemeval_oracle` variant (evidence sessions only) the same
|
||||||
@@ -146,19 +260,53 @@ retrieval recall reported as QA accuracy typically overstates by 20–30 points.
|
|||||||
|
|
||||||
### Memory Footprint
|
### Memory Footprint
|
||||||
|
|
||||||
| Records | File Size | Bytes/Record | With Compression |
|
**On disk** — 384-dim `float16` embeddings (the default for new stores),
|
||||||
|---------|-----------|--------------|------------------|
|
200-char text, `footprint_bench`
|
||||||
| 1K | ~6.5 MB | ~6.5 KB | ~2.1 MB (3.1x) |
|
([BENCHMARKS.md § Memory Footprint](BENCHMARKS.md#memory-footprint-1)):
|
||||||
| 10K | ~65 MB | ~6.5 KB | ~21 MB (3.1x) |
|
|
||||||
| 100K | ~645 MB | ~6.5 KB | ~208 MB (3.1x) |
|
| Records | File Size | Bytes/Record | Gzip-6 compressed |
|
||||||
|
|---------|-----------|--------------|-------------------|
|
||||||
|
| 1K | 810.4 KB | 829 B | 56.4 KB |
|
||||||
|
| 10K | 7.8 MB | 820 B | 471.3 KB |
|
||||||
|
| 100K | 76.7 MB | 803 B | 4.5 MB |
|
||||||
|
|
||||||
|
The benchmark's synthetic embeddings and text are far more repetitive than
|
||||||
|
real data (only 40 distinct texts), so no column here is an expectation for
|
||||||
|
real data. The compressed column is an upper bound, and the Bytes/Record
|
||||||
|
column is optimistic too: it is not an uncompressed figure, because the store
|
||||||
|
always deflates its text (any string dataset of 4 KiB or more) whatever
|
||||||
|
`MemoryConfig::compression` says. The `float16` embeddings alone are 768 B per
|
||||||
|
record, so 200 characters of real text would take a record above 820 B.
|
||||||
|
This table used to show `f32` stores (1.7 KB per record, 169.8 MB at 100K);
|
||||||
|
those were not re-measured. The float16 study compares the two on the same
|
||||||
|
data: 100K × 384 records take 80.8 MiB as `float16` and 154.0 MiB as `f32`.
|
||||||
|
|
||||||
|
**In memory** — a store reopened from disk, 384-dim `f32`, measured with a
|
||||||
|
counting allocator ([BENCHMARKS.md § Memory footprint](BENCHMARKS.md#memory-footprint)):
|
||||||
|
|
||||||
|
| Records | Raw vectors | Reopened, `f32` index | Reopened, `i8` index (default) |
|
||||||
|
|---------|-------------|-----------------------|--------------------------------|
|
||||||
|
| 1K | 1 MiB | 4 MiB (2.40x) | 2 MiB (1.64x) |
|
||||||
|
| 10K | 15 MiB | 44 MiB (3.03x) | 27 MiB (1.81x) |
|
||||||
|
| 100K | 146 MiB | 399 MiB (2.72x) | **256 MiB (1.74x)** |
|
||||||
|
|
||||||
|
Down from 505 MiB (3.44x) at 100K before v2.6.0, when the cache held every
|
||||||
|
embedding twice. The `f32` column was re-measured on 2026-09-24 and reproduced
|
||||||
|
exactly; the `i8` column was not re-run.
|
||||||
|
|
||||||
### Consolidation Efficiency
|
### Consolidation Efficiency
|
||||||
|
|
||||||
|
1,000 records (10 signal + 990 noise), `working_capacity = 100`
|
||||||
|
([BENCHMARKS.md § Consolidation Efficiency](BENCHMARKS.md#consolidation-efficiency)):
|
||||||
|
|
||||||
| Metric | Before | After | Delta |
|
| Metric | Before | After | Delta |
|
||||||
|--------|--------|-------|-------|
|
|--------|--------|-------|-------|
|
||||||
| Records in store | 1,000 | ~110 | −89% |
|
| Records in store | 1,000 | 100 | −90% |
|
||||||
| Hit@1 recall | ~60% | ~90% | +30% |
|
| Hit@1 recall (signal records) | 100% | 100% | no loss |
|
||||||
| Search latency | ~2.8 ms | ~0.3 ms | **9x faster** |
|
| Search latency (avg) | 2.22 ms | 0.24 ms | **9.3x faster** |
|
||||||
|
|
||||||
|
The consolidation cycle that does this took 0.13 ms; a cycle over 10K records
|
||||||
|
takes 2.81 ms and over 100K 46.7 ms.
|
||||||
|
|
||||||
**Full benchmark details: [BENCHMARKS.md](BENCHMARKS.md)**
|
**Full benchmark details: [BENCHMARKS.md](BENCHMARKS.md)**
|
||||||
|
|
||||||
@@ -166,74 +314,74 @@ retrieval recall reported as QA accuracy typically overstates by 20–30 points.
|
|||||||
|
|
||||||
## Agent Memory Architecture
|
## Agent Memory Architecture
|
||||||
|
|
||||||
ClawhDF5's agent memory engine implements research from 15+ recent papers on agentic memory systems. It's not a toy — it's the real thing.
|
ClawhDF5's agent memory engine draws on 15+ recent papers on agentic memory systems (see [Research Foundation](#research-foundation)).
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────┐
|
┌─────────────────┐
|
||||||
│ Agent Query │
|
│ Agent Query │
|
||||||
└────────┬────────┘
|
└────────┬────────┘
|
||||||
│
|
│
|
||||||
┌────────────▼────────────┐
|
┌─────────────────▼──────────────────┐
|
||||||
│ Hybrid Retrieval │
|
│ HDF5Memory::search │
|
||||||
│ Vector + BM25 + RRF │
|
│ optional source-channel filter │
|
||||||
└────────────┬────────────┘
|
│ HNSW vector + BM25 keyword │
|
||||||
│
|
│ weighted fusion (0.4 / 0.6) │
|
||||||
┌──────────────────▼──────────────────┐
|
│ × √(Hebbian activation) │
|
||||||
│ Multi-Factor Re-Ranking │
|
└─────────────────┬──────────────────┘
|
||||||
│ temporal · authority · activation │
|
│ opt-in (SearchOptions);
|
||||||
└──────────────────┬──────────────────┘
|
│ ClawhdfBackend turns both on
|
||||||
│
|
┌─────────────────▼──────────────────┐
|
||||||
┌────────────▼────────────┐
|
│ Multi-factor re-ranking │
|
||||||
│ Confidence Rejection │
|
│ relevance · recency · authority · │
|
||||||
│ (suppress bad matches) │
|
│ activation │
|
||||||
└────────────┬────────────┘
|
├────────────────────────────────────┤
|
||||||
│
|
│ Confidence rejection │
|
||||||
┌────────────────────────▼────────────────────────┐
|
│ (suppress bad matches) │
|
||||||
│ Memory Store (HDF5) │
|
└─────────────────┬──────────────────┘
|
||||||
│ │
|
│
|
||||||
│ ┌───────────┐ ┌───────────┐ ┌───────────────┐ │
|
┌────────────────────────────▼────────────────────────────┐
|
||||||
│ │ Working │→│ Episodic │→│ Semantic │ │
|
│ In memory │
|
||||||
│ │ (bounded) │ │ (bounded) │ │ (long-term) │ │
|
│ cache (flat f32 embeddings) · BM25 index · HNSW index │
|
||||||
│ └───────────┘ └───────────┘ └───────────────┘ │
|
│ provenance ledger + anomaly alerts (session-scoped) │
|
||||||
│ │
|
└────────────────────────────┬────────────────────────────┘
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
|
│ WAL append; checkpoint
|
||||||
│ │Knowledge │ │Temporal │ │ Multi-Modal │ │
|
┌────────────────────────────▼────────────────────────────┐
|
||||||
│ │ Graph │ │ Index │ │ Embeddings │ │
|
│ agent_memory.h5 /meta · /memory · /sessions · │
|
||||||
│ └──────────┘ └──────────┘ └────────────────┘ │
|
│ /knowledge_graph │
|
||||||
│ │
|
│ agent_memory.h5.wal chained-CRC write-ahead log │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
|
│ agent_memory.h5.ann HNSW graph (derived, rebuildable) │
|
||||||
│ │Provenance│ │ Anomaly │ │ Source │ │
|
│ agent_memory.h5.lock single-writer lock │
|
||||||
│ │ Tracking │ │Detection │ │ Isolation │ │
|
└─────────────────────────────────────────────────────────┘
|
||||||
│ └──────────┘ └──────────┘ └────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌────────┴────────┐
|
|
||||||
│ agent_memory.h5 │
|
|
||||||
│ single file │
|
|
||||||
└─────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Consolidation tiers (Working → Episodic → Semantic), the knowledge-graph
|
||||||
|
algorithms, temporal and multi-modal indexes are library components you drive
|
||||||
|
directly; the store persists the records, sessions and graph they work over.
|
||||||
|
|
||||||
### Module Overview
|
### Module Overview
|
||||||
|
|
||||||
| Module | What It Does |
|
| Module | What It Does |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| **`knowledge`** | Entity/relation graph with BFS traversal, spreading activation, fuzzy entity resolution |
|
| **`knowledge`** | Entity/relation graph with BFS traversal, spreading activation, fuzzy (Levenshtein) entity resolution |
|
||||||
| **`consolidation`** | Three-tier memory (Working → Episodic → Semantic) with importance scoring and time-decay |
|
| **`consolidation`** | Three-tier memory (Working → Episodic → Semantic) with importance scoring, novelty, and time-decay |
|
||||||
| **`hybrid`** | Vector + BM25 fusion with Reciprocal Rank Fusion (RRF, k=60). The vector stage uses the HNSW index by default (`hnsw` feature, on by default); disable with `--no-default-features --features float16` for an exact linear scan |
|
| **`hybrid`** | Vector + BM25 fusion. Default is a min-max-normalised weighted sum, vector 0.4 / keyword 0.6 (`hybrid::DEFAULT_FUSION`, tuned on LongMemEval); RRF is available via `Fusion::Rrf` / `hybrid_search_with`. The vector stage uses the HNSW index by default (`hnsw` feature); disable with `--no-default-features --features float16` for an exact linear scan |
|
||||||
| **`reranker`** | Multi-factor re-ranking: temporal recency, source authority, activation weight |
|
| **`reranker`** | Multi-factor re-ranking: retrieval relevance (leads, weight 1.0), temporal recency, source authority, activation weight. Opt-in via `SearchOptions::with_rerank`; on in `ClawhdfBackend` |
|
||||||
| **`confidence`** | Low-confidence rejection — suppresses spurious recalls when nothing matches |
|
| **`confidence`** | Low-confidence rejection — suppresses spurious recalls when nothing matches. Opt-in via `SearchOptions::with_confidence`; on in `ClawhdfBackend` |
|
||||||
| **`temporal`** | Sorted timestamp index, session DAG, entity timeline, temporal query hints |
|
| **`temporal`** | Sorted timestamp index, session DAG, entity timeline, temporal query hints |
|
||||||
| **`multimodal`** | Cross-modal search across text/image/audio/video embeddings |
|
| **`multimodal`** | Cross-modal search across text/image/audio/video embeddings |
|
||||||
| **`provenance`** | Source attribution, FNV-1a content hashing, integrity verification |
|
| **`signing`** | Ed25519-signed checkpoints: SHA-256 per record in a Merkle tree, plus hashes of settings, sessions and the knowledge graph; `HDF5Memory::verify` names any edited record |
|
||||||
| **`anomaly`** | Write rate limiting, 15 injection pattern detectors, source distribution analysis |
|
| **`provenance`** | Source attribution and an unkeyed FNV-1a content hash per record, held in memory for the session, for detecting accidental corruption (not tamper-proof) |
|
||||||
| **`openclaw`** | OpenClaw integration: MemoryBackend trait, Markdown ↔ HDF5 conversion |
|
| **`anomaly`** | Write rate limiting, 15 injection-pattern detectors, source-distribution analysis. Alerts never block a save; drain them with `take_anomaly_alerts` |
|
||||||
|
| **`openclaw`** | `ClawhdfBackend`: a Markdown-oriented backend (ingest by section, search, read back by path, export). Named for OpenClaw, but **not an OpenClaw plugin** — see [docs/openclaw.md](docs/openclaw.md) |
|
||||||
| **`vector_search`** | Flat cosine, pre-normed, SIMD, BLAS, GPU, parallel search paths |
|
| **`vector_search`** | Flat cosine, pre-normed, SIMD, BLAS, GPU, parallel search paths |
|
||||||
| **`ivf` / `pq`** | IVF-PQ approximate nearest neighbor for billion-scale search |
|
| **`ivf` / `pq`** | Standalone IVF and IVF-PQ indexes (benchmarked to 100K vectors); not used by `HDF5Memory`, whose ANN index is HNSW |
|
||||||
| **`bm25`** | BM25 keyword index with TF-IDF scoring |
|
| **`bm25`** | Incremental Okapi BM25 inverted index, kept for the life of the store; optional stemming |
|
||||||
|
| **`query_expand`** | Synonym / acronym / temporal query expansion |
|
||||||
| **`entity_extract`** | Rule-based entity extraction from text chunks into the knowledge graph |
|
| **`entity_extract`** | Rule-based entity extraction from text chunks into the knowledge graph |
|
||||||
| **`wal`** | Write-ahead log for crash-safe persistence; each entry is CRC32-checked on replay, so a corrupted entry stops replay there instead of loading bad data |
|
| **`wal`** | Write-ahead log (v4) with a chained CRC32 per entry, so a corrupted, reordered, duplicated or spliced entry stops replay; checkpoints record a WAL mark so nothing is applied twice. Appends are not fsynced |
|
||||||
| **`memory_strategy`** | Pluggable strategies: save-every, semantic-shift, user-correction detection |
|
| **`memory_strategy`** | Pluggable strategies: save-every, semantic-shift, user-correction detection |
|
||||||
| **`decision_gate`** | Sub-microsecond trivial/substantive classification |
|
| **`decision_gate`** | Sub-microsecond trivial/substantive classification |
|
||||||
|
| **`ephemeral`** | In-memory TTL/LFU working tier |
|
||||||
| **`async_memory`** | Tokio-based async wrapper over the memory store (`async` feature) |
|
| **`async_memory`** | Tokio-based async wrapper over the memory store (`async` feature) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -265,7 +413,7 @@ assert_eq!(values, vec![22.5, 23.1, 21.8]);
|
|||||||
use clawhdf5_agent::{HDF5Memory, MemoryConfig, MemoryEntry, AgentMemory};
|
use clawhdf5_agent::{HDF5Memory, MemoryConfig, MemoryEntry, AgentMemory};
|
||||||
|
|
||||||
// Create memory store
|
// Create memory store
|
||||||
let config = MemoryConfig::new("agent.h5", "my-agent", 384);
|
let config = MemoryConfig::new("agent.h5".into(), "my-agent", 384);
|
||||||
let mut memory = HDF5Memory::create(config)?;
|
let mut memory = HDF5Memory::create(config)?;
|
||||||
|
|
||||||
// Save a memory
|
// Save a memory
|
||||||
@@ -278,13 +426,67 @@ memory.save(MemoryEntry {
|
|||||||
tags: "preference".into(),
|
tags: "preference".into(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Search
|
// Hybrid search: vector + BM25, weighted 0.4 / 0.6 (the measured default)
|
||||||
let results = memory.search(&query_embedding, 5)?;
|
let results = memory.hybrid_search(&query_embedding, "user preferences", 0.4, 0.6, 5);
|
||||||
for result in results {
|
for result in results {
|
||||||
println!("[{:.3}] {}", result.score, result.chunk);
|
println!("[{:.3}] {}", result.score, result.chunk);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Search Options
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use clawhdf5_agent::SearchOptions;
|
||||||
|
use clawhdf5_agent::confidence::ConfidenceConfig;
|
||||||
|
use clawhdf5_agent::reranker::ReRankConfig;
|
||||||
|
|
||||||
|
// Only memories from these source channels; still a full page of k results.
|
||||||
|
let work = memory.search(
|
||||||
|
&query_embedding,
|
||||||
|
"deadline",
|
||||||
|
&SearchOptions::new(5).with_sources(["slack", "email"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-rank by relevance, recency, source authority and activation, then drop
|
||||||
|
// low-confidence results — the pipeline ClawhdfBackend runs.
|
||||||
|
let careful = memory.search(
|
||||||
|
&query_embedding,
|
||||||
|
"user preferences",
|
||||||
|
&SearchOptions::new(5)
|
||||||
|
.with_rerank(ReRankConfig::default())
|
||||||
|
.with_confidence(ConfidenceConfig::default()),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signed Checkpoints
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use clawhdf5_agent::signing;
|
||||||
|
|
||||||
|
// Once, somewhere safe: keep the secret key, publish the public key.
|
||||||
|
let key = signing::generate_key();
|
||||||
|
let public = key.verifying_key();
|
||||||
|
|
||||||
|
// Every checkpoint is signed from now on. The key is never written to disk;
|
||||||
|
// a signed store refuses to checkpoint without it.
|
||||||
|
memory.set_signing_key(key);
|
||||||
|
memory.flush_wal()?;
|
||||||
|
|
||||||
|
// Anyone holding the public key can check the file, e.g. after copying it.
|
||||||
|
let report = HDF5Memory::verify(std::path::Path::new("agent.h5"), &public)?;
|
||||||
|
assert!(report.is_valid());
|
||||||
|
// On a tampered file: report.changed_records lists the records that differ.
|
||||||
|
```
|
||||||
|
|
||||||
|
The signature covers every record (text, embedding as stored, channel,
|
||||||
|
timestamp, session, tags, deleted flag, activation), the store's settings,
|
||||||
|
its sessions and its knowledge graph — a change made with any tool is caught.
|
||||||
|
It covers checkpoints, not saves still in the WAL
|
||||||
|
(`report.wal_entries_unsigned` counts those). CLI: `clawhdf5-cli keygen`,
|
||||||
|
`--signing-key <file>` on writing commands, and `verify --public-key`.
|
||||||
|
Signing adds about 20% to a checkpoint and 32 bytes per record to the file
|
||||||
|
([BENCHMARKS.md § Signed checkpoints](BENCHMARKS.md#signed-checkpoints)).
|
||||||
|
|
||||||
### Knowledge Graph
|
### Knowledge Graph
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -309,8 +511,8 @@ let neighbors = kg.bfs_neighbors(alice, 2); // 2-hop neighborhood
|
|||||||
let activated = kg.spreading_activation(&[alice], 0.5, 0.01, 5);
|
let activated = kg.spreading_activation(&[alice], 0.5, 0.01, 5);
|
||||||
|
|
||||||
// Entity resolution — fuzzy matching
|
// Entity resolution — fuzzy matching
|
||||||
let resolved = kg.resolve_or_create("alice", "person", -1, 2);
|
let (id, created) = kg.resolve_or_create("alice", "person", -1, 2);
|
||||||
// Returns existing Alice entity (Levenshtein distance ≤ 2)
|
// id == alice, created == false: matched the existing entity (Levenshtein distance ≤ 2)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Memory Consolidation
|
### Memory Consolidation
|
||||||
@@ -321,15 +523,19 @@ use clawhdf5_agent::consolidation::*;
|
|||||||
let config = ConsolidationConfig::default();
|
let config = ConsolidationConfig::default();
|
||||||
let mut engine = ConsolidationEngine::new(config);
|
let mut engine = ConsolidationEngine::new(config);
|
||||||
|
|
||||||
// Add memories — automatically scored for importance
|
let now = 1_700_000_000.0; // seconds since the epoch
|
||||||
engine.add_memory("User prefers dark mode", vec![0.1, 0.2, ...], MemorySource::User);
|
|
||||||
engine.add_memory("ok", vec![0.0, 0.0, ...], MemorySource::System);
|
// Add memories — automatically scored for importance.
|
||||||
|
// Elevated sources (System, …) go through a separate, explicit API.
|
||||||
|
let id = engine.add_memory("User prefers dark mode".into(), vec![0.1, 0.2, ...], UntrustedSource::User, now);
|
||||||
|
engine.add_trusted_memory("ok".into(), vec![0.0, 0.0, ...], TrustedSource::System, now);
|
||||||
|
|
||||||
// Access a memory (reactivates it)
|
// Access a memory (reactivates it)
|
||||||
engine.access_memory(0);
|
engine.access_memory(id, now);
|
||||||
|
|
||||||
// Run consolidation cycle
|
// Run consolidation cycle
|
||||||
let stats = engine.consolidate();
|
engine.consolidate(now);
|
||||||
|
let stats = engine.get_stats();
|
||||||
// Working memories promote to Episodic (if important enough)
|
// Working memories promote to Episodic (if important enough)
|
||||||
// Episodic memories promote to Semantic (if accessed enough)
|
// Episodic memories promote to Semantic (if accessed enough)
|
||||||
// Low-decay memories get evicted when tiers are full
|
// Low-decay memories get evicted when tiers are full
|
||||||
@@ -351,19 +557,25 @@ let ids = index.range_query(1700000000.0, 1700010800.0);
|
|||||||
let recent = index.latest(10);
|
let recent = index.latest(10);
|
||||||
```
|
```
|
||||||
|
|
||||||
### OpenClaw Integration
|
### Markdown Backend
|
||||||
|
|
||||||
|
`ClawhdfBackend` ingests Markdown by section and searches it with the full
|
||||||
|
pipeline. It is a library API — clawhdf5 is **not** an OpenClaw memory plugin
|
||||||
|
([docs/openclaw.md](docs/openclaw.md)). Sections stored this way carry no
|
||||||
|
embedding, so their search is keyword-only unless you save records with
|
||||||
|
vectors through `save_entry`.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use clawhdf5_agent::openclaw::*;
|
use clawhdf5_agent::openclaw::*;
|
||||||
|
|
||||||
// Create backend
|
// Create backend
|
||||||
let mut backend = ClawhdfBackend::create("memory.h5", "agent-1", 384)?;
|
let mut backend = ClawhdfBackend::create(std::path::Path::new("memory.h5"), 384)?;
|
||||||
|
|
||||||
// Ingest existing Markdown memory files
|
// Ingest existing Markdown memory files
|
||||||
let md = std::fs::read_to_string("MEMORY.md")?;
|
let md = std::fs::read_to_string("MEMORY.md")?;
|
||||||
let count = backend.ingest_markdown("MEMORY.md", &md)?;
|
let count = backend.ingest_markdown("MEMORY.md", &md)?;
|
||||||
|
|
||||||
// Search (uses full pipeline: RRF → re-rank → confidence filter)
|
// Search (full pipeline: weighted vector + BM25 fusion → re-rank → confidence filter)
|
||||||
let results = backend.search("user preferences", &query_embedding, 5);
|
let results = backend.search("user preferences", &query_embedding, 5);
|
||||||
|
|
||||||
// Export back to Markdown
|
// Export back to Markdown
|
||||||
@@ -375,22 +587,23 @@ let exported = backend.export_markdown("MEMORY.md")?;
|
|||||||
## Crate Map
|
## Crate Map
|
||||||
|
|
||||||
```
|
```
|
||||||
clawhdf5 workspace (16 crates, ~92K lines of Rust; plus libaec-sys, an
|
clawhdf5 workspace (16 crates, ~86K lines of Rust in src/, ~104K with tests
|
||||||
internal FFI bindings crate for the optional szip feature)
|
and benches; plus libaec-sys, an internal FFI bindings
|
||||||
|
crate for the optional szip feature)
|
||||||
│
|
│
|
||||||
├── Core HDF5
|
├── Core HDF5
|
||||||
│ ├── clawhdf5-format — Binary parser/writer (no_std), shared type definitions
|
│ ├── clawhdf5-format — Binary parser/writer (no_std-capable), shared type definitions
|
||||||
│ ├── clawhdf5-io — I/O abstraction (buffered, mmap, async)
|
│ ├── clawhdf5-io — I/O abstraction (file/memory readers; optional mmap, async, HSDS, MPI)
|
||||||
│ ├── clawhdf5-filters — Fast deflate path (zlib-ng); lz4/zstd/pcodec/szip filters live in clawhdf5-format
|
│ ├── clawhdf5-filters — Fast deflate path (zlib-ng); lz4/zstd/pcodec/szip filters live in clawhdf5-format
|
||||||
│ ├── clawhdf5-derive — Proc macros
|
│ ├── clawhdf5-derive — Proc macros
|
||||||
│ ├── clawhdf5 — High-level API
|
│ ├── clawhdf5 — High-level API
|
||||||
│ ├── clawhdf5-netcdf4 — NetCDF-4 support
|
│ ├── clawhdf5-netcdf4 — NetCDF-4 support
|
||||||
│ ├── clawhdf5-accel — SIMD (NEON, AVX2, AVX-512)
|
│ ├── clawhdf5-accel — SIMD (AVX2, NEON incl. SDOT int8; AVX-512 behind `avx512`)
|
||||||
│ └── clawhdf5-gpu — GPU compute (wgpu, hand-written WGSL compute shaders)
|
│ └── clawhdf5-gpu — GPU compute (wgpu, hand-written WGSL compute shaders)
|
||||||
│
|
│
|
||||||
├── Agent Memory
|
├── Agent Memory
|
||||||
│ ├── clawhdf5-agent — Memory engine (20.9K lines, 32 modules; WAL is CRC32-checked per entry)
|
│ ├── clawhdf5-agent — Memory engine (24.7K lines, 32 modules; chained-CRC WAL)
|
||||||
│ ├── clawhdf5-ann — HNSW approximate nearest neighbor (default backend; optional `parallel` feature)
|
│ ├── clawhdf5-ann — HNSW approximate nearest neighbor (default backend; f32 or int8 storage; `parallel` build)
|
||||||
│ ├── clawhdf5-migrate — SQLite → HDF5 migration
|
│ ├── clawhdf5-migrate — SQLite → HDF5 migration
|
||||||
│ ├── clawhdf5-android — Android JNI bridge
|
│ ├── clawhdf5-android — Android JNI bridge
|
||||||
│ └── clawhdf5-cli — CLI tool
|
│ └── clawhdf5-cli — CLI tool
|
||||||
@@ -411,10 +624,10 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
|
|||||||
|
|
||||||
| Paper | Key Insight | ClawhDF5 Module |
|
| Paper | Key Insight | ClawhDF5 Module |
|
||||||
|-------|-------------|-----------------|
|
|-------|-------------|-----------------|
|
||||||
| **MemX** (2026) | RRF + multi-factor re-ranking | `hybrid`, `reranker` |
|
| **MemX** (2026) | Hybrid fusion + multi-factor re-ranking | `hybrid`, `reranker` |
|
||||||
| **Graph-Native Cognitive Memory** (2026) | Graph-structured belief revision | `knowledge` |
|
| **Graph-Native Cognitive Memory** (2026) | Graph-structured memory (weighted, timestamped relations; entity timelines) | `knowledge`, `temporal` |
|
||||||
| **CraniMem** (2026) | Bounded hippocampal memory | `consolidation` |
|
| **CraniMem** (2026) | Bounded hippocampal memory | `consolidation` |
|
||||||
| **D-MEM** (2026) | Reward prediction error gating | `consolidation` |
|
| **D-MEM** (2026) | Surprise-gated storage (implemented as a novelty score) | `consolidation` |
|
||||||
| **SYNAPSE** (2025) | Spreading activation for recall | `knowledge` |
|
| **SYNAPSE** (2025) | Spreading activation for recall | `knowledge` |
|
||||||
| **RAGdb** (2025) | Zero-dependency edge RAG | Architecture |
|
| **RAGdb** (2025) | Zero-dependency edge RAG | Architecture |
|
||||||
| **MemoryGraft** (2025) | Memory poisoning attacks | `anomaly`, `provenance` |
|
| **MemoryGraft** (2025) | Memory poisoning attacks | `anomaly`, `provenance` |
|
||||||
@@ -429,16 +642,45 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
|
|||||||
|
|
||||||
| Flag | Default | Description |
|
| Flag | Default | Description |
|
||||||
|------|---------|-------------|
|
|------|---------|-------------|
|
||||||
| `agent` | no | Full agent memory layer |
|
| `float16` | **yes** | Half-precision cosine kernel (`cosine_similarity_f16`). Half-precision *storage* is the `MemoryConfig::float16` setting below, and needs no feature |
|
||||||
| `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 |
|
||||||
| `parallel` | no | Rayon parallel search |
|
| `parallel` | **yes** | Parallel HNSW bulk build (same graph, ~3× faster on 16 cores) and Rayon brute-force search strategies |
|
||||||
|
| `zstd` | no | Compress embeddings with Zstd instead of deflate when `MemoryConfig::compression` is on (links libzstd) |
|
||||||
| `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) |
|
||||||
| `openblas` | no | OpenBLAS (Linux) |
|
| `openblas` | no | OpenBLAS (Linux) |
|
||||||
| `gpu` | no | GPU search via wgpu |
|
| `gpu` | no | GPU search via wgpu |
|
||||||
| `async` | no | Tokio async with background flush |
|
| `async` | no | Tokio async with background flush |
|
||||||
|
|
||||||
|
To opt out of the parallel build: `--no-default-features --features float16,hnsw`.
|
||||||
|
For an exact linear cosine scan instead of HNSW: `--no-default-features --features float16`.
|
||||||
|
|
||||||
|
`MemoryConfig::hnsw_m`, `hnsw_ef_construction` and `hnsw_ef_search` tune the
|
||||||
|
vector index (16 / 64 / scale-with-`k` by default) and are stored with the
|
||||||
|
file.
|
||||||
|
|
||||||
|
`MemoryConfig::quantized_index` (**on by default** for new stores) holds the
|
||||||
|
HNSW index's own copy of the embeddings as `i8`, roughly halving a loaded
|
||||||
|
store's memory (2.72x -> 1.74x the raw vectors at 100k x 384). Quantised
|
||||||
|
distances are approximate, so the query path re-scores the candidate pool
|
||||||
|
against the exact embeddings the store already holds, which keeps recall at the
|
||||||
|
`f32` index's level. It is also **faster**: 1.63x the queries per second at
|
||||||
|
equal recall on x86-64 (AVX2) and 1.18x on a Raspberry Pi 5 (NEON `SDOT`), with
|
||||||
|
index builds 1.8x and 2.3x faster respectively. Stores created before the
|
||||||
|
setting existed keep their `f32` index; opt out for new stores with
|
||||||
|
`quantized_index = false` or `clawhdf5-cli create --f32-index`. See
|
||||||
|
[BENCHMARKS.md § Quantising the index copy](BENCHMARKS.md#quantising-the-index-copy-quantized_index).
|
||||||
|
|
||||||
|
`MemoryConfig::float16` (**on by default** for new stores) stores the
|
||||||
|
embeddings on disk as IEEE half precision (numpy `float16`): at 100K × 384 the
|
||||||
|
file drops from 154 to 81 MiB, checkpoints and opens get faster, and on the
|
||||||
|
full LongMemEval haystack with real MiniLM embeddings every retrieval metric
|
||||||
|
matches `f32`. Embeddings are rounded as they are saved, so the store searches
|
||||||
|
the same before and after a reopen; values must lie within ±65504. Existing
|
||||||
|
stores keep their setting. Opt out with `float16 = false` or
|
||||||
|
`clawhdf5-cli create --f32` — e.g. for unnormalised vectors. See
|
||||||
|
[BENCHMARKS.md § float16 embedding storage](BENCHMARKS.md#float16-embedding-storage-memoryconfigfloat16).
|
||||||
|
|
||||||
### `clawhdf5-format`
|
### `clawhdf5-format`
|
||||||
|
|
||||||
| Flag | Default | Description |
|
| Flag | Default | Description |
|
||||||
@@ -447,26 +689,31 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
|
|||||||
| `deflate` | yes | Deflate compression |
|
| `deflate` | yes | Deflate compression |
|
||||||
| `checksum` | yes | Jenkins lookup3 verification |
|
| `checksum` | yes | Jenkins lookup3 verification |
|
||||||
| `provenance` | yes | SHA-256 provenance attributes |
|
| `provenance` | yes | SHA-256 provenance attributes |
|
||||||
| `fast-deflate` | **yes** | zlib-ng backend for faster deflate |
|
| `zlib-rs` | **yes** | Pure-Rust deflate backend ([zlib-rs](https://github.com/trifectatechfoundation/zlib-rs)) |
|
||||||
| `system-zlib-decompress` | **yes** | Use the system zlib for decompression where available |
|
| `fast-deflate` | no | zlib-ng deflate backend instead (C; needs `cmake`). Overrides `zlib-rs` when both are on |
|
||||||
|
| `system-zlib-decompress` | **yes** | Use Apple's system libz for decompression (macOS only; no effect elsewhere) |
|
||||||
| `parallel` | no | Parallel chunk encoding + compression (rayon) |
|
| `parallel` | no | Parallel chunk encoding + compression (rayon) |
|
||||||
| `fast-checksum` | no | crc32fast-accelerated checksums |
|
| `fast-checksum` | no | crc32fast-accelerated checksums |
|
||||||
| `lz4` | no | LZ4 block compression filter (id 32004) |
|
| `lz4` | no | LZ4 block compression filter (id 32004) |
|
||||||
| `zstd` | no | Zstandard compression filter (id 32015) |
|
| `zstd` | no | Zstandard compression filter (id 32015) |
|
||||||
| `pcodec` | no | Pcodec lossless numerical codec (id 32023, via `pco` crate) |
|
| `pcodec` | no | Pcodec lossless numerical codec (via `pco` crate). Private, unregistered filter id 480: **only clawhdf5 can read these datasets** (h5py/libhdf5 cannot). Files from clawhdf5 <= 2.7.0 used id 32023, which is registered to Granular BitRound; they still read. |
|
||||||
| `system-zlib` / `zlib-rs` | no | Alternative zlib backends for deflate |
|
| `system-zlib` | no | System zlib backend for deflate (C) |
|
||||||
| `blake3_hash` | no | BLAKE3 content hashing for provenance |
|
| `blake3_hash` | no | BLAKE3 content hashing for provenance |
|
||||||
|
| `szip` | no | SZIP filter (id 4) via libaec (C, through the internal `libaec-sys` crate) |
|
||||||
|
|
||||||
### `clawhdf5-ann`
|
### `clawhdf5-ann`
|
||||||
|
|
||||||
| Flag | Default | Description |
|
| Flag | Default | Description |
|
||||||
|------|---------|-------------|
|
|------|---------|-------------|
|
||||||
| `parallel` | no | Rayon-parallel neighbor-distance computation during HNSW graph pruning |
|
| `parallel` | no | Batched bulk build runs neighbour planning and back-link pruning on a Rayon pool; the graph is identical with or without it (enabled by `clawhdf5-agent`'s default `parallel`) |
|
||||||
|
|
||||||
### `clawhdf5-io`
|
### `clawhdf5-io`
|
||||||
|
|
||||||
| Flag | Default | Description |
|
| Flag | Default | Description |
|
||||||
|------|---------|-------------|
|
|------|---------|-------------|
|
||||||
|
| `mmap` | no | Memory-mapped reads (`memmap2`) |
|
||||||
|
| `async` | no | Tokio-based async I/O |
|
||||||
|
| `hsds` | no | HSDS (HDF REST service) client |
|
||||||
| `mpi-io` | no | MPI-backed I/O via the `mpi` crate |
|
| `mpi-io` | no | MPI-backed I/O via the `mpi` crate |
|
||||||
|
|
||||||
> **Parallel I/O (MPI) limitation:** `mpi-io`'s read path is a root-rank read
|
> **Parallel I/O (MPI) limitation:** `mpi-io`'s read path is a root-rank read
|
||||||
@@ -480,18 +727,26 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
|
|||||||
## Building
|
## Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Default
|
# Default (pure Rust: no cmake or C compiler needed)
|
||||||
cargo build --workspace
|
cargo build --workspace
|
||||||
|
|
||||||
# Agent memory with all accelerations (Linux)
|
# Agent memory with all accelerations (Linux)
|
||||||
cargo build -p clawhdf5-agent --features "agent,float16,parallel,fast-math"
|
cargo build -p clawhdf5-agent --features fast-math
|
||||||
|
|
||||||
# Agent memory with Apple Accelerate (macOS)
|
# Agent memory with Apple Accelerate (macOS)
|
||||||
cargo build -p clawhdf5-agent --features "agent,float16,accelerate,parallel,gpu"
|
cargo build -p clawhdf5-agent --features "accelerate,gpu"
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
cargo test --workspace # all 1,650+ tests
|
cargo test --workspace # all 1,850+ 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
|
||||||
@@ -504,25 +759,42 @@ cargo bench -p clawhdf5-bench # h5bench-equivalent I/O suite
|
|||||||
|
|
||||||
```
|
```
|
||||||
agent_memory.h5
|
agent_memory.h5
|
||||||
├── /meta
|
├── /meta (attributes)
|
||||||
│ ├── schema_version: "1.0"
|
│ ├── schema_version: "1.0", edgehdf5_version
|
||||||
│ ├── agent_id, embedder, embedding_dim
|
│ ├── agent_id, embedder, embedding_dim, chunk_size, overlap, created_at
|
||||||
│ └── created_at
|
│ ├── float16, compression, compression_level, compact_threshold,
|
||||||
|
│ │ hebbian_boost, decay_factor, wal_enabled, wal_max_entries
|
||||||
|
│ ├── quantized_index, hnsw_m, hnsw_ef_construction, hnsw_ef_search
|
||||||
|
│ ├── wal_applied_len, wal_applied_crc (WAL mark of the last checkpoint)
|
||||||
|
│ └── ann_generation (ties the .ann sidecar to this checkpoint)
|
||||||
├── /memory
|
├── /memory
|
||||||
│ ├── chunks: string[N]
|
│ ├── chunks: string[N]
|
||||||
│ ├── embeddings: f32[N × D] (or f16 with float16 flag)
|
│ ├── embeddings: f32[N × D], or f16 for a `float16` store
|
||||||
│ ├── tombstones: u8[N]
|
│ │ (chunked; deflate, or Zstd with the `zstd`
|
||||||
│ └── norms: f32[N] (pre-computed L2)
|
│ │ feature, when compression is on)
|
||||||
|
│ ├── source_channel: string[N]
|
||||||
|
│ ├── timestamps: f64[N]
|
||||||
|
│ ├── session_ids: string[N]
|
||||||
|
│ ├── tags: string[N]
|
||||||
|
│ ├── tombstones: u8[N]
|
||||||
|
│ ├── norms: f32[N] (pre-computed L2)
|
||||||
|
│ └── activation_weights: f32[N] (Hebbian)
|
||||||
├── /sessions
|
├── /sessions
|
||||||
│ ├── ids: string[S]
|
│ ├── ids, channels, summaries: string[S]
|
||||||
│ └── summaries: string[S]
|
│ ├── start_idxs, end_idxs: i64[S]
|
||||||
|
│ └── timestamps: f64[S]
|
||||||
└── /knowledge_graph
|
└── /knowledge_graph
|
||||||
├── entity_names: string[E]
|
├── entity_ids, entity_emb_idxs: i64[E]; entity_names, entity_types: string[E]
|
||||||
├── relation_srcs: i64[R]
|
├── relation_srcs, relation_tgts: i64[R]; relation_types: string[R]
|
||||||
├── relation_tgts: i64[R]
|
├── relation_weights: f32[R]; relation_ts: f64[R]
|
||||||
└── relation_types: string[R]
|
└── alias_strings: string[A]; alias_entity_ids: i64[A] (when aliases exist)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Alongside the store: `<store>.h5.wal` (write-ahead log), `<store>.h5.ann`
|
||||||
|
(HNSW graph; derived, safe to delete) and `<store>.h5.lock` (single-writer
|
||||||
|
lock). A second writer gets `MemoryError::Locked`; use
|
||||||
|
`HDF5Memory::open_read_only` for a lock-free point-in-time view.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Migration
|
## Migration
|
||||||
@@ -541,9 +813,39 @@ Replace in `Cargo.toml` and source:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo install --path crates/clawhdf5-migrate
|
cargo install --path crates/clawhdf5-migrate
|
||||||
clawhdf5-migrate --sqlite old.db --hdf5 memory.h5 --agent-id my-agent --embedding-dim 384
|
clawhdf5-migrate --sqlite old.db --hdf5 memory.h5 --agent-id my-agent --embedder minilm
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The output is an ordinary `clawhdf5-agent` store, written through the agent's
|
||||||
|
own API: open it with `HDF5Memory::open` (or `clawhdf5-cli --path memory.h5 …`)
|
||||||
|
and search it straight away. The source must use the `memory_chunks` / `sessions` / `entities` / `relations` layout (names are
|
||||||
|
configurable with `--*-table`); note that this is not ZeroClaw's schema, and
|
||||||
|
ZeroClaw does not use clawhdf5. What carries over:
|
||||||
|
|
||||||
|
| SQLite | Agent store |
|
||||||
|
|--------|-------------|
|
||||||
|
| `memory_chunks` | memory records (text, embedding, source channel, timestamp, session id, tags); rows with `deleted = 1` become deleted records, or are left out with `--skip-deleted` |
|
||||||
|
| `sessions` | sessions (id, start/end index, channel, summary, timestamp) |
|
||||||
|
| `entities`, `relations` | knowledge graph entities and relations; entities get new ids and relations are re-pointed at them |
|
||||||
|
|
||||||
|
The chunk `id` column has no counterpart in the agent store, so records are
|
||||||
|
written in `id` order and numbered from 0. Embeddings are stored as float16
|
||||||
|
like any new store; `--f32` keeps full precision (and is required for values
|
||||||
|
beyond ±65504). The embedding dimension is detected from the first row unless
|
||||||
|
`--embedding-dim` is given, and every row must have it: a row of another length
|
||||||
|
is an error, never truncated or padded. A source with no memory records (only
|
||||||
|
sessions or the graph) needs `--embedding-dim`, since a store's dimension is
|
||||||
|
fixed when it is created. Every row is checked before the output is created,
|
||||||
|
so a source that cannot be migrated leaves an existing store at `--hdf5` as it
|
||||||
|
was. `--incremental` adds to an existing store only the rows it does not
|
||||||
|
already hold; the source must have the store's dimension, and records already
|
||||||
|
in the store take the source's deleted flag (a row deleted in SQLite since the
|
||||||
|
last run is deleted in the store; one un-deleted there is written again, as
|
||||||
|
the agent has no un-delete). The tool reads the result back with
|
||||||
|
`HDF5Memory::open_read_only`, compares it with the source (every row with
|
||||||
|
`--validate-full`) and checks that a migrated record is found by search;
|
||||||
|
`--dry-run` only counts the rows.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
@@ -557,10 +859,10 @@ See [ROADMAP.md](ROADMAP.md) for the full implementation tracker.
|
|||||||
- ✅ Temporal reasoning with sub-µs queries
|
- ✅ Temporal reasoning with sub-µs queries
|
||||||
- ✅ Memory security + anomaly detection
|
- ✅ Memory security + anomaly detection
|
||||||
- ✅ Multi-modal memory (text/image/audio/video)
|
- ✅ Multi-modal memory (text/image/audio/video)
|
||||||
- ✅ OpenClaw integration layer
|
- ✅ Markdown ingest/export backend (`ClawhdfBackend`); an OpenClaw plugin was never built — see [docs/openclaw.md](docs/openclaw.md)
|
||||||
- ✅ Comprehensive Criterion benchmarks
|
- ✅ Comprehensive Criterion benchmarks
|
||||||
|
|
||||||
**Phase 2** — MemoryArena and LongMemEval academic benchmarks are done (see [BENCHMARKS.md](BENCHMARKS.md), reproduced on a second machine); remaining: publish the OpenClaw TypeScript bridge to npm, crates.io/PyPI publishing.
|
**Phase 2** — MemoryArena and LongMemEval academic benchmarks are done (see [BENCHMARKS.md](BENCHMARKS.md), reproduced on a second machine); remaining: crates.io/PyPI publishing. The Node bindings are unpublished and known to be broken ([known issues](docs/known-issues.md)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -577,6 +879,6 @@ MIT
|
|||||||
---
|
---
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<em>Built by <a href="https://github.com/redclawsystems">RedClaw Systems</a></em><br>
|
<em>Built by <a href="https://git.redclaw.dev/quantumclaw">RedClaw Systems</a></em><br>
|
||||||
<em>~92,000 lines of Rust. Zero C dependencies. One file to remember everything.</em>
|
<em>~86,000 lines of Rust. Zero C dependencies. One file to remember everything.</em>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+14
-8
@@ -105,24 +105,30 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Track 7: OpenClaw Integration
|
## Track 7: OpenClaw Integration — withdrawn (2026-09-25)
|
||||||
**Status:** 🟢 Complete
|
**Status:** ⚪ Withdrawn (the items below were library work; no OpenClaw integration shipped)
|
||||||
**Priority:** Critical (for adoption)
|
**Priority:** Critical (for adoption)
|
||||||
**Crates:** `clawhdf5-agent`, `clawhdf5-napi`
|
**Crates:** `clawhdf5-agent`, `clawhdf5-napi`
|
||||||
|
|
||||||
- [x] **7.1** Memory backend trait — MemoryBackend with search/get/write/ingest/export/stats
|
- [x] **7.1** Memory backend trait — MemoryBackend with search/get/write/ingest/export/stats
|
||||||
- [x] **7.2** Hybrid retrieval pipeline — ClawhdfBackend wires RRF → reranker → confidence rejection
|
- [x] **7.2** Hybrid retrieval pipeline — ClawhdfBackend wires RRF → reranker → confidence rejection
|
||||||
- [x] **7.3** Markdown import/export — MarkdownParser + MarkdownExporter with line tracking + metadata
|
- [x] **7.3** Markdown import/export — MarkdownParser + MarkdownExporter with line tracking + metadata
|
||||||
- [x] **7.4** memory_search tool — backed by full hybrid retrieval pipeline
|
- [x] **7.4** `search()` — backed by the full hybrid retrieval pipeline (a Rust method; no OpenClaw tool was ever registered)
|
||||||
- [x] **7.5** memory_get tool — get() with path + line range support
|
- [x] **7.5** `get()` — read back by path, with a line slice (not an OpenClaw tool either)
|
||||||
- [x] **7.6** Compaction integration — run_compaction() (decay + compact + WAL flush), run_consolidation() (hippocampal engine), tick_session(), flush_wal()
|
- [x] **7.6** Compaction integration — run_compaction() (decay + compact + WAL flush), run_consolidation() (hippocampal engine), tick_session(), flush_wal()
|
||||||
- [x] **7.7** Config surface — `memory.backend = "clawhdf5"` schema documented in docs/openclaw-config.md
|
- [ ] **7.7** ~~Config surface — `memory.backend = "clawhdf5"`~~ — never valid OpenClaw config; docs removed
|
||||||
- [x] **7.8** Documentation + migration guide — docs/migration-guide.md, docs/openclaw-integration.md (architecture, full API reference, code patterns)
|
- [ ] **7.8** ~~Documentation + migration guide~~ — removed: they described an integration that never worked
|
||||||
|
|
||||||
**Node.js bridge:** `clawhdf5-napi` (napi-rs) → `@redclaw/clawhdf5` npm package with full TypeScript types.
|
**Node.js bridge:** `clawhdf5-napi` (napi-rs) and a TypeScript wrapper in `packages/clawhdf5-node` exist but are unpublished, untested in CI and known to be broken (docs/known-issues.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> **Withdrawn.** None of this track produced a working OpenClaw integration: no
|
||||||
|
> plugin was built, the documented `memory.backend = "clawhdf5"` config was never
|
||||||
|
> valid in any OpenClaw release, and the Node package was never published. The
|
||||||
|
> Rust `ClawhdfBackend` remains as a library API. Not pursued for now; see
|
||||||
|
> [docs/openclaw.md](docs/openclaw.md) for what a plugin would need today.
|
||||||
|
|
||||||
## Track 8: Benchmarking & Validation
|
## Track 8: Benchmarking & Validation
|
||||||
**Status:** 🟢 Complete
|
**Status:** 🟢 Complete
|
||||||
**Priority:** High
|
**Priority:** High
|
||||||
@@ -142,7 +148,7 @@
|
|||||||
|
|
||||||
**Phase 1:** ~~Tracks 1, 2, 3 — core memory intelligence~~ 🟢 Complete
|
**Phase 1:** ~~Tracks 1, 2, 3 — core memory intelligence~~ 🟢 Complete
|
||||||
**Phase 2:** ~~Track 4 (temporal) + Track 5 (security)~~ 🟢 Complete
|
**Phase 2:** ~~Track 4 (temporal) + Track 5 (security)~~ 🟢 Complete
|
||||||
**Phase 3:** ~~Track 6 (multi-modal) + Track 7 (OpenClaw integration)~~ 🟢 Complete
|
**Phase 3:** ~~Track 6 (multi-modal)~~ 🟢 Complete; Track 7 (OpenClaw integration) withdrawn
|
||||||
**Phase 4:** ~~Track 8 (benchmarking + validation)~~ 🟢 Complete
|
**Phase 4:** ~~Track 8 (benchmarking + validation)~~ 🟢 Complete
|
||||||
|
|
||||||
All 8 tracks delivered. 1,650+ tests passing, zero clippy warnings.
|
All 8 tracks delivered. 1,650+ tests passing, zero clippy warnings.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/.cache/
|
||||||
|
# pin the probe's dependencies (the workspace lock is not committed)
|
||||||
|
!/probe/Cargo.lock
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Conformance sweep
|
||||||
|
|
||||||
|
Reads every HDF5 file of eight public corpora with clawhdf5 and with
|
||||||
|
h5py/libhdf5, compares the two readings object by object, and writes
|
||||||
|
[`CONFORMANCE.md`](../CONFORMANCE.md).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CLAWHDF5_PYTHON=/path/to/venv/bin/python conformance/run.sh # ~30 s once the corpus is cached
|
||||||
|
conformance/run.sh --update-baseline # after an intended change in results
|
||||||
|
```
|
||||||
|
|
||||||
|
Needs Rust, `git`, `h5dump` (Debian/Ubuntu `hdf5-tools`), `libaec` (for the
|
||||||
|
probe's `szip` feature; `libaec-dev`), and a Python with the packages in
|
||||||
|
`requirements.txt`. The first run downloads about 450 MB of sparse checkouts.
|
||||||
|
|
||||||
|
| file | role |
|
||||||
|
|---|---|
|
||||||
|
| `corpus.txt` | the corpora: git URL, pinned commit, swept root, sparse-checkout patterns |
|
||||||
|
| `fetch-corpus.sh` | shallow, sparse, blob-filtered checkout of each pinned commit into `.cache/src/` (gitignored); no-op when already there |
|
||||||
|
| `list_files.py` | which files are probed (HDF5/netCDF-4 extensions minus netCDF classic, plus the CVE reproducers) |
|
||||||
|
| `probe/` | the clawhdf5 side: a standalone crate (outside the workspace, so `cargo test --workspace` never builds it) that walks a file with `clawhdf5-format` and prints canonical JSON |
|
||||||
|
| `ref.py` | the h5py side: the same JSON from h5py |
|
||||||
|
| `run_one.sh` | runs both sides on one file (and `h5dump` on the CVE corpus) under a timeout and an address-space limit |
|
||||||
|
| `compare.py` | classifies each file (ok / our-error / mismatch / h5py-cannot-read / panic / hang / crash / oom) and groups root causes |
|
||||||
|
| `report.py` | writes `CONFORMANCE.md` |
|
||||||
|
| `check.py` | the gate: fails on any panic/hang/crash/oom, on an ok count below `baseline.json`, or on a baseline-ok file that is no longer ok |
|
||||||
|
| `baseline.json` | the ok files the gate holds the line on |
|
||||||
|
| `requirements.txt` | pinned h5py / numpy / hdf5plugin / netCDF4 |
|
||||||
|
|
||||||
|
Results for every file (both sides' JSON and stderr, `results.csv`,
|
||||||
|
`results.json`, `summary.md`) are left in `.cache/results/`.
|
||||||
|
|
||||||
|
The nightly job is `.gitea/workflows/conformance.yml`; it prints the report
|
||||||
|
into the job log.
|
||||||
|
|
||||||
|
The canonical value encoding both sides hash is documented at the top of
|
||||||
|
`probe/src/main.rs`. Values are compared as libhdf5 presents them: a float
|
||||||
|
with a non-IEEE bit layout (N-Bit) or an integer with a bit offset is compared
|
||||||
|
as the converted number, not as raw file bytes.
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
{
|
||||||
|
"comment": "conformance/run.sh fails if the ok count drops below `ok` or a file in `ok_files` stops being ok. Regenerate with `conformance/run.sh --update-baseline` after an intended change.",
|
||||||
|
"commit": "10d1029ead524e2fe64c2cd7f61b28067d9e449c",
|
||||||
|
"date": "2026-09-26 03:46 UTC",
|
||||||
|
"reference": "h5py 3.16.0 / HDF5 2.0.0",
|
||||||
|
"files": 697,
|
||||||
|
"ok": 569,
|
||||||
|
"counts": {
|
||||||
|
"h5py-cannot-read": 92,
|
||||||
|
"mismatch": 22,
|
||||||
|
"ok": 569,
|
||||||
|
"our-error": 14
|
||||||
|
},
|
||||||
|
"per_corpus": {
|
||||||
|
"NCAS-CMS_pyfive": {
|
||||||
|
"mismatch": 1,
|
||||||
|
"ok": 32
|
||||||
|
},
|
||||||
|
"cve_hdf5": {
|
||||||
|
"h5py-cannot-read": 32,
|
||||||
|
"mismatch": 9,
|
||||||
|
"ok": 100,
|
||||||
|
"our-error": 6
|
||||||
|
},
|
||||||
|
"h5py_data": {
|
||||||
|
"ok": 4
|
||||||
|
},
|
||||||
|
"hdf5": {
|
||||||
|
"h5py-cannot-read": 60,
|
||||||
|
"mismatch": 12,
|
||||||
|
"ok": 386,
|
||||||
|
"our-error": 8
|
||||||
|
},
|
||||||
|
"netcdf-c": {
|
||||||
|
"ok": 20
|
||||||
|
},
|
||||||
|
"netcdf4-python": {
|
||||||
|
"ok": 18
|
||||||
|
},
|
||||||
|
"usnistgov_h5wasm": {
|
||||||
|
"ok": 5
|
||||||
|
},
|
||||||
|
"xarray-data": {
|
||||||
|
"ok": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ok_files": [
|
||||||
|
"NCAS-CMS_pyfive/tests/compact.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/btreev2.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/chunked.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/cmip_bad_eg.nc",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/compressed.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/compressed_v1.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/dataset_datatypes.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/dataset_multidim.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/dim_scales.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/earliest.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/enum_h5variable.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/enum_variable.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/enum_variable.nc",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/enums_from_netcdf.nc",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/fillvalue_earliest.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/fillvalue_latest.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/filter_pipeline_v2.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/fletcher32.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/fractal_heap_no_mci_rlat.nc",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/groups.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/h5netcdf_test.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/issue23_A.nc",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/issue23_A_contiguous.nc",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/issue23_B.nc",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/latest.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/netcdf4_classic.nc",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/new_style_groups.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/noy_AERmonZ_UKESM1-0-LL_piControl_r1i1p1f2_gnz_200001-200012.nc",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/references.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/data/resizable.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/opaque_datetime.hdf5",
|
||||||
|
"NCAS-CMS_pyfive/tests/opaque_fixed.hdf5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2016-4330.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2016-4331.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2016-4332-mtime-new.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2016-4332-mtime.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2016-4333.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2017-17505.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2017-17506.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2017-17507.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2017-17508.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2017-17509.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-11202.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-11203.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-11204.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-11205.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-11206-new.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-11206-old.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-11207.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-13867.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-13868.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-13869.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-13870.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-13871.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-13872.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-13873.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-13875.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-14031.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-14033.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-14034.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-14035.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-14460.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-15671.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-15672.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-16438.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-17233.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-17234.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-17237.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-17432.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-17434.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-17435.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2018-17437.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2019-8396.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2019-9152.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2020-10811.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2020-18232.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2021-36977.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2021-37501.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2021-45829.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2021-45833.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-29157.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-29158.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-29159.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-29160.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-29161.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-29162.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-29163.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-29164.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-29165.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-29166.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32605.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32606.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32607-1.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32607-2.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32608.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32610.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32611.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32612.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32613.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32614.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32615.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32616.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32617.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32619.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32620.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32621.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32622.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-32624.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-33873.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-33875.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-33876.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2024-33877.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2025-2310.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2025-2924.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2025-2925.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2025-6269-1.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2025-6269-2.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2025-6269-3.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2025-6269-4.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2025-6516.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2025-6857.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2025-7067.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2026-26200.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2026-34734.h5",
|
||||||
|
"cve_hdf5/cvefiles/cve-2026-92627.h5",
|
||||||
|
"cve_hdf5/cvefiles/unknown-1.h5",
|
||||||
|
"cve_hdf5/fuzzerfiles/gh-4431-poc-03.h5",
|
||||||
|
"cve_hdf5/fuzzerfiles/gh-4432-poc-05.h5",
|
||||||
|
"cve_hdf5/fuzzerfiles/gh-4433-poc-08.h5",
|
||||||
|
"cve_hdf5/fuzzerfiles/gh-4435-poc-10.h5",
|
||||||
|
"cve_hdf5/fuzzerfiles/gh_2649_flawed.h5",
|
||||||
|
"cve_hdf5/fuzzerfiles/gh_2649_plain_model.h5",
|
||||||
|
"h5py_data/compound-dtype-complex.h5",
|
||||||
|
"h5py_data/vlen_string_dset.h5",
|
||||||
|
"h5py_data/vlen_string_dset_utc.h5",
|
||||||
|
"h5py_data/vlen_string_s390x.h5",
|
||||||
|
"hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_bitgroom.h5",
|
||||||
|
"hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_granularbr.h5",
|
||||||
|
"hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_jpeg.h5",
|
||||||
|
"hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_lz4.h5",
|
||||||
|
"hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_zstd.h5",
|
||||||
|
"hdf5/HDF5Examples/C/H5G/16/h5ex_g_iterate.h5",
|
||||||
|
"hdf5/HDF5Examples/C/H5G/16/h5ex_g_traverse.h5",
|
||||||
|
"hdf5/HDF5Examples/C/H5G/h5ex_g_iterate.h5",
|
||||||
|
"hdf5/HDF5Examples/C/H5G/h5ex_g_traverse.h5",
|
||||||
|
"hdf5/HDF5Examples/C/H5G/h5ex_g_visit.h5",
|
||||||
|
"hdf5/HDF5Examples/FORTRAN/H5G/h5ex_g_iterate.h5",
|
||||||
|
"hdf5/HDF5Examples/FORTRAN/H5G/h5ex_g_traverse.h5",
|
||||||
|
"hdf5/HDF5Examples/FORTRAN/H5G/h5ex_g_visit.h5",
|
||||||
|
"hdf5/HDF5Examples/JAVA/H5G/h5ex_g_iterate.h5",
|
||||||
|
"hdf5/HDF5Examples/JAVA/H5G/h5ex_g_visit.h5",
|
||||||
|
"hdf5/HDF5Examples/JAVA/compat/H5G/110/h5ex_g_iterate.h5",
|
||||||
|
"hdf5/HDF5Examples/JAVA/compat/H5G/110/h5ex_g_visit.h5",
|
||||||
|
"hdf5/HDF5Examples/JAVA/compat/H5G/h5ex_g_iterate.h5",
|
||||||
|
"hdf5/HDF5Examples/JAVA/compat/H5G/h5ex_g_visit.h5",
|
||||||
|
"hdf5/c++/test/th5s.h5",
|
||||||
|
"hdf5/hl/test/testfiles/test_ds_be.h5",
|
||||||
|
"hdf5/hl/test/testfiles/test_ds_be_new_ref-32bit.h5",
|
||||||
|
"hdf5/hl/test/testfiles/test_ds_be_new_ref.h5",
|
||||||
|
"hdf5/hl/test/testfiles/test_ds_le.h5",
|
||||||
|
"hdf5/hl/test/testfiles/test_ds_le_new_ref.h5",
|
||||||
|
"hdf5/hl/test/testfiles/test_ld.h5",
|
||||||
|
"hdf5/hl/test/testfiles/test_table_be.h5",
|
||||||
|
"hdf5/hl/test/testfiles/test_table_cray.h5",
|
||||||
|
"hdf5/hl/test/testfiles/test_table_le.h5",
|
||||||
|
"hdf5/test/testfiles/aggr.h5",
|
||||||
|
"hdf5/test/testfiles/bad_chunk_ndims.h5",
|
||||||
|
"hdf5/test/testfiles/bad_compound.h5",
|
||||||
|
"hdf5/test/testfiles/bad_offset.h5",
|
||||||
|
"hdf5/test/testfiles/be_data.h5",
|
||||||
|
"hdf5/test/testfiles/be_extlink1.h5",
|
||||||
|
"hdf5/test/testfiles/be_extlink2.h5",
|
||||||
|
"hdf5/test/testfiles/btree_idx_1_6.h5",
|
||||||
|
"hdf5/test/testfiles/btree_idx_1_8.h5",
|
||||||
|
"hdf5/test/testfiles/charsets.h5",
|
||||||
|
"hdf5/test/testfiles/corrupt_stab_msg.h5",
|
||||||
|
"hdf5/test/testfiles/deflate.h5",
|
||||||
|
"hdf5/test/testfiles/file_image_core_test.h5",
|
||||||
|
"hdf5/test/testfiles/filespace_1_6.h5",
|
||||||
|
"hdf5/test/testfiles/filespace_1_8.h5",
|
||||||
|
"hdf5/test/testfiles/fill18.h5",
|
||||||
|
"hdf5/test/testfiles/fill_old.h5",
|
||||||
|
"hdf5/test/testfiles/filter_error.h5",
|
||||||
|
"hdf5/test/testfiles/fsm_aggr_nopersist.h5",
|
||||||
|
"hdf5/test/testfiles/fsm_aggr_persist.h5",
|
||||||
|
"hdf5/test/testfiles/group_old.h5",
|
||||||
|
"hdf5/test/testfiles/h5fc_ext1_f.h5",
|
||||||
|
"hdf5/test/testfiles/h5fc_ext1_i.h5",
|
||||||
|
"hdf5/test/testfiles/h5fc_ext2_if.h5",
|
||||||
|
"hdf5/test/testfiles/h5fc_ext2_sf.h5",
|
||||||
|
"hdf5/test/testfiles/h5fc_ext3_isf.h5",
|
||||||
|
"hdf5/test/testfiles/h5fc_ext_none.h5",
|
||||||
|
"hdf5/test/testfiles/le_data.h5",
|
||||||
|
"hdf5/test/testfiles/le_extlink1.h5",
|
||||||
|
"hdf5/test/testfiles/le_extlink2.h5",
|
||||||
|
"hdf5/test/testfiles/memleak_H5O_dtype_decode_helper_H5Odtype.h5",
|
||||||
|
"hdf5/test/testfiles/mergemsg.h5",
|
||||||
|
"hdf5/test/testfiles/noencoder.h5",
|
||||||
|
"hdf5/test/testfiles/none.h5",
|
||||||
|
"hdf5/test/testfiles/paged_nopersist.h5",
|
||||||
|
"hdf5/test/testfiles/paged_persist.h5",
|
||||||
|
"hdf5/test/testfiles/specmetaread.h5",
|
||||||
|
"hdf5/test/testfiles/tarrold.h5",
|
||||||
|
"hdf5/test/testfiles/tbad_msg_count.h5",
|
||||||
|
"hdf5/test/testfiles/tbogus.h5",
|
||||||
|
"hdf5/test/testfiles/test_filters_be.h5",
|
||||||
|
"hdf5/test/testfiles/test_filters_le.h5",
|
||||||
|
"hdf5/test/testfiles/th5s.h5",
|
||||||
|
"hdf5/test/testfiles/tlayouto.h5",
|
||||||
|
"hdf5/test/testfiles/tmisc38a.h5",
|
||||||
|
"hdf5/test/testfiles/tmisc38b.h5",
|
||||||
|
"hdf5/test/testfiles/tmtimen.h5",
|
||||||
|
"hdf5/test/testfiles/tmtimeo.h5",
|
||||||
|
"hdf5/test/testfiles/tnullspace.h5",
|
||||||
|
"hdf5/test/testfiles/tsizeslheap.h5",
|
||||||
|
"hdf5/tools/test/testfiles/bigendian/tdset2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/binfp64.h5",
|
||||||
|
"hdf5/tools/test/testfiles/binin16.h5",
|
||||||
|
"hdf5/tools/test/testfiles/binin32.h5",
|
||||||
|
"hdf5/tools/test/testfiles/binin8.h5",
|
||||||
|
"hdf5/tools/test/testfiles/binin8w.h5",
|
||||||
|
"hdf5/tools/test/testfiles/binuin16.h5",
|
||||||
|
"hdf5/tools/test/testfiles/binuin32.h5",
|
||||||
|
"hdf5/tools/test/testfiles/bounds_latest_latest.h5",
|
||||||
|
"hdf5/tools/test/testfiles/charsets.h5",
|
||||||
|
"hdf5/tools/test/testfiles/compounds_array_vlen1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/compounds_array_vlen2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/err_attr_dspace.h5",
|
||||||
|
"hdf5/tools/test/testfiles/file_space.h5",
|
||||||
|
"hdf5/tools/test/testfiles/filter_fail.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5clear_fsm_persist_equal.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5clear_fsm_persist_noclose.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5clear_fsm_persist_user_equal.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5clear_fsm_persist_user_less.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5clear_sec2_v0.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5clear_sec2_v2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5copy_extlinks_src.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5copy_extlinks_trg.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5copy_ref.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5copytst.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5copytst_new.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_attr1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_attr2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_attr3.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_attr_v_level1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_attr_v_level2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_basic1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_basic2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_comp_vl_strs.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_danglelinks1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_danglelinks2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_dset1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_dset2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_dset3.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_dset_zero_dim_size1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_dset_zero_dim_size2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_dtypes.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_empty.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_enum_invalid_values.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_eps1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_eps2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_exclude1-1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_exclude1-2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_exclude2-1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_exclude2-2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_exclude3-1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_exclude3-2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_ext2softlink_src.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_ext2softlink_trg.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_extlink_src.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_extlink_trg.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_grp_recurse1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_grp_recurse2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_grp_recurse_ext1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_grp_recurse_ext2-1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_grp_recurse_ext2-2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_grp_recurse_ext2-3.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_hyper1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_hyper2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_linked_softlink.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_links.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_onion_dset_1d.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_onion_dset_ext.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_onion_objs.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_softlinks.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_strings1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5diff_strings2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5fc_edge_v3.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5fc_err_level.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5fc_ext1_f.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5fc_ext1_i.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5fc_ext1_s.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5fc_ext2_if.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5fc_ext2_is.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5fc_ext2_sf.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5fc_ext3_isf.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5fc_ext_none.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5fc_non_v3.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_CVE-2018-14460.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_CVE-2018-17432.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_aggr.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_attr.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_attr_refs.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_deflate.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_early.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_ext.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_f32le.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_f32le_ex.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_fill.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_filters.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_fletcher.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_fsm_aggr_nopersist.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_fsm_aggr_persist.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_hlink.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_int32le_1d.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_int32le_1d_ex.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_int32le_2d.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_int32le_2d_ex.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_int32le_3d.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_int32le_3d_ex.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_layout.UD.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_layout.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_layout2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_layout3.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_layouto.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_named_dtypes.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_nbit.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_nested_8bit_enum.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_nested_8bit_enum_deflated.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_none.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_objs.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_paged_nopersist.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_paged_persist.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_refs.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_shuffle.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_soffset.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_szip.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_uint8be.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5repack_uint8be_ex.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5stat_err_old_fill.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5stat_err_old_layout.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5stat_filters.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5stat_idx.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5stat_newgrat.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5stat_threshold.h5",
|
||||||
|
"hdf5/tools/test/testfiles/h5stat_tsohm.h5",
|
||||||
|
"hdf5/tools/test/testfiles/mod_h5clear_mdc_image.h5",
|
||||||
|
"hdf5/tools/test/testfiles/non_comparables1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/non_comparables2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/old_h5fc_ext1_f.h5",
|
||||||
|
"hdf5/tools/test/testfiles/old_h5fc_ext1_i.h5",
|
||||||
|
"hdf5/tools/test/testfiles/old_h5fc_ext1_s.h5",
|
||||||
|
"hdf5/tools/test/testfiles/old_h5fc_ext2_if.h5",
|
||||||
|
"hdf5/tools/test/testfiles/old_h5fc_ext2_is.h5",
|
||||||
|
"hdf5/tools/test/testfiles/old_h5fc_ext2_sf.h5",
|
||||||
|
"hdf5/tools/test/testfiles/old_h5fc_ext3_isf.h5",
|
||||||
|
"hdf5/tools/test/testfiles/old_h5fc_ext_none.h5",
|
||||||
|
"hdf5/tools/test/testfiles/packedbits.h5",
|
||||||
|
"hdf5/tools/test/testfiles/t128bit_float.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tCVE-2021-37501_attr_decode.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tCVE_2018_11206_fill_new.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tCVE_2018_11206_fill_old.h5",
|
||||||
|
"hdf5/tools/test/testfiles/taindices.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tarray1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tarray1_big.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tarray2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tarray4.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tarray5.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tarray8.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tattr.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tattr2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tattr4_be.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tattrintsize.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tattrreg.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tbfloat16.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tbfloat16_be.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tbigdims.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tbinary.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tbitnopaque.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tchar.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tcmpdattrintsize.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tcmpdintarray.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tcmpdints.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tcmpdintsize.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tcomplex.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tcompound.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tcompound_complex.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tcompound_complex2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tdatareg.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tdset.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tdset2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tdset_idx.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tempty.h5",
|
||||||
|
"hdf5/tools/test/testfiles/textlink.h5",
|
||||||
|
"hdf5/tools/test/testfiles/textlinkfar.h5",
|
||||||
|
"hdf5/tools/test/testfiles/textlinksrc.h5",
|
||||||
|
"hdf5/tools/test/testfiles/textlinktar.h5",
|
||||||
|
"hdf5/tools/test/testfiles/textpfe.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tfcontents2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tfilters.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tfloat16.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tfloat16_be.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tfloat4.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tfloat6.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tfloat8.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tfloatsattrs.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tfpformat.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tfvalues.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tgroup.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tgrp_comments.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tgrpnullspace.h5",
|
||||||
|
"hdf5/tools/test/testfiles/thlink.h5",
|
||||||
|
"hdf5/tools/test/testfiles/thyperslab.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tintascii.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tints4dims.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tintsattrs.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tintsnodata.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tlarge_objname.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tldouble.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tldouble_scalar.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tlonglinks.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tloop.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tnamed_dtype_attr.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tnestedcmpddt.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tnestedcomp.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tno-subset.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tnullspace.h5",
|
||||||
|
"hdf5/tools/test/testfiles/torderattr.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tordergr.h5",
|
||||||
|
"hdf5/tools/test/testfiles/trefer_attr.h5",
|
||||||
|
"hdf5/tools/test/testfiles/trefer_compat.h5",
|
||||||
|
"hdf5/tools/test/testfiles/trefer_ext1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/trefer_ext2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/trefer_grp.h5",
|
||||||
|
"hdf5/tools/test/testfiles/trefer_obj.h5",
|
||||||
|
"hdf5/tools/test/testfiles/trefer_obj_del.h5",
|
||||||
|
"hdf5/tools/test/testfiles/trefer_param.h5",
|
||||||
|
"hdf5/tools/test/testfiles/trefer_reg.h5",
|
||||||
|
"hdf5/tools/test/testfiles/trefer_reg_1d.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tsaf.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tscalarattrintsize.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tscalarintattrsize.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tscalarintsize.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tscalarstring.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tslink.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tsoftlinks.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tst_onion_dset_1d.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tst_onion_dset_ext.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tst_onion_objs.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tstr.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tstr2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tstr3.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tudfilter.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tudfilter2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tvldtypes1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tvldtypes2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tvldtypes3.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tvldtypes4.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tvldtypes5.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tvlenstr_array.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tvlstr.h5",
|
||||||
|
"hdf5/tools/test/testfiles/tvms.h5",
|
||||||
|
"hdf5/tools/test/testfiles/txtfp32.h5",
|
||||||
|
"hdf5/tools/test/testfiles/txtfp64.h5",
|
||||||
|
"hdf5/tools/test/testfiles/txtin16.h5",
|
||||||
|
"hdf5/tools/test/testfiles/txtin32.h5",
|
||||||
|
"hdf5/tools/test/testfiles/txtin8.h5",
|
||||||
|
"hdf5/tools/test/testfiles/txtstr.h5",
|
||||||
|
"hdf5/tools/test/testfiles/txtuin16.h5",
|
||||||
|
"hdf5/tools/test/testfiles/txtuin32.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/1_a.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/1_b.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/1_c.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/1_d.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/1_e.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/1_f.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/1_vds.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/2_a.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/2_b.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/2_c.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/2_d.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/2_e.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/2_vds.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/3_1_vds.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/3_2_vds.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/4_0.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/4_1.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/4_2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/4_vds.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/5_a.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/5_b.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/5_c.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/5_vds.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/a.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/b.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/c.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/d.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/f-0.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/f-3.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/vds-eiger.h5",
|
||||||
|
"hdf5/tools/test/testfiles/vds/vds-percival-unlim-maxmin.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tbitfields.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tcompound2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tdset2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tenum.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/test35.nc",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tloop2.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tname-amp.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tname-apos.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tname-gt.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tname-lt.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tname-quot.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tname-sp.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tnodata.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tobjref.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/topaque.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tref-escapes-at.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tref-escapes.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tref.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tstring-at.h5",
|
||||||
|
"hdf5/tools/test/testfiles/xml/tstring.h5",
|
||||||
|
"hdf5/tools/test/testfiles/zerodim.h5",
|
||||||
|
"netcdf-c/h5_test/ref_tst_h_compounds.h5",
|
||||||
|
"netcdf-c/h5_test/ref_tst_h_compounds2.h5",
|
||||||
|
"netcdf-c/nc_test4/ref_hdf5_compat1.nc",
|
||||||
|
"netcdf-c/nc_test4/ref_hdf5_compat2.nc",
|
||||||
|
"netcdf-c/nc_test4/ref_hdf5_compat3.nc",
|
||||||
|
"netcdf-c/nc_test4/ref_szip.h5",
|
||||||
|
"netcdf-c/nc_test4/ref_tst_compounds.nc",
|
||||||
|
"netcdf-c/nc_test4/ref_tst_dims.nc",
|
||||||
|
"netcdf-c/nc_test4/ref_tst_interops4.nc",
|
||||||
|
"netcdf-c/nc_test4/ref_tst_xplatform2_1.nc",
|
||||||
|
"netcdf-c/nc_test4/ref_tst_xplatform2_2.nc",
|
||||||
|
"netcdf-c/nc_test4/tdset.h5",
|
||||||
|
"netcdf-c/ncdump/ref_nc_test_netcdf4_4_0.nc",
|
||||||
|
"netcdf-c/ncdump/ref_no_ncproperty.nc",
|
||||||
|
"netcdf-c/ncdump/ref_provenance_v1.nc",
|
||||||
|
"netcdf-c/ncdump/ref_test_corrupt_magic.nc",
|
||||||
|
"netcdf-c/ncdump/ref_tst_compounds2.nc",
|
||||||
|
"netcdf-c/ncdump/ref_tst_compounds3.nc",
|
||||||
|
"netcdf-c/ncdump/ref_tst_compounds4.nc",
|
||||||
|
"netcdf-c/ncdump/ref_tst_irish_rover.nc",
|
||||||
|
"netcdf4-python/examples/data/prmsl.2000.nc",
|
||||||
|
"netcdf4-python/examples/data/prmsl.2001.nc",
|
||||||
|
"netcdf4-python/examples/data/prmsl.2002.nc",
|
||||||
|
"netcdf4-python/examples/data/prmsl.2003.nc",
|
||||||
|
"netcdf4-python/examples/data/prmsl.2004.nc",
|
||||||
|
"netcdf4-python/examples/data/prmsl.2005.nc",
|
||||||
|
"netcdf4-python/examples/data/prmsl.2006.nc",
|
||||||
|
"netcdf4-python/examples/data/prmsl.2007.nc",
|
||||||
|
"netcdf4-python/examples/data/prmsl.2008.nc",
|
||||||
|
"netcdf4-python/examples/data/prmsl.2009.nc",
|
||||||
|
"netcdf4-python/examples/data/prmsl.2010.nc",
|
||||||
|
"netcdf4-python/examples/data/prmsl.2011.nc",
|
||||||
|
"netcdf4-python/examples/data/rtofs_glo_3dz_f006_6hrly_reg3.nc",
|
||||||
|
"netcdf4-python/test/20171025_2056.Cloud_Top_Height.nc",
|
||||||
|
"netcdf4-python/test/issue1152.nc",
|
||||||
|
"netcdf4-python/test/issue671.nc",
|
||||||
|
"netcdf4-python/test/issue672.nc",
|
||||||
|
"netcdf4-python/test/test_gold.nc",
|
||||||
|
"usnistgov_h5wasm/test/array.h5",
|
||||||
|
"usnistgov_h5wasm/test/compressed.h5",
|
||||||
|
"usnistgov_h5wasm/test/empty.h5",
|
||||||
|
"usnistgov_h5wasm/test/float16.h5",
|
||||||
|
"usnistgov_h5wasm/test/vlen.h5",
|
||||||
|
"xarray-data/ROMS_example.nc",
|
||||||
|
"xarray-data/basin_mask.nc",
|
||||||
|
"xarray-data/imerghh_730.hdf5",
|
||||||
|
"xarray-data/precipitation.nc4"
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+88
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""check.py <results_dir> <baseline.json> [--update]
|
||||||
|
|
||||||
|
The conformance gate. Fails (exit 1) when
|
||||||
|
* clawhdf5 panicked, hung, crashed or ran out of memory on any file, or
|
||||||
|
* the ok count fell below the baseline's, or
|
||||||
|
* a file the baseline lists as ok is no longer ok (even if another file
|
||||||
|
became ok and the total held).
|
||||||
|
New ok files are reported so the baseline can be raised (--update rewrites it
|
||||||
|
from the results).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
FATAL = ("panic", "hang", "crash", "oom")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||||
|
update = "--update" in sys.argv
|
||||||
|
res_dir, base_path = args
|
||||||
|
res = json.load(open(os.path.join(res_dir, "results.json")))
|
||||||
|
rows = res["rows"]
|
||||||
|
counts = {}
|
||||||
|
per_corpus = {}
|
||||||
|
for r in rows:
|
||||||
|
counts[r["class"]] = counts.get(r["class"], 0) + 1
|
||||||
|
pc = per_corpus.setdefault(r["corpus"], {})
|
||||||
|
pc[r["class"]] = pc.get(r["class"], 0) + 1
|
||||||
|
ok_files = sorted(r["file"] for r in rows if r["class"] == "ok")
|
||||||
|
|
||||||
|
if update:
|
||||||
|
meta = {}
|
||||||
|
mp = os.path.join(res_dir, "report-meta.json")
|
||||||
|
if os.path.exists(mp):
|
||||||
|
meta = json.load(open(mp))
|
||||||
|
base = {
|
||||||
|
"comment": "conformance/run.sh fails if the ok count drops below `ok` or a file in `ok_files` stops being ok. "
|
||||||
|
"Regenerate with `conformance/run.sh --update-baseline` after an intended change.",
|
||||||
|
"commit": meta.get("commit", ""),
|
||||||
|
"date": meta.get("date", ""),
|
||||||
|
"reference": meta.get("reference", ""),
|
||||||
|
"files": len(rows),
|
||||||
|
"ok": len(ok_files),
|
||||||
|
"counts": dict(sorted(counts.items())),
|
||||||
|
"per_corpus": {k: dict(sorted(v.items())) for k, v in sorted(per_corpus.items())},
|
||||||
|
"ok_files": ok_files,
|
||||||
|
}
|
||||||
|
with open(base_path, "w") as fh:
|
||||||
|
json.dump(base, fh, indent=1)
|
||||||
|
fh.write("\n")
|
||||||
|
print(f"baseline updated: {len(ok_files)} ok of {len(rows)} files -> {base_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
base = json.load(open(base_path))
|
||||||
|
failures = []
|
||||||
|
fatal = [r for r in rows if r["class"] in FATAL]
|
||||||
|
for r in fatal:
|
||||||
|
failures.append(f"{r['class']}: {r['file']}: {r['ours_detail'][:200]}")
|
||||||
|
if len(ok_files) < base["ok"]:
|
||||||
|
failures.append(f"ok count dropped: {len(ok_files)} < baseline {base['ok']}")
|
||||||
|
now_ok = set(ok_files)
|
||||||
|
by_file = {r["file"]: r for r in rows}
|
||||||
|
for f in base["ok_files"]:
|
||||||
|
if f not in now_ok:
|
||||||
|
r = by_file.get(f)
|
||||||
|
why = f"now {r['class']}: {(r['ours_detail'] or r['first_issue'])[:200]}" if r else "no longer in the corpus"
|
||||||
|
failures.append(f"regressed: {f}: {why}")
|
||||||
|
gained = sorted(now_ok - set(base["ok_files"]))
|
||||||
|
|
||||||
|
print(f"conformance: {len(ok_files)} ok of {len(rows)} files (baseline {base['ok']} of {base['files']}); "
|
||||||
|
+ ", ".join(f"{k} {v}" for k, v in sorted(counts.items())))
|
||||||
|
if gained:
|
||||||
|
print(f"{len(gained)} file(s) newly ok — raise the baseline with `conformance/run.sh --update-baseline`:")
|
||||||
|
for f in gained:
|
||||||
|
print(f" + {f}")
|
||||||
|
if failures:
|
||||||
|
print(f"CONFORMANCE GATE FAILED ({len(failures)}):")
|
||||||
|
for f in failures:
|
||||||
|
print(f" - {f}")
|
||||||
|
return 1
|
||||||
|
print("conformance gate passed")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+289
@@ -0,0 +1,289 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""compare.py <results_dir>: classify each file and group failures by root cause.
|
||||||
|
|
||||||
|
Writes <results_dir>/results.csv, results.json and summary.md.
|
||||||
|
File classes (first match wins):
|
||||||
|
hang, oom, crash, panic ours: timeout / allocation failure / signal / any panic (caught or not)
|
||||||
|
h5py-cannot-read libhdf5/h5py failed to open the file (or crashed/hung)
|
||||||
|
our-error we fail to open, list, or read something h5py reads
|
||||||
|
mismatch we read something with different shape/values, or a different object set
|
||||||
|
ok
|
||||||
|
"""
|
||||||
|
import collections
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
R = sys.argv[1]
|
||||||
|
RUNS = os.path.join(R, "runs")
|
||||||
|
|
||||||
|
|
||||||
|
def load(d, name):
|
||||||
|
rc_p = os.path.join(d, name + ".rc")
|
||||||
|
if not os.path.exists(rc_p):
|
||||||
|
return None
|
||||||
|
rc = int(open(rc_p).read().strip() or -1)
|
||||||
|
err = open(os.path.join(d, name + ".err"), errors="replace").read()
|
||||||
|
js = None
|
||||||
|
try:
|
||||||
|
js = json.load(open(os.path.join(d, name + ".json")))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return {"rc": rc, "err": err, "json": js}
|
||||||
|
|
||||||
|
|
||||||
|
def proc_status(p):
|
||||||
|
"""-> (status, detail)"""
|
||||||
|
if p is None:
|
||||||
|
return "missing", ""
|
||||||
|
rc, err = p["rc"], p["err"]
|
||||||
|
first_panic = next((ln for ln in err.splitlines() if ln.startswith("PANIC:") or "panicked at" in ln), "")
|
||||||
|
if rc == 0 and p["json"] is not None:
|
||||||
|
return "ok", ""
|
||||||
|
if rc == 137 or rc == 124:
|
||||||
|
return "hang", f"timeout ({os.environ.get('TMO', '20')} s)"
|
||||||
|
if "memory allocation of" in err or "MemoryError" in err or "std::bad_alloc" in err:
|
||||||
|
m = re.search(r"memory allocation of \d+ bytes failed", err)
|
||||||
|
return "oom", m.group(0) if m else "allocation failure"
|
||||||
|
if "overflowed its stack" in err:
|
||||||
|
return "crash", "stack overflow"
|
||||||
|
if rc == 101:
|
||||||
|
return "panic", first_panic or (err.strip().splitlines() or [""])[-1]
|
||||||
|
if rc in (134, 139, 136, 135, 132) or rc > 128:
|
||||||
|
sig = {134: "SIGABRT", 139: "SIGSEGV", 136: "SIGFPE", 135: "SIGBUS", 132: "SIGILL"}.get(rc, f"signal {rc - 128}")
|
||||||
|
tail = [ln for ln in err.strip().splitlines() if ln.strip()][-1:]
|
||||||
|
return "crash", f"{sig}: {tail[0][:200] if tail else ''}"
|
||||||
|
tail = [ln for ln in err.strip().splitlines() if ln.strip()][-1:]
|
||||||
|
return "crash", f"rc={rc}: {tail[0][:200] if tail else ''}"
|
||||||
|
|
||||||
|
|
||||||
|
def norm(msg):
|
||||||
|
m = msg.split("\n")[0]
|
||||||
|
m = re.sub(r"0x[0-9a-fA-F]+", "X", m)
|
||||||
|
m = re.sub(r'"[^"]*"', '"…"', m)
|
||||||
|
m = re.sub(r"'[^']*'", "'…'", m)
|
||||||
|
m = re.sub(r"\d+", "N", m)
|
||||||
|
return m[:160]
|
||||||
|
|
||||||
|
|
||||||
|
def panic_head(msg):
|
||||||
|
"""First line + first clawhdf5 frame of a PANIC record."""
|
||||||
|
lines = msg.split("\n")
|
||||||
|
frame = next((ln.strip() for ln in lines[1:] if "clawhdf5_format" in ln), "")
|
||||||
|
return lines[0][:300], frame[:300]
|
||||||
|
|
||||||
|
|
||||||
|
def eq_shape(a, b):
|
||||||
|
return a == b
|
||||||
|
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
issues_by_file = {}
|
||||||
|
root_causes = collections.defaultdict(lambda: {"files": set(), "count": 0, "examples": []})
|
||||||
|
mismatch_causes = collections.defaultdict(lambda: {"files": set(), "count": 0, "examples": []})
|
||||||
|
panics = []
|
||||||
|
ref_only_errors = collections.Counter()
|
||||||
|
incomparable = collections.Counter()
|
||||||
|
|
||||||
|
|
||||||
|
def add(bucket, key, file, example):
|
||||||
|
b = bucket[key]
|
||||||
|
b["count"] += 1
|
||||||
|
if file not in b["files"] and len(b["examples"]) < 6:
|
||||||
|
b["examples"].append(example)
|
||||||
|
b["files"].add(file)
|
||||||
|
|
||||||
|
|
||||||
|
files = [ln.strip() for ln in open(os.path.join(R, "files.txt")) if ln.strip()]
|
||||||
|
for rel in files:
|
||||||
|
d = os.path.join(RUNS, rel.replace("/", "__"))
|
||||||
|
corpus = rel.split("/")[0]
|
||||||
|
ours, ref = load(d, "ours"), load(d, "ref")
|
||||||
|
h5dump = load(d, "h5dump")
|
||||||
|
os_, od = proc_status(ours)
|
||||||
|
rs, rd = proc_status(ref)
|
||||||
|
oj = ours["json"] if ours else None
|
||||||
|
rj = ref["json"] if ref else None
|
||||||
|
issues = [] # (kind, detail)
|
||||||
|
caught_panics = []
|
||||||
|
|
||||||
|
def scan_err(path, what, msg):
|
||||||
|
if msg.startswith("PANIC:"):
|
||||||
|
caught_panics.append((path, what, msg))
|
||||||
|
|
||||||
|
if oj:
|
||||||
|
for o in oj.get("objects", []):
|
||||||
|
for k in ("error", "attrs_error", "list_error"):
|
||||||
|
if k in o:
|
||||||
|
scan_err(o["path"], k, o[k])
|
||||||
|
for an, av in (o.get("attrs") or {}).items():
|
||||||
|
if "error" in av:
|
||||||
|
scan_err(o["path"], f"attr {an}", av["error"])
|
||||||
|
if oj.get("open_error", "").startswith("PANIC:"):
|
||||||
|
caught_panics.append(("<open>", "open", oj["open_error"]))
|
||||||
|
|
||||||
|
ref_open_fail = rs != "ok" or (rj is not None and "open_error" in rj)
|
||||||
|
ours_open_err = oj.get("open_error") if oj else None
|
||||||
|
n_obj = n_ok = 0
|
||||||
|
if os_ == "ok" and rj and not ref_open_fail and not ours_open_err:
|
||||||
|
ro = {x["path"]: x for x in rj.get("objects", [])}
|
||||||
|
oo = {x["path"]: x for x in oj.get("objects", [])}
|
||||||
|
our_list_errors = [x for x in oo.values() if "list_error" in x]
|
||||||
|
for p in sorted(set(ro) | set(oo)):
|
||||||
|
a, b = ro.get(p), oo.get(p)
|
||||||
|
n_obj += 1
|
||||||
|
if a is None:
|
||||||
|
issues.append(("mismatch", f"extra object {p} (kind={b.get('kind')})", "extra-object", b))
|
||||||
|
continue
|
||||||
|
if b is None:
|
||||||
|
if our_list_errors:
|
||||||
|
continue # accounted for by the list_error
|
||||||
|
issues.append(("mismatch", f"missing object {p} (kind={a.get('kind')})", "missing-object", a))
|
||||||
|
continue
|
||||||
|
ok = True
|
||||||
|
if a.get("kind") != b.get("kind") and "error" not in b and "error" not in a:
|
||||||
|
issues.append(("mismatch", f"{p}: kind {a.get('kind')} vs ours {b.get('kind')}", "kind", b))
|
||||||
|
ok = False
|
||||||
|
for k in ("error", "list_error", "attrs_error"):
|
||||||
|
if k in b and k not in a:
|
||||||
|
issues.append(("our-error", f"{p}: {k}: {b[k]}", b[k], b))
|
||||||
|
ok = False
|
||||||
|
elif k in a and k not in b and k == "error":
|
||||||
|
ref_only_errors[norm(a[k])] += 1
|
||||||
|
if a.get("kind") == "dataset" and "error" not in a and "error" not in b:
|
||||||
|
if "skipped" in a or "skipped" in b:
|
||||||
|
pass
|
||||||
|
elif a.get("converted"):
|
||||||
|
incomparable[f"dataset {a['converted']}"] += 1
|
||||||
|
elif a.get("shape") != b.get("shape"):
|
||||||
|
issues.append(("mismatch", f"{p}: shape {a.get('shape')} vs ours {b.get('shape')}", "shape", b))
|
||||||
|
ok = False
|
||||||
|
elif a.get("hash") != b.get("hash"):
|
||||||
|
issues.append(("mismatch", f"{p}: values differ (h5py {a.get('dtype')} vs ours {b.get('dtype')})", "values", b | {"ref_head": a.get("head"), "ref_dtype": a.get("dtype")}))
|
||||||
|
ok = False
|
||||||
|
ra, oa = a.get("attrs") or {}, b.get("attrs") or {}
|
||||||
|
if "attrs_error" not in b and "attrs_error" not in a:
|
||||||
|
for an in sorted(set(ra) | set(oa)):
|
||||||
|
x, y = ra.get(an), oa.get(an)
|
||||||
|
if x is None:
|
||||||
|
issues.append(("mismatch", f"{p}@{an}: extra attribute", "extra-attr", y or {}))
|
||||||
|
elif y is None:
|
||||||
|
issues.append(("mismatch", f"{p}@{an}: missing attribute", "missing-attr", x))
|
||||||
|
elif "error" in y and "error" not in x:
|
||||||
|
issues.append(("our-error", f"{p}@{an}: {y['error']}", y["error"], y))
|
||||||
|
elif "error" in x:
|
||||||
|
continue
|
||||||
|
elif x.get("converted"):
|
||||||
|
incomparable[f"attr {x['converted']}"] += 1
|
||||||
|
elif x.get("shape") != y.get("shape"):
|
||||||
|
issues.append(("mismatch", f"{p}@{an}: attr shape {x.get('shape')} vs ours {y.get('shape')}", "attr-shape", y | {"ref_dtype": x.get("dtype")}))
|
||||||
|
elif x.get("hash") != y.get("hash"):
|
||||||
|
issues.append(("mismatch", f"{p}@{an}: attr values differ (h5py {x.get('dtype')} vs ours {y.get('dtype')})", "attr-values", y | {"ref_head": x.get("head"), "ref_dtype": x.get("dtype")}))
|
||||||
|
if ok:
|
||||||
|
n_ok += 1
|
||||||
|
|
||||||
|
# classify
|
||||||
|
if os_ in ("hang", "oom", "crash", "panic"):
|
||||||
|
cls = os_
|
||||||
|
elif caught_panics:
|
||||||
|
cls = "panic"
|
||||||
|
elif ref_open_fail:
|
||||||
|
cls = "h5py-cannot-read"
|
||||||
|
elif ours_open_err:
|
||||||
|
cls = "our-error"
|
||||||
|
issues.append(("our-error", f"open: {ours_open_err}", ours_open_err, {}))
|
||||||
|
elif any(i[0] == "our-error" for i in issues):
|
||||||
|
cls = "our-error"
|
||||||
|
elif issues:
|
||||||
|
cls = "mismatch"
|
||||||
|
else:
|
||||||
|
cls = "ok"
|
||||||
|
|
||||||
|
if os_ in ("hang", "oom", "crash", "panic") or caught_panics:
|
||||||
|
panics.append({
|
||||||
|
"file": rel, "class": cls, "detail": od,
|
||||||
|
"stderr": (ours["err"] if ours else "")[:3000],
|
||||||
|
"caught": [(p, w, m[:2500]) for p, w, m in caught_panics[:3]],
|
||||||
|
"n_caught": len(caught_panics),
|
||||||
|
})
|
||||||
|
for kind, detail, key, rec in issues:
|
||||||
|
if kind == "our-error":
|
||||||
|
add(root_causes, norm(key), rel, detail[:300])
|
||||||
|
else:
|
||||||
|
if key in ("values", "attr-values", "shape", "attr-shape"):
|
||||||
|
mk = f"{key}: ours={rec.get('dtype')} h5py={rec.get('ref_dtype')} layout={rec.get('layout','-')} filters={rec.get('filters','-')}"
|
||||||
|
else:
|
||||||
|
mk = key
|
||||||
|
add(mismatch_causes, mk, rel, detail[:300] + (f" | ref_head={rec.get('ref_head')} our_head={rec.get('head')}" if rec.get("ref_head") else ""))
|
||||||
|
ref_detail = rd if rs != "ok" else ((rj or {}).get("open_error") or "")
|
||||||
|
h5d = ""
|
||||||
|
if h5dump:
|
||||||
|
rc = h5dump["rc"]
|
||||||
|
h5d = {0: "ok", 1: "error", 137: "hang", 124: "hang", 134: "SIGABRT", 139: "SIGSEGV", 136: "SIGFPE", 135: "SIGBUS"}.get(rc, f"rc={rc}")
|
||||||
|
if "memory allocation" in h5dump["err"] or "Cannot allocate" in h5dump["err"]:
|
||||||
|
h5d += "(oom)"
|
||||||
|
rows.append({
|
||||||
|
"file": rel, "corpus": corpus, "class": cls,
|
||||||
|
"ours": os_ if os_ != "ok" else ("open-error" if ours_open_err else ("panic" if caught_panics else "ok")),
|
||||||
|
"ours_detail": (od or ours_open_err or (caught_panics[0][2].split("\n")[0] if caught_panics else ""))[:300],
|
||||||
|
"ref": rs if rs != "ok" else ("open-error" if (rj or {}).get("open_error") else "ok"),
|
||||||
|
"ref_detail": ref_detail[:300],
|
||||||
|
"h5dump_1_14_6": h5d,
|
||||||
|
"h5dump_detail": ([ln for ln in h5dump["err"].splitlines() if ln.strip()][-1:] or [""])[0][:200] if h5dump else "",
|
||||||
|
"objects": n_obj, "objects_ok": n_ok,
|
||||||
|
"issues": len(issues), "first_issue": issues[0][1][:300] if issues else "",
|
||||||
|
"superblock": (oj or {}).get("superblock_version", ""),
|
||||||
|
})
|
||||||
|
# the first issues of each file, for report.py's known-cause matching
|
||||||
|
issues_by_file[rel] = [
|
||||||
|
{"kind": k, "key": key, "detail": det[:300], "ours_dtype": rec.get("dtype"), "ref_dtype": rec.get("ref_dtype")}
|
||||||
|
for k, det, key, rec in issues[:50]
|
||||||
|
]
|
||||||
|
|
||||||
|
with open(os.path.join(R, "results.csv"), "w", newline="") as fh:
|
||||||
|
w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
|
||||||
|
w.writeheader()
|
||||||
|
w.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def ser(b):
|
||||||
|
return {k: {"files": len(v["files"]), "count": v["count"], "examples": v["examples"], "file_list": sorted(v["files"])} for k, v in sorted(b.items(), key=lambda kv: -len(kv[1]["files"]))}
|
||||||
|
|
||||||
|
|
||||||
|
json.dump({"rows": rows, "issues": issues_by_file, "root_causes": ser(root_causes), "mismatch_causes": ser(mismatch_causes),
|
||||||
|
"panics": panics, "incomparable": incomparable.most_common(), "ref_only_errors": ref_only_errors.most_common()},
|
||||||
|
open(os.path.join(R, "results.json"), "w"), indent=1)
|
||||||
|
|
||||||
|
classes = ["ok", "our-error", "mismatch", "h5py-cannot-read", "hang", "panic", "crash", "oom"]
|
||||||
|
by_corpus = collections.defaultdict(collections.Counter)
|
||||||
|
for r in rows:
|
||||||
|
by_corpus[r["corpus"]][r["class"]] += 1
|
||||||
|
by_corpus["ALL"][r["class"]] += 1
|
||||||
|
lines = ["# Conformance sweep summary", "", "| corpus | files | " + " | ".join(classes) + " |", "|---" * (len(classes) + 2) + "|"]
|
||||||
|
for c in sorted(by_corpus, key=lambda k: (k == "ALL", k)):
|
||||||
|
cnt = by_corpus[c]
|
||||||
|
lines.append(f"| {c} | {sum(cnt.values())} | " + " | ".join(str(cnt.get(k, 0)) for k in classes) + " |")
|
||||||
|
lines += ["", "## Panics / hangs / crashes / OOM", ""]
|
||||||
|
for p in panics:
|
||||||
|
lines.append(f"- **{p['file']}** [{p['class']}] {p['detail']}")
|
||||||
|
for path, what, m in p["caught"][:1]:
|
||||||
|
lines.append(" ```\n " + f"{path} ({what}): " + m.replace("\n", "\n ")[:1500] + "\n ```")
|
||||||
|
if not p["caught"] and p["stderr"]:
|
||||||
|
lines.append(" ```\n " + p["stderr"].strip()[:1500].replace("\n", "\n ") + "\n ```")
|
||||||
|
lines += ["", "## Our-error root causes (files affected)", ""]
|
||||||
|
for k, v in ser(root_causes).items():
|
||||||
|
lines.append(f"- [{v['files']} files, {v['count']} objs] `{k}`")
|
||||||
|
for ex in v["examples"][:3]:
|
||||||
|
lines.append(f" - {ex}")
|
||||||
|
lines += ["", "## Mismatch root causes", ""]
|
||||||
|
for k, v in ser(mismatch_causes).items():
|
||||||
|
lines.append(f"- [{v['files']} files, {v['count']} objs] `{k}`")
|
||||||
|
for ex in v["examples"][:3]:
|
||||||
|
lines.append(f" - {ex}")
|
||||||
|
lines += ["", "## Objects h5py fails on but we read (top)", ""]
|
||||||
|
for k, n in ref_only_errors.most_common(15):
|
||||||
|
lines.append(f"- {n} x `{k}`")
|
||||||
|
open(os.path.join(R, "summary.md"), "w").write("\n".join(lines) + "\n")
|
||||||
|
print("\n".join(lines[:4 + len(by_corpus)]))
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Conformance corpora, pinned by commit. fetch-corpus.sh reads this file.
|
||||||
|
#
|
||||||
|
# name git-url commit root [sparse-checkout patterns...]
|
||||||
|
#
|
||||||
|
# `root` is the directory inside the checkout that is swept ("." = all of it).
|
||||||
|
# Patterns are git non-cone sparse-checkout patterns; none = whole repository.
|
||||||
|
# Every file under <root> with an HDF5/netCDF-4 extension is probed; for
|
||||||
|
# cve_hdf5 the extension-less files in cvefiles/ and fuzzerfiles/ are too.
|
||||||
|
# Licences: each corpus keeps its upstream licence; nothing here is committed
|
||||||
|
# to this repository — the files are downloaded into the gitignored cache.
|
||||||
|
hdf5 https://github.com/HDFGroup/hdf5.git a3cf1ea82cc7a66e50029a688121e1b105a7ce88 . *.h5 *.he5 *.nc *.hdf5 *.h5f
|
||||||
|
cve_hdf5 https://github.com/HDFGroup/cve_hdf5.git 3fd1f5ae3869e01b8ae02b41d7108de7ffb1a374 .
|
||||||
|
netcdf-c https://github.com/Unidata/netcdf-c.git beb7b9585273c1548386231a59b809d906359033 . /nc_test4/*.nc /ncdump/*.nc /nc_test4/*.h5 /ncdump/*.h5 /h5_test/*.h5 /hdf5_test/*.h5
|
||||||
|
NCAS-CMS_pyfive https://github.com/NCAS-CMS/pyfive.git 8cf07b8749133f41c5e30b8a4c604486f687fe74 . *.h5 *.hdf5 *.hdf *.nc *.he5
|
||||||
|
usnistgov_h5wasm https://github.com/usnistgov/h5wasm.git 02f6336527d2812783fcedabfbf42127ec8d06d2 . *.h5 *.hdf5 *.hdf *.nc *.he5
|
||||||
|
netcdf4-python https://github.com/Unidata/netcdf4-python.git 6e67576d39aef8091fb20bd767b4f1a52ddc1bec . *.nc *.h5
|
||||||
|
xarray-data https://github.com/pydata/xarray-data.git a35297e9da2cc99c811014f0c8a4297345a5c28d . /basin_mask.nc /precipitation.nc4 /imerghh_730.hdf5 /eraint_uvz.nc /ROMS_example.nc /tiny.nc
|
||||||
|
# h5py 3.16.0 (tag 3.16.0), its test data files.
|
||||||
|
h5py_data https://github.com/h5py/h5py.git b2f0347c4200333acd89b43733f1caa0c115162f h5py/tests/data_files /h5py/tests/data_files/*
|
||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# fetch-corpus.sh [cache_dir]
|
||||||
|
#
|
||||||
|
# Download the corpora pinned in conformance/corpus.txt into the (gitignored)
|
||||||
|
# cache: <cache>/src/<name> is a shallow, sparse, blob-filtered checkout of the
|
||||||
|
# pinned commit and <cache>/corpus/<name> links to the swept root inside it.
|
||||||
|
# A corpus already checked out at its pinned commit is left alone, so a second
|
||||||
|
# run costs nothing and needs no network.
|
||||||
|
set -euo pipefail
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
CACHE="${1:-${CONFORMANCE_CACHE:-$HERE/.cache}}"
|
||||||
|
mkdir -p "$CACHE/src" "$CACHE/corpus"
|
||||||
|
CACHE="$(cd "$CACHE" && pwd)"
|
||||||
|
|
||||||
|
retry() { local i; for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 5)); done; return 1; }
|
||||||
|
|
||||||
|
grep -v '^[[:space:]]*\(#\|$\)' "$HERE/corpus.txt" | while read -r name url commit root patterns; do
|
||||||
|
src="$CACHE/src/$name"
|
||||||
|
if [ -d "$src/.git" ] && [ "$(git -C "$src" rev-parse HEAD 2>/dev/null)" = "$commit" ]; then
|
||||||
|
echo "cached $name @ ${commit:0:12}"
|
||||||
|
else
|
||||||
|
echo "fetching $name @ ${commit:0:12} from $url"
|
||||||
|
rm -rf "$src"
|
||||||
|
git init -q "$src"
|
||||||
|
git -C "$src" remote add origin "$url"
|
||||||
|
git -C "$src" config advice.detachedHead false
|
||||||
|
if [ -n "$patterns" ]; then
|
||||||
|
git -C "$src" config core.sparseCheckout true
|
||||||
|
# no-cone patterns (globs); `set -f` keeps the shell from expanding them
|
||||||
|
(set -f; printf '%s\n' $patterns) > "$src/.git/info/sparse-checkout"
|
||||||
|
fi
|
||||||
|
retry git -C "$src" fetch -q --depth 1 --filter=blob:none origin "$commit"
|
||||||
|
retry git -C "$src" checkout -q FETCH_HEAD
|
||||||
|
got="$(git -C "$src" rev-parse HEAD)"
|
||||||
|
[ "$got" = "$commit" ] || { echo "error: $name checked out $got, expected $commit" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
ln -sfn "$src/$root" "$CACHE/corpus/$name"
|
||||||
|
done
|
||||||
|
echo "corpus ready in $CACHE/corpus"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""list_files.py <corpus_dir>: print the files the sweep probes, one per line,
|
||||||
|
as <corpus>/<path> in byte order.
|
||||||
|
|
||||||
|
* every file named *.h5 *.hdf5 *.he5 *.nc *.nc4 *.hdf *.h5f in each corpus,
|
||||||
|
except netCDF classic / 64-bit-offset / CDF5 files (magic "CDF"): they are
|
||||||
|
not HDF5, so neither side can read them and they say nothing;
|
||||||
|
* plus, for cve_hdf5, every file in cvefiles/ and fuzzerfiles/ except
|
||||||
|
.md/.c sources — the reproducers are mostly extension-less, and they are
|
||||||
|
kept whatever their bytes look like (that is their point).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
EXTS = (".h5", ".hdf5", ".he5", ".nc", ".nc4", ".hdf", ".h5f")
|
||||||
|
|
||||||
|
|
||||||
|
def walk(top):
|
||||||
|
for dirpath, dirnames, filenames in os.walk(top):
|
||||||
|
dirnames[:] = [d for d in dirnames if d != ".git"]
|
||||||
|
for fn in filenames:
|
||||||
|
p = os.path.join(dirpath, fn)
|
||||||
|
if os.path.isfile(p) and not os.path.islink(p):
|
||||||
|
yield os.path.relpath(p, top)
|
||||||
|
|
||||||
|
|
||||||
|
def main(root):
|
||||||
|
out = set()
|
||||||
|
for corpus in sorted(os.listdir(root)):
|
||||||
|
top = os.path.join(root, corpus)
|
||||||
|
if not os.path.isdir(top):
|
||||||
|
continue
|
||||||
|
for rel in walk(top):
|
||||||
|
path = os.path.join(top, rel)
|
||||||
|
if rel.lower().endswith(EXTS):
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
if fh.read(3) == b"CDF":
|
||||||
|
continue
|
||||||
|
out.add(f"{corpus}/{rel}")
|
||||||
|
elif corpus == "cve_hdf5" and rel.split(os.sep)[0] in ("cvefiles", "fuzzerfiles") \
|
||||||
|
and not rel.endswith((".md", ".c")):
|
||||||
|
out.add(f"{corpus}/{rel}")
|
||||||
|
for f in sorted(out, key=lambda s: s.encode()):
|
||||||
|
print(f)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1])
|
||||||
Generated
+458
@@ -0,0 +1,458 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "better_io"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef0a3155e943e341e557863e69a708999c94ede624e37865c8e2a91b94efa78f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clawhdf5-format"
|
||||||
|
version = "2.7.0"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"flate2",
|
||||||
|
"libaec-sys",
|
||||||
|
"lz4_flex",
|
||||||
|
"pco",
|
||||||
|
"portable-atomic",
|
||||||
|
"sha2",
|
||||||
|
"zstd",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "conformance-probe"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"clawhdf5-format",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dtype_dispatch"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab23e69df104e2fd85ee63a533a22d2132ef5975dc6b36f9f3e5a7305e4a8ed7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
"zlib-rs",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "half"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"crunchy",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jobserver"
|
||||||
|
version = "0.1.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libaec-sys"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lz4_flex"
|
||||||
|
version = "0.11.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
|
||||||
|
dependencies = [
|
||||||
|
"twox-hash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pco"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "386342cad4c6e97f081568e5d910ea7d871314c843aa8fc564f2a6b64cab9456"
|
||||||
|
dependencies = [
|
||||||
|
"better_io",
|
||||||
|
"dtype_dispatch",
|
||||||
|
"half",
|
||||||
|
"rand_xoshiro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_xoshiro"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.151"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "twox-hash"
|
||||||
|
version = "2.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.26"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.59"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.59"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zlib-rs"
|
||||||
|
version = "0.6.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-safe",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-safe"
|
||||||
|
version = "7.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-sys"
|
||||||
|
version = "2.1.0+zstd.1.5.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "conformance-probe"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.92"
|
||||||
|
publish = false
|
||||||
|
description = "Walks an HDF5 file with clawhdf5-format and prints a canonical JSON description (see conformance/README.md)"
|
||||||
|
|
||||||
|
# Deliberately outside the main workspace: `cargo test --workspace` never
|
||||||
|
# builds it, and it links the optional C codecs (zstd, libaec) that the core
|
||||||
|
# crates' default build must not.
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clawhdf5-format = { path = "../../crates/clawhdf5-format", features = ["lz4", "zstd", "szip", "pcodec"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
# Keep panics catchable (the probe records them per object) and turn integer
|
||||||
|
# overflow into a reported panic instead of silent wraparound.
|
||||||
|
debug = 1
|
||||||
|
overflow-checks = true
|
||||||
|
debug-assertions = true
|
||||||
|
panic = "unwind"
|
||||||
@@ -0,0 +1,898 @@
|
|||||||
|
//! Conformance probe: walks an HDF5 file with clawhdf5-format (the same calls
|
||||||
|
//! the `clawhdf5` facade makes) and prints a canonical JSON description:
|
||||||
|
//! every hard-linked object (sorted-name DFS, deduplicated by header address),
|
||||||
|
//! and for each dataset / attribute its shape plus the SHA-256 of its values
|
||||||
|
//! in a canonical encoding shared with `ref.py`.
|
||||||
|
//!
|
||||||
|
//! Canonical value encoding (per element, concatenated, row-major):
|
||||||
|
//! int / float / bitfield / enum / time : element bytes, little-endian
|
||||||
|
//! non-IEEE-layout float (e.g. N-Bit) : the IEEE float of the same size it converts to
|
||||||
|
//! int with bit offset / short precision: the full-width integer it converts to
|
||||||
|
//! opaque : raw bytes
|
||||||
|
//! compound : members in declaration order (padding dropped)
|
||||||
|
//! array : base elements row-major
|
||||||
|
//! string (fixed or VL) : b'S' + u32le len + bytes (cut at first NUL, trailing spaces stripped)
|
||||||
|
//! VL sequence : b'V' + u32le count + base elements
|
||||||
|
//! reference : b'R' (payload not compared)
|
||||||
|
//!
|
||||||
|
//! Every object is processed inside catch_unwind; a caught panic is recorded
|
||||||
|
//! with its message, location and the clawhdf5 frames of its backtrace.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::panic::{self, AssertUnwindSafe};
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use clawhdf5_format::attribute::extract_attributes_full;
|
||||||
|
use clawhdf5_format::data_layout::DataLayout;
|
||||||
|
use clawhdf5_format::data_read;
|
||||||
|
use clawhdf5_format::dataspace::{Dataspace, DataspaceType};
|
||||||
|
use clawhdf5_format::datatype::{Datatype, DatatypeByteOrder};
|
||||||
|
use clawhdf5_format::filter_pipeline::FilterPipeline;
|
||||||
|
use clawhdf5_format::global_heap::GlobalHeapCollection;
|
||||||
|
use clawhdf5_format::group_v1::{self, GroupEntry};
|
||||||
|
use clawhdf5_format::group_v2;
|
||||||
|
use clawhdf5_format::message_type::MessageType;
|
||||||
|
use clawhdf5_format::object_header::ObjectHeader;
|
||||||
|
use clawhdf5_format::signature;
|
||||||
|
use clawhdf5_format::superblock::Superblock;
|
||||||
|
use clawhdf5_format::symbol_table::SymbolTableMessage;
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
const MAX_BYTES: u64 = 200 * 1024 * 1024;
|
||||||
|
const MAX_OBJECTS: usize = 200_000;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static LAST_PANIC: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_hook() {
|
||||||
|
panic::set_hook(Box::new(|info| {
|
||||||
|
let msg = if let Some(s) = info.payload().downcast_ref::<&str>() {
|
||||||
|
s.to_string()
|
||||||
|
} else if let Some(s) = info.payload().downcast_ref::<String>() {
|
||||||
|
s.clone()
|
||||||
|
} else {
|
||||||
|
"<non-string panic>".into()
|
||||||
|
};
|
||||||
|
let loc = info
|
||||||
|
.location()
|
||||||
|
.map(|l| format!("{}:{}", l.file(), l.line()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let bt = std::backtrace::Backtrace::force_capture().to_string();
|
||||||
|
// keep only frames from clawhdf5 code
|
||||||
|
let mut frames = Vec::new();
|
||||||
|
let lines: Vec<&str> = bt.lines().collect();
|
||||||
|
for (i, l) in lines.iter().enumerate() {
|
||||||
|
let t = l.trim();
|
||||||
|
if t.contains("clawhdf5_format::") || t.contains("conformance_probe::") {
|
||||||
|
let at = lines
|
||||||
|
.get(i + 1)
|
||||||
|
.map(|n| n.trim())
|
||||||
|
.filter(|n| n.starts_with("at "))
|
||||||
|
.map(|n| {
|
||||||
|
let n = n.trim_start_matches("at ");
|
||||||
|
match n.find("/crates/") {
|
||||||
|
Some(p) => n[p + 1..].to_string(),
|
||||||
|
None => n.to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let name = t.split_once(": ").map(|x| x.1).unwrap_or(t);
|
||||||
|
frames.push(format!("{name} ({at})"));
|
||||||
|
if frames.len() >= 12 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let full = format!("PANIC: {msg} @ {loc}\n {}", frames.join("\n "));
|
||||||
|
eprintln!("{full}");
|
||||||
|
LAST_PANIC.with(|p| *p.borrow_mut() = Some(full));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `f`, turning a panic into Err("PANIC: ...").
|
||||||
|
fn guarded<T>(f: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
|
||||||
|
match panic::catch_unwind(AssertUnwindSafe(f)) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => Err(LAST_PANIC
|
||||||
|
.with(|p| p.borrow_mut().take())
|
||||||
|
.unwrap_or_else(|| "PANIC: <unknown>".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn e<E: std::fmt::Debug>(x: E) -> String {
|
||||||
|
format!("{x:?}")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Ctx<'a> {
|
||||||
|
data: &'a [u8],
|
||||||
|
os: u8,
|
||||||
|
ls: u8,
|
||||||
|
base_dir: std::path::PathBuf,
|
||||||
|
heaps: RefCell<HashMap<u64, Result<Rc<GlobalHeapCollection>, String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Ctx<'a> {
|
||||||
|
fn header(&self, addr: u64) -> Result<ObjectHeader, String> {
|
||||||
|
ObjectHeader::parse(self.data, addr as usize, self.os, self.ls).map_err(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload(&self, h: &ObjectHeader, t: MessageType) -> Result<Option<Vec<u8>>, String> {
|
||||||
|
match h.messages.iter().find(|m| m.msg_type == t) {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(m) => {
|
||||||
|
clawhdf5_format::shared_message::message_data(self.data, m, self.os, self.ls)
|
||||||
|
.map(|c| Some(c.into_owned()))
|
||||||
|
.map_err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn heap_obj(&self, addr: u64, idx: u32) -> Result<Vec<u8>, String> {
|
||||||
|
let coll = {
|
||||||
|
let mut cache = self.heaps.borrow_mut();
|
||||||
|
cache
|
||||||
|
.entry(addr)
|
||||||
|
.or_insert_with(|| {
|
||||||
|
GlobalHeapCollection::parse(self.data, addr as usize, self.ls)
|
||||||
|
.map(Rc::new)
|
||||||
|
.map_err(e)
|
||||||
|
})
|
||||||
|
.clone()?
|
||||||
|
};
|
||||||
|
coll.get_object(idx as u16)
|
||||||
|
.map(|o| o.data.clone())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!("GlobalHeapObjectNotFound {{ collection_address: {addr}, index: {idx} }}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_offset(&self, b: &[u8]) -> u64 {
|
||||||
|
let mut v = 0u64;
|
||||||
|
for (i, x) in b.iter().take(self.os as usize).enumerate() {
|
||||||
|
v |= (*x as u64) << (8 * i);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canon(&self, dt: &Datatype, b: &[u8], out: &mut Vec<u8>) -> Result<(), String> {
|
||||||
|
let size = dt.type_size() as usize;
|
||||||
|
if b.len() < size {
|
||||||
|
return Err(format!(
|
||||||
|
"canon: element slice {} < type size {size}",
|
||||||
|
b.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match dt {
|
||||||
|
Datatype::FloatingPoint { .. } if !ieee_layout(dt) => {
|
||||||
|
canon_custom_float(dt, &b[..size], out)?
|
||||||
|
}
|
||||||
|
Datatype::FixedPoint { .. } if partial_int(dt) => {
|
||||||
|
canon_partial_int(dt, &b[..size], out)?
|
||||||
|
}
|
||||||
|
Datatype::FixedPoint { byte_order, .. }
|
||||||
|
| Datatype::BitField { byte_order, .. }
|
||||||
|
| Datatype::FloatingPoint { byte_order, .. } => match byte_order {
|
||||||
|
DatatypeByteOrder::LittleEndian => out.extend_from_slice(&b[..size]),
|
||||||
|
DatatypeByteOrder::BigEndian => out.extend(b[..size].iter().rev()),
|
||||||
|
DatatypeByteOrder::Vax => return Err("canon: VAX byte order".into()),
|
||||||
|
},
|
||||||
|
Datatype::Time { .. } | Datatype::Opaque { .. } => out.extend_from_slice(&b[..size]),
|
||||||
|
Datatype::String { .. } => canon_str(&b[..size], out),
|
||||||
|
Datatype::Compound { members, .. } => {
|
||||||
|
for m in members {
|
||||||
|
let off = m.byte_offset as usize;
|
||||||
|
let ms = m.datatype.type_size() as usize;
|
||||||
|
if off.checked_add(ms).is_none_or(|end| end > size) {
|
||||||
|
return Err(format!("canon: member {} out of bounds", m.name));
|
||||||
|
}
|
||||||
|
self.canon(&m.datatype, &b[off..off + ms], out)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Datatype::Reference { .. } => out.push(b'R'),
|
||||||
|
Datatype::Enumeration { base_type, .. } => self.canon(base_type, b, out)?,
|
||||||
|
Datatype::Array {
|
||||||
|
base_type,
|
||||||
|
dimensions,
|
||||||
|
} => {
|
||||||
|
let n: usize = dimensions.iter().map(|d| *d as usize).product();
|
||||||
|
let bs = base_type.type_size() as usize;
|
||||||
|
for i in 0..n {
|
||||||
|
self.canon(base_type, &b[i * bs..], out)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Datatype::VariableLength {
|
||||||
|
is_string,
|
||||||
|
base_type,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let len = u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize;
|
||||||
|
let addr = self.read_offset(&b[4..]);
|
||||||
|
let idx_off = 4 + self.os as usize;
|
||||||
|
let idx = u32::from_le_bytes([
|
||||||
|
b[idx_off],
|
||||||
|
b[idx_off + 1],
|
||||||
|
b[idx_off + 2],
|
||||||
|
b[idx_off + 3],
|
||||||
|
]);
|
||||||
|
let obj = if len == 0 || addr == 0 || addr == u64::MAX >> (64 - 8 * self.os as u32)
|
||||||
|
{
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
self.heap_obj(addr, idx)?
|
||||||
|
};
|
||||||
|
if *is_string {
|
||||||
|
let l = len.min(obj.len());
|
||||||
|
canon_str(&obj[..l], out);
|
||||||
|
} else {
|
||||||
|
let bs = base_type.type_size() as usize;
|
||||||
|
if bs == 0 {
|
||||||
|
return Err("canon: VL base size 0".into());
|
||||||
|
}
|
||||||
|
let need = len.checked_mul(bs).ok_or("canon: VL overflow")?;
|
||||||
|
if len > 0 && obj.len() < need {
|
||||||
|
return Err(format!("canon: VL object {} < {need}", obj.len()));
|
||||||
|
}
|
||||||
|
out.push(b'V');
|
||||||
|
out.extend_from_slice(&(len as u32).to_le_bytes());
|
||||||
|
for i in 0..len {
|
||||||
|
self.canon(base_type, &obj[i * bs..], out)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns (shape json, n_elements)
|
||||||
|
fn shape(ds: &Dataspace) -> (Value, u64) {
|
||||||
|
match ds.space_type {
|
||||||
|
DataspaceType::Null => (Value::String("null".into()), 0),
|
||||||
|
DataspaceType::Scalar => (json!([]), 1),
|
||||||
|
DataspaceType::Simple => {
|
||||||
|
let n = ds.dimensions.iter().fold(1u64, |a, d| a.saturating_mul(*d));
|
||||||
|
(json!(ds.dimensions), n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_values(
|
||||||
|
&self,
|
||||||
|
dt: &Datatype,
|
||||||
|
raw: &[u8],
|
||||||
|
n: u64,
|
||||||
|
rec: &mut Map<String, Value>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let size = dt.type_size() as usize;
|
||||||
|
let need = (n as usize).checked_mul(size).ok_or("n*size overflow")?;
|
||||||
|
if raw.len() != need {
|
||||||
|
return Err(format!(
|
||||||
|
"raw length {} != n_elements {n} * type_size {size}",
|
||||||
|
raw.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut canon = Vec::with_capacity(need);
|
||||||
|
for i in 0..n as usize {
|
||||||
|
self.canon(dt, &raw[i * size..(i + 1) * size], &mut canon)?;
|
||||||
|
}
|
||||||
|
let h = Sha256::digest(&canon);
|
||||||
|
rec.insert("hash".into(), Value::String(hex(&h)));
|
||||||
|
rec.insert(
|
||||||
|
"head".into(),
|
||||||
|
Value::String(hex(&canon[..canon.len().min(48)])),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VDS source files resolve next to the virtual file; like the library,
|
||||||
|
/// refuse absolute paths and `..`.
|
||||||
|
fn vds_resolver(
|
||||||
|
&self,
|
||||||
|
) -> impl Fn(&str) -> Result<Option<Vec<u8>>, clawhdf5_format::error::FormatError> + use<> {
|
||||||
|
let base = self.base_dir.clone();
|
||||||
|
move |name: &str| {
|
||||||
|
use clawhdf5_format::error::FormatError;
|
||||||
|
let p = std::path::Path::new(name);
|
||||||
|
if p.is_absolute()
|
||||||
|
|| p.components()
|
||||||
|
.any(|c| matches!(c, std::path::Component::ParentDir))
|
||||||
|
{
|
||||||
|
return Err(FormatError::ChunkedReadError(format!("refused {name}")));
|
||||||
|
}
|
||||||
|
match std::fs::read(base.join(p)) {
|
||||||
|
Ok(b) => Ok(Some(b)),
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(err) => Err(FormatError::ChunkedReadError(err.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_dataset(&self, h: &ObjectHeader, rec: &mut Map<String, Value>) -> Result<(), String> {
|
||||||
|
let dtb = self
|
||||||
|
.payload(h, MessageType::Datatype)?
|
||||||
|
.ok_or("MissingMessage(Datatype)")?;
|
||||||
|
let (dt, _) = Datatype::parse(&dtb).map_err(e)?;
|
||||||
|
rec.insert("dtype".into(), Value::String(dtype_str(&dt)));
|
||||||
|
let dsb = self
|
||||||
|
.payload(h, MessageType::Dataspace)?
|
||||||
|
.ok_or("MissingMessage(Dataspace)")?;
|
||||||
|
let mut ds = Dataspace::parse(&dsb, self.ls).map_err(e)?;
|
||||||
|
// A virtual dataset's extent can come from its sources (unlimited /
|
||||||
|
// printf mappings), as h5py reports it, rather than the stored one.
|
||||||
|
if let Some(lm) = h
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.msg_type == MessageType::DataLayout)
|
||||||
|
&& let Ok(dl @ DataLayout::Virtual { .. }) =
|
||||||
|
DataLayout::parse(&lm.data, self.os, self.ls)
|
||||||
|
{
|
||||||
|
let resolver = self.vds_resolver();
|
||||||
|
ds.dimensions = clawhdf5_format::vds::virtual_dataset_extent(
|
||||||
|
self.data,
|
||||||
|
&dl,
|
||||||
|
&ds,
|
||||||
|
self.os,
|
||||||
|
self.ls,
|
||||||
|
Some(&resolver),
|
||||||
|
)
|
||||||
|
.map_err(e)?;
|
||||||
|
}
|
||||||
|
let (shape, n) = Self::shape(&ds);
|
||||||
|
rec.insert("shape".into(), shape);
|
||||||
|
if n.saturating_mul(dt.type_size() as u64) > MAX_BYTES {
|
||||||
|
rec.insert("skipped".into(), Value::String("too large".into()));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let lm = h
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.msg_type == MessageType::DataLayout)
|
||||||
|
.ok_or("MissingMessage(DataLayout)")?;
|
||||||
|
let dl = DataLayout::parse(&lm.data, self.os, self.ls).map_err(e)?;
|
||||||
|
rec.insert(
|
||||||
|
"layout".into(),
|
||||||
|
Value::String(
|
||||||
|
match &dl {
|
||||||
|
DataLayout::Compact { .. } => "compact",
|
||||||
|
DataLayout::Contiguous { .. } => "contiguous",
|
||||||
|
DataLayout::Chunked { .. } => "chunked",
|
||||||
|
DataLayout::Virtual { .. } => "virtual",
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let pipeline = match self.payload(h, MessageType::FilterPipeline)? {
|
||||||
|
Some(p) => Some(FilterPipeline::parse(&p).map_err(e)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
if let Some(p) = &pipeline {
|
||||||
|
rec.insert(
|
||||||
|
"filters".into(),
|
||||||
|
json!(p.filters.iter().map(|f| f.filter_id).collect::<Vec<_>>()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let raw = if matches!(dl, DataLayout::Virtual { .. }) {
|
||||||
|
let resolver = self.vds_resolver();
|
||||||
|
let fill = clawhdf5_format::fill_value::dataset_fill_value_in(
|
||||||
|
self.data,
|
||||||
|
&h.messages,
|
||||||
|
self.os,
|
||||||
|
self.ls,
|
||||||
|
)
|
||||||
|
.map_err(e)?;
|
||||||
|
clawhdf5_format::vds::read_virtual_dataset(
|
||||||
|
self.data,
|
||||||
|
&dl,
|
||||||
|
&ds,
|
||||||
|
&dt,
|
||||||
|
fill.as_deref(),
|
||||||
|
self.os,
|
||||||
|
self.ls,
|
||||||
|
Some(&resolver),
|
||||||
|
)
|
||||||
|
.map_err(e)?
|
||||||
|
.data
|
||||||
|
} else {
|
||||||
|
let cache = clawhdf5_format::chunk_cache::ChunkCache::new();
|
||||||
|
clawhdf5_format::fill_value::read_full_with_fill::<clawhdf5_format::error::FormatError>(
|
||||||
|
&h.messages,
|
||||||
|
self.data,
|
||||||
|
&dl,
|
||||||
|
&ds,
|
||||||
|
dt.type_size() as usize,
|
||||||
|
self.os,
|
||||||
|
self.ls,
|
||||||
|
|| {
|
||||||
|
data_read::read_raw_data_cached(
|
||||||
|
self.data,
|
||||||
|
&dl,
|
||||||
|
&ds,
|
||||||
|
&dt,
|
||||||
|
pipeline.as_ref(),
|
||||||
|
self.os,
|
||||||
|
self.ls,
|
||||||
|
&cache,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(e)?
|
||||||
|
};
|
||||||
|
self.hash_values(&dt, &raw, n, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attrs(&self, h: &ObjectHeader) -> Result<Map<String, Value>, String> {
|
||||||
|
let msgs = extract_attributes_full(self.data, h, self.os, self.ls).map_err(e)?;
|
||||||
|
let mut out = Map::new();
|
||||||
|
for a in &msgs {
|
||||||
|
let r = guarded(|| {
|
||||||
|
let mut rec = Map::new();
|
||||||
|
rec.insert("dtype".into(), Value::String(dtype_str(&a.datatype)));
|
||||||
|
let (shape, n) = Self::shape(&a.dataspace);
|
||||||
|
rec.insert("shape".into(), shape);
|
||||||
|
self.hash_values(&a.datatype, &a.raw_data, n, &mut rec)?;
|
||||||
|
Ok(rec)
|
||||||
|
});
|
||||||
|
let v = match r {
|
||||||
|
Ok(rec) => Value::Object(rec),
|
||||||
|
Err(msg) => json!({ "error": msg }),
|
||||||
|
};
|
||||||
|
out.insert(a.name.clone(), v);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entries(&self, h: &ObjectHeader) -> Result<Vec<GroupEntry>, String> {
|
||||||
|
let v1 = h
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.msg_type == MessageType::SymbolTable);
|
||||||
|
if let Some(m) = v1 {
|
||||||
|
let stm = SymbolTableMessage::parse(&m.data, self.os).map_err(e)?;
|
||||||
|
group_v1::resolve_v1_group_entries(self.data, &stm, self.os, self.ls).map_err(e)
|
||||||
|
} else if h
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.any(|m| m.msg_type == MessageType::LinkInfo || m.msg_type == MessageType::Link)
|
||||||
|
{
|
||||||
|
group_v2::resolve_v2_group_entries(self.data, h, self.os, self.ls).map_err(e)
|
||||||
|
} else {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Element bytes as an unsigned integer (at most 16 bytes), honouring byte order.
|
||||||
|
fn element_bits(b: &[u8], byte_order: &DatatypeByteOrder) -> Result<u128, String> {
|
||||||
|
if b.len() > 16 {
|
||||||
|
return Err(format!("canon: {}-byte numeric element", b.len()));
|
||||||
|
}
|
||||||
|
let mut v = 0u128;
|
||||||
|
match byte_order {
|
||||||
|
DatatypeByteOrder::LittleEndian => {
|
||||||
|
for (i, x) in b.iter().enumerate() {
|
||||||
|
v |= u128::from(*x) << (8 * i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DatatypeByteOrder::BigEndian => {
|
||||||
|
for x in b {
|
||||||
|
v = (v << 8) | u128::from(*x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DatatypeByteOrder::Vax => return Err("canon: VAX byte order".into()),
|
||||||
|
}
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field(v: u128, pos: u32, len: u32) -> u128 {
|
||||||
|
if len == 0 || pos >= 128 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let v = v >> pos;
|
||||||
|
if len >= 128 {
|
||||||
|
v
|
||||||
|
} else {
|
||||||
|
v & ((1u128 << len) - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when a float's bit fields are exactly IEEE 754 binary16/32/64 for its
|
||||||
|
/// size. h5py hands back such a type's bytes untouched; any other layout (an
|
||||||
|
/// N-Bit `H5Tset_precision` float, say) is *converted* by libhdf5 into the
|
||||||
|
/// numpy float of the same size, so comparing raw bytes would be meaningless.
|
||||||
|
fn ieee_layout(dt: &Datatype) -> bool {
|
||||||
|
let Datatype::FloatingPoint {
|
||||||
|
size,
|
||||||
|
bit_offset,
|
||||||
|
bit_precision,
|
||||||
|
exponent_location,
|
||||||
|
exponent_size,
|
||||||
|
mantissa_location,
|
||||||
|
mantissa_size,
|
||||||
|
exponent_bias,
|
||||||
|
..
|
||||||
|
} = dt
|
||||||
|
else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let std = match size {
|
||||||
|
2 => (16, 10, 5, 10, 15),
|
||||||
|
4 => (32, 23, 8, 23, 127),
|
||||||
|
8 => (64, 52, 11, 52, 1023),
|
||||||
|
_ => return true, // no same-size numpy float to convert to: compare raw
|
||||||
|
};
|
||||||
|
*bit_offset == 0
|
||||||
|
&& (
|
||||||
|
*bit_precision,
|
||||||
|
*exponent_location,
|
||||||
|
*exponent_size,
|
||||||
|
*mantissa_size,
|
||||||
|
*exponent_bias,
|
||||||
|
) == (std.0, std.1, std.2, std.3, std.4)
|
||||||
|
&& *mantissa_location == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonicalise a non-IEEE-layout float the way libhdf5's float->float
|
||||||
|
/// conversion presents it to h5py: as the IEEE float of the same size.
|
||||||
|
/// Assumes the implied-leading-one normalisation and the sign bit at the top
|
||||||
|
/// of the precision (what `H5Tset_precision` produces; the parser does not
|
||||||
|
/// keep either field).
|
||||||
|
fn canon_custom_float(dt: &Datatype, b: &[u8], out: &mut Vec<u8>) -> Result<(), String> {
|
||||||
|
let Datatype::FloatingPoint {
|
||||||
|
size,
|
||||||
|
byte_order,
|
||||||
|
bit_offset,
|
||||||
|
bit_precision,
|
||||||
|
exponent_location,
|
||||||
|
exponent_size,
|
||||||
|
mantissa_location,
|
||||||
|
mantissa_size,
|
||||||
|
exponent_bias,
|
||||||
|
} = dt
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let (esize, msize) = (u32::from(*exponent_size), u32::from(*mantissa_size));
|
||||||
|
if esize == 0 || esize > 30 || msize > 64 {
|
||||||
|
return Err(format!("canon: unsupported float layout e{esize} m{msize}"));
|
||||||
|
}
|
||||||
|
let v = element_bits(b, byte_order)?;
|
||||||
|
let sign_pos = (u32::from(*bit_offset) + u32::from(*bit_precision)).saturating_sub(1);
|
||||||
|
let neg = field(v, sign_pos, 1) == 1;
|
||||||
|
let e = field(v, u32::from(*exponent_location), esize) as i64;
|
||||||
|
let m = field(v, u32::from(*mantissa_location), msize);
|
||||||
|
let emax = (1i64 << esize) - 1;
|
||||||
|
let bias = i64::from(*exponent_bias);
|
||||||
|
let mag = if e == emax {
|
||||||
|
if m == 0 { f64::INFINITY } else { f64::NAN }
|
||||||
|
} else if e == 0 {
|
||||||
|
(m as f64) * 2f64.powi((1 - bias - msize as i64) as i32)
|
||||||
|
} else {
|
||||||
|
((1u128 << msize) as f64 + m as f64) * 2f64.powi((e - bias - msize as i64) as i32)
|
||||||
|
};
|
||||||
|
let x = if neg { -mag } else { mag };
|
||||||
|
match size {
|
||||||
|
2 => out
|
||||||
|
.extend_from_slice(&clawhdf5_format::float16::f32_to_f16_bits(x as f32).to_le_bytes()),
|
||||||
|
4 => out.extend_from_slice(&(x as f32).to_le_bytes()),
|
||||||
|
8 => out.extend_from_slice(&x.to_le_bytes()),
|
||||||
|
_ => unreachable!("ieee_layout keeps other sizes raw"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integers stored with a bit offset or reduced precision (N-Bit): libhdf5
|
||||||
|
/// converts them to the full-width integer of the same size, shifting the
|
||||||
|
/// value down and sign-extending from the top precision bit.
|
||||||
|
fn canon_partial_int(dt: &Datatype, b: &[u8], out: &mut Vec<u8>) -> Result<(), String> {
|
||||||
|
let Datatype::FixedPoint {
|
||||||
|
size,
|
||||||
|
byte_order,
|
||||||
|
signed,
|
||||||
|
bit_offset,
|
||||||
|
bit_precision,
|
||||||
|
} = dt
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let prec = u32::from(*bit_precision);
|
||||||
|
let v = element_bits(b, byte_order)?;
|
||||||
|
let mut x = field(v, u32::from(*bit_offset), prec);
|
||||||
|
if *signed && prec > 0 && prec < 128 && field(x, prec - 1, 1) == 1 {
|
||||||
|
x |= !0u128 << prec;
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&x.to_le_bytes()[..*size as usize]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn partial_int(dt: &Datatype) -> bool {
|
||||||
|
matches!(dt, Datatype::FixedPoint { size, bit_offset, bit_precision, .. }
|
||||||
|
if *bit_offset != 0 || u32::from(*bit_precision) != size * 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canon_str(b: &[u8], out: &mut Vec<u8>) {
|
||||||
|
let cut = b.iter().position(|&c| c == 0).unwrap_or(b.len());
|
||||||
|
let mut s = &b[..cut];
|
||||||
|
while let [rest @ .., b' '] = s {
|
||||||
|
s = rest;
|
||||||
|
}
|
||||||
|
out.push(b'S');
|
||||||
|
out.extend_from_slice(&(s.len() as u32).to_le_bytes());
|
||||||
|
out.extend_from_slice(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(b: &[u8]) -> String {
|
||||||
|
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dtype_str(dt: &Datatype) -> String {
|
||||||
|
match dt {
|
||||||
|
Datatype::FixedPoint {
|
||||||
|
size,
|
||||||
|
signed,
|
||||||
|
byte_order,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
format!(
|
||||||
|
"{}{}{}",
|
||||||
|
bo(byte_order),
|
||||||
|
if *signed { "i" } else { "u" },
|
||||||
|
size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Datatype::FloatingPoint {
|
||||||
|
size, byte_order, ..
|
||||||
|
} => format!("{}f{}", bo(byte_order), size),
|
||||||
|
Datatype::BitField {
|
||||||
|
size, byte_order, ..
|
||||||
|
} => format!("{}b{}", bo(byte_order), size),
|
||||||
|
Datatype::Time { size, .. } => format!("time{size}"),
|
||||||
|
Datatype::String { size, .. } => format!("S{size}"),
|
||||||
|
Datatype::Opaque { size, .. } => format!("V{size}"),
|
||||||
|
Datatype::Compound { size, members } => format!(
|
||||||
|
"{{{}}}{size}",
|
||||||
|
members
|
||||||
|
.iter()
|
||||||
|
.map(|m| format!("{}:{}", m.name, dtype_str(&m.datatype)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
),
|
||||||
|
Datatype::Reference { ref_type, .. } => format!("ref({ref_type:?})"),
|
||||||
|
Datatype::Enumeration { base_type, .. } => format!("enum({})", dtype_str(base_type)),
|
||||||
|
Datatype::VariableLength {
|
||||||
|
is_string: true, ..
|
||||||
|
} => "vlstr".into(),
|
||||||
|
Datatype::VariableLength { base_type, .. } => format!("vlen({})", dtype_str(base_type)),
|
||||||
|
Datatype::Array {
|
||||||
|
base_type,
|
||||||
|
dimensions,
|
||||||
|
} => format!("({}){dimensions:?}", dtype_str(base_type)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bo(b: &DatatypeByteOrder) -> &'static str {
|
||||||
|
match b {
|
||||||
|
DatatypeByteOrder::LittleEndian => "<",
|
||||||
|
DatatypeByteOrder::BigEndian => ">",
|
||||||
|
DatatypeByteOrder::Vax => "vax",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_group(h: &ObjectHeader) -> bool {
|
||||||
|
h.messages.iter().any(|m| {
|
||||||
|
matches!(
|
||||||
|
m.msg_type,
|
||||||
|
MessageType::LinkInfo | MessageType::Link | MessageType::SymbolTable
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
install_hook();
|
||||||
|
let path = std::env::args().nth(1).expect("usage: probe <file>");
|
||||||
|
let mut top = Map::new();
|
||||||
|
top.insert("file".into(), Value::String(path.clone()));
|
||||||
|
let data = match std::fs::read(&path) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(err) => {
|
||||||
|
top.insert("open_error".into(), Value::String(format!("Io({err})")));
|
||||||
|
println!("{}", Value::Object(top));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Every address is relative to the superblock: look at the file from
|
||||||
|
// there on (past any user block), as libhdf5 does.
|
||||||
|
let hdf5: &[u8] = match signature::find_signature(&data) {
|
||||||
|
Ok(off) => &data[off..],
|
||||||
|
Err(_) => &data,
|
||||||
|
};
|
||||||
|
let sb = guarded(|| Superblock::parse(hdf5, 0).map_err(e));
|
||||||
|
let sb = match sb {
|
||||||
|
Ok(sb) => sb,
|
||||||
|
Err(msg) => {
|
||||||
|
top.insert("open_error".into(), Value::String(msg));
|
||||||
|
println!("{}", Value::Object(top));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
top.insert("superblock_version".into(), json!(sb.version));
|
||||||
|
let ctx = Ctx {
|
||||||
|
data: hdf5,
|
||||||
|
os: sb.offset_size,
|
||||||
|
ls: sb.length_size,
|
||||||
|
base_dir: std::path::Path::new(&path)
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
heaps: RefCell::new(HashMap::new()),
|
||||||
|
};
|
||||||
|
let mut objects: Vec<Value> = Vec::new();
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
let mut soft_v1 = 0u64;
|
||||||
|
// explicit DFS stack: (address, path)
|
||||||
|
let mut stack: Vec<(u64, String)> = vec![(sb.root_group_address, "/".to_string())];
|
||||||
|
while let Some((addr, p)) = stack.pop() {
|
||||||
|
if objects.len() >= MAX_OBJECTS {
|
||||||
|
top.insert("truncated".into(), json!(true));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !visited.insert(addr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut rec = Map::new();
|
||||||
|
rec.insert("path".into(), Value::String(p.clone()));
|
||||||
|
let r = guarded(|| {
|
||||||
|
let h = ctx.header(addr)?;
|
||||||
|
Ok(h)
|
||||||
|
});
|
||||||
|
let h = match r {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(msg) => {
|
||||||
|
rec.insert("kind".into(), Value::String("unknown".into()));
|
||||||
|
rec.insert("error".into(), Value::String(msg));
|
||||||
|
objects.push(Value::Object(rec));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let is_ds = h
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.any(|m| m.msg_type == MessageType::DataLayout);
|
||||||
|
let kind = if is_ds {
|
||||||
|
"dataset"
|
||||||
|
} else if is_group(&h) || addr == sb.root_group_address {
|
||||||
|
"group"
|
||||||
|
} else if h
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.any(|m| m.msg_type == MessageType::Datatype)
|
||||||
|
{
|
||||||
|
"datatype"
|
||||||
|
} else {
|
||||||
|
"unknown"
|
||||||
|
};
|
||||||
|
rec.insert("kind".into(), Value::String(kind.into()));
|
||||||
|
if kind == "dataset"
|
||||||
|
&& let Err(msg) = guarded(|| ctx.read_dataset(&h, &mut rec))
|
||||||
|
{
|
||||||
|
rec.insert("error".into(), Value::String(msg));
|
||||||
|
}
|
||||||
|
if kind != "datatype" {
|
||||||
|
match guarded(|| ctx.attrs(&h)) {
|
||||||
|
Ok(m) => {
|
||||||
|
rec.insert("attrs".into(), Value::Object(m));
|
||||||
|
}
|
||||||
|
Err(msg) => {
|
||||||
|
rec.insert("attrs_error".into(), Value::String(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == "group" {
|
||||||
|
match guarded(|| ctx.entries(&h)) {
|
||||||
|
Ok(mut ents) => {
|
||||||
|
ents.retain(|en| {
|
||||||
|
if en.cache_type == 2 {
|
||||||
|
soft_v1 += 1;
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ents.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
let base = if p == "/" { String::new() } else { p.clone() };
|
||||||
|
for en in ents.into_iter().rev() {
|
||||||
|
stack.push((en.object_header_address, format!("{base}/{}", en.name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(msg) => {
|
||||||
|
rec.insert("list_error".into(), Value::String(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
objects.push(Value::Object(rec));
|
||||||
|
}
|
||||||
|
if soft_v1 > 0 {
|
||||||
|
top.insert("v1_soft_link_entries".into(), json!(soft_v1));
|
||||||
|
}
|
||||||
|
top.insert("objects".into(), Value::Array(objects));
|
||||||
|
println!("{}", Value::Object(top));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The N-Bit float of libhdf5's `test/testfiles/le_data.h5`
|
||||||
|
/// (`Nbit_float_data_le`): offset 7, precision 20, sign bit 26, exponent
|
||||||
|
/// 20+6 (bias 31), mantissa 7+13.
|
||||||
|
fn nbit_f32(byte_order: DatatypeByteOrder) -> Datatype {
|
||||||
|
Datatype::FloatingPoint {
|
||||||
|
size: 4,
|
||||||
|
byte_order,
|
||||||
|
bit_offset: 7,
|
||||||
|
bit_precision: 20,
|
||||||
|
exponent_location: 20,
|
||||||
|
exponent_size: 6,
|
||||||
|
mantissa_location: 7,
|
||||||
|
mantissa_size: 13,
|
||||||
|
exponent_bias: 31,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canon_one(dt: &Datatype, bytes: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
canon_custom_float(dt, bytes, &mut out).unwrap();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nbit_float_canonicalises_to_the_value_libhdf5_returns() {
|
||||||
|
let le = nbit_f32(DatatypeByteOrder::LittleEndian);
|
||||||
|
let be = nbit_f32(DatatypeByteOrder::BigEndian);
|
||||||
|
assert!(!ieee_layout(&le));
|
||||||
|
// 1.0: exponent = bias, mantissa 0
|
||||||
|
let one: u32 = 31 << 20;
|
||||||
|
assert_eq!(canon_one(&le, &one.to_le_bytes()), 1.0f32.to_le_bytes());
|
||||||
|
assert_eq!(canon_one(&be, &one.to_be_bytes()), 1.0f32.to_le_bytes());
|
||||||
|
// -2.1999512 (h5py's reading of the file's -2.2): sign, e = 32, m = 819
|
||||||
|
let v: u32 = (1 << 26) | (32 << 20) | (819 << 7);
|
||||||
|
assert_eq!(
|
||||||
|
canon_one(&le, &v.to_le_bytes()),
|
||||||
|
(-2.199_951_2f32).to_le_bytes()
|
||||||
|
);
|
||||||
|
assert_eq!(canon_one(&le, &[0; 4]), 0.0f32.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ieee_floats_keep_their_raw_bytes() {
|
||||||
|
let f32le = Datatype::FloatingPoint {
|
||||||
|
size: 4,
|
||||||
|
byte_order: DatatypeByteOrder::LittleEndian,
|
||||||
|
bit_offset: 0,
|
||||||
|
bit_precision: 32,
|
||||||
|
exponent_location: 23,
|
||||||
|
exponent_size: 8,
|
||||||
|
mantissa_location: 0,
|
||||||
|
mantissa_size: 23,
|
||||||
|
exponent_bias: 127,
|
||||||
|
};
|
||||||
|
assert!(ieee_layout(&f32le));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_precision_int_is_shifted_and_sign_extended() {
|
||||||
|
let dt = Datatype::FixedPoint {
|
||||||
|
size: 4,
|
||||||
|
byte_order: DatatypeByteOrder::BigEndian,
|
||||||
|
signed: true,
|
||||||
|
bit_offset: 4,
|
||||||
|
bit_precision: 17,
|
||||||
|
};
|
||||||
|
assert!(partial_int(&dt));
|
||||||
|
let stored = (((-5i32) as u32) & 0x1_FFFF) << 4;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
canon_partial_int(&dt, &stored.to_be_bytes(), &mut out).unwrap();
|
||||||
|
assert_eq!(out, (-5i32).to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+259
@@ -0,0 +1,259 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reference probe: same JSON as the Rust `conformance-probe`, produced with h5py.
|
||||||
|
|
||||||
|
Walk: iterative DFS from '/', children in sorted (UTF-8 byte) name order, hard
|
||||||
|
links only, each object once (first path wins, deduplicated by object identity).
|
||||||
|
Canonical value encoding: see harness/src/main.rs.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import h5py
|
||||||
|
|
||||||
|
try:
|
||||||
|
import hdf5plugin # noqa: F401 registers blosc/lz4/zstd/bzip2/... filters
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
pass
|
||||||
|
|
||||||
|
MAX_BYTES = 200 * 1024 * 1024
|
||||||
|
MAX_OBJECTS = 200_000
|
||||||
|
|
||||||
|
|
||||||
|
def canon_str(b, out):
|
||||||
|
if isinstance(b, str):
|
||||||
|
b = b.encode("utf-8", "surrogateescape")
|
||||||
|
b = bytes(b)
|
||||||
|
cut = b.find(b"\x00")
|
||||||
|
if cut >= 0:
|
||||||
|
b = b[:cut]
|
||||||
|
b = b.rstrip(b" ")
|
||||||
|
out += b"S" + struct.pack("<I", len(b)) + b
|
||||||
|
|
||||||
|
|
||||||
|
def simple(dt):
|
||||||
|
if dt.fields:
|
||||||
|
return all(simple(dt.fields[n][0]) for n in dt.names)
|
||||||
|
if dt.subdtype:
|
||||||
|
return simple(dt.subdtype[0])
|
||||||
|
return dt.kind in "iufcbV"
|
||||||
|
|
||||||
|
|
||||||
|
def packed(dt):
|
||||||
|
if dt.fields:
|
||||||
|
return np.dtype([(n, packed(dt.fields[n][0])) for n in dt.names])
|
||||||
|
if dt.subdtype:
|
||||||
|
base, shape = dt.subdtype
|
||||||
|
return np.dtype((packed(base), shape))
|
||||||
|
if dt.kind in "iufcb":
|
||||||
|
return dt.newbyteorder("<")
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def canon_el(dt, val, out):
|
||||||
|
if dt.fields:
|
||||||
|
for n in dt.names:
|
||||||
|
canon_el(dt.fields[n][0], val[n], out)
|
||||||
|
return
|
||||||
|
if dt.subdtype:
|
||||||
|
base, _ = dt.subdtype
|
||||||
|
for x in np.asarray(val).reshape(-1):
|
||||||
|
canon_el(base, x, out)
|
||||||
|
return
|
||||||
|
k = dt.kind
|
||||||
|
if k in "iufcb":
|
||||||
|
out += np.asarray(val, dtype=dt).astype(dt.newbyteorder("<")).tobytes()
|
||||||
|
elif k == "V":
|
||||||
|
out += np.asarray(val, dtype=dt).tobytes()
|
||||||
|
elif k == "S":
|
||||||
|
canon_str(val, out)
|
||||||
|
elif k == "O":
|
||||||
|
if h5py.check_string_dtype(dt) is not None:
|
||||||
|
canon_str(val if val is not None else b"", out)
|
||||||
|
elif h5py.check_ref_dtype(dt) is not None:
|
||||||
|
out += b"R"
|
||||||
|
else:
|
||||||
|
base = h5py.check_vlen_dtype(dt)
|
||||||
|
if base is None:
|
||||||
|
raise TypeError(f"unhandled object dtype {dt!r}")
|
||||||
|
arr = np.asarray(val if val is not None else [], dtype=base).reshape(-1)
|
||||||
|
out += b"V" + struct.pack("<I", arr.shape[0])
|
||||||
|
if simple(base):
|
||||||
|
out += arr.astype(packed(base)).tobytes()
|
||||||
|
else:
|
||||||
|
for x in arr:
|
||||||
|
canon_el(base, x, out)
|
||||||
|
elif k == "U":
|
||||||
|
canon_str(str(val), out)
|
||||||
|
else:
|
||||||
|
raise TypeError(f"unhandled dtype kind {k} ({dt!r})")
|
||||||
|
|
||||||
|
|
||||||
|
def has_obj(dt):
|
||||||
|
if dt.fields:
|
||||||
|
return any(has_obj(dt.fields[n][0]) for n in dt.names)
|
||||||
|
if dt.subdtype:
|
||||||
|
return has_obj(dt.subdtype[0])
|
||||||
|
return dt.kind == "O"
|
||||||
|
|
||||||
|
|
||||||
|
def note_conversion(tid, dt, rec):
|
||||||
|
"""h5py converts some file types (FP8, bfloat16, x87 long double, ...) to a
|
||||||
|
different-sized numpy type; then value bytes are not comparable."""
|
||||||
|
try:
|
||||||
|
if not has_obj(dt) and tid.get_size() != dt.itemsize:
|
||||||
|
rec["converted"] = f"file type size {tid.get_size()} -> numpy {dt} ({dt.itemsize})"
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def hash_values(arr, dt, rec):
|
||||||
|
if dt.subdtype is not None:
|
||||||
|
# h5py expands an HDF5 array element type into trailing array dims
|
||||||
|
dt = dt.subdtype[0]
|
||||||
|
arr = np.asarray(arr, dtype=dt)
|
||||||
|
if simple(dt):
|
||||||
|
c = np.ascontiguousarray(arr).astype(packed(dt)).tobytes()
|
||||||
|
else:
|
||||||
|
out = bytearray()
|
||||||
|
for x in arr.reshape(-1):
|
||||||
|
canon_el(dt, x, out)
|
||||||
|
c = bytes(out)
|
||||||
|
rec["hash"] = hashlib.sha256(c).hexdigest()
|
||||||
|
rec["head"] = c[:48].hex()
|
||||||
|
|
||||||
|
|
||||||
|
def err(e):
|
||||||
|
s = f"{type(e).__name__}: {e}"
|
||||||
|
return s.splitlines()[0][:400] if s else type(e).__name__
|
||||||
|
|
||||||
|
|
||||||
|
def shape_of(s):
|
||||||
|
return "null" if s is None else list(s)
|
||||||
|
|
||||||
|
|
||||||
|
def n_bytes(shape, tid):
|
||||||
|
n = 1
|
||||||
|
for d in shape or ():
|
||||||
|
n *= d
|
||||||
|
return n * tid.get_size()
|
||||||
|
|
||||||
|
|
||||||
|
def read_attrs(obj):
|
||||||
|
out = {}
|
||||||
|
names = sorted(obj.attrs.keys(), key=lambda s: s.encode("utf-8", "surrogateescape"))
|
||||||
|
for name in names:
|
||||||
|
rec = {}
|
||||||
|
try:
|
||||||
|
aid = obj.attrs.get_id(name)
|
||||||
|
rec["dtype"] = str(aid.dtype)
|
||||||
|
rec["shape"] = shape_of(aid.shape)
|
||||||
|
note_conversion(aid.get_type(), aid.dtype, rec)
|
||||||
|
if aid.shape is None:
|
||||||
|
hash_values(np.empty((0,), dtype=aid.dtype), aid.dtype, rec)
|
||||||
|
else:
|
||||||
|
val = obj.attrs[name]
|
||||||
|
hash_values(val, aid.dtype, rec)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
rec = {"error": err(e)}
|
||||||
|
out[name] = rec
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main(path):
|
||||||
|
top = {"file": path}
|
||||||
|
try:
|
||||||
|
f = h5py.File(path, "r")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
top["open_error"] = err(e)
|
||||||
|
print(json.dumps(top))
|
||||||
|
return
|
||||||
|
objects = []
|
||||||
|
seen = set()
|
||||||
|
stack = [("/", None)]
|
||||||
|
while stack:
|
||||||
|
p, obj = stack.pop()
|
||||||
|
if len(objects) >= MAX_OBJECTS:
|
||||||
|
top["truncated"] = True
|
||||||
|
break
|
||||||
|
rec = {"path": p}
|
||||||
|
try:
|
||||||
|
if obj is None:
|
||||||
|
obj = f[p]
|
||||||
|
key = hash(obj.id) # h5py ObjectID hash = (fileno, object address/token)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
rec["kind"] = "unknown"
|
||||||
|
rec["error"] = err(e)
|
||||||
|
objects.append(rec)
|
||||||
|
continue
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
if isinstance(obj, h5py.Dataset):
|
||||||
|
kind = "dataset"
|
||||||
|
elif isinstance(obj, h5py.Group):
|
||||||
|
kind = "group"
|
||||||
|
elif isinstance(obj, h5py.Datatype):
|
||||||
|
kind = "datatype"
|
||||||
|
else:
|
||||||
|
kind = "unknown"
|
||||||
|
rec["kind"] = kind
|
||||||
|
if kind == "dataset":
|
||||||
|
try:
|
||||||
|
dt = obj.dtype
|
||||||
|
rec["dtype"] = str(dt)
|
||||||
|
rec["shape"] = shape_of(obj.shape)
|
||||||
|
note_conversion(obj.id.get_type(), dt, rec)
|
||||||
|
if obj.shape is None:
|
||||||
|
hash_values(np.empty((0,), dtype=dt), dt, rec)
|
||||||
|
elif n_bytes(obj.shape, obj.id.get_type()) > MAX_BYTES:
|
||||||
|
rec["skipped"] = "too large"
|
||||||
|
else:
|
||||||
|
arr = np.empty(obj.shape, dtype=dt)
|
||||||
|
if arr.size:
|
||||||
|
try:
|
||||||
|
obj.read_direct(arr)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
arr = obj[()]
|
||||||
|
hash_values(arr, dt, rec)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
rec["error"] = err(e)
|
||||||
|
if kind != "datatype":
|
||||||
|
try:
|
||||||
|
rec["attrs"] = read_attrs(obj)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
rec["attrs_error"] = err(e)
|
||||||
|
if kind == "group":
|
||||||
|
try:
|
||||||
|
names = sorted(obj.keys(), key=lambda s: s.encode("utf-8", "surrogateescape"))
|
||||||
|
base = "" if p == "/" else p
|
||||||
|
kids = []
|
||||||
|
for n in names:
|
||||||
|
try:
|
||||||
|
link = obj.get(n, getlink=True)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
link = None
|
||||||
|
if link is not None and not isinstance(link, h5py.HardLink):
|
||||||
|
continue
|
||||||
|
kids.append(f"{base}/{n}")
|
||||||
|
for k in reversed(kids):
|
||||||
|
stack.append((k, None))
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
rec["list_error"] = err(e)
|
||||||
|
objects.append(rec)
|
||||||
|
top["objects"] = objects
|
||||||
|
print(json.dumps(top), flush=True)
|
||||||
|
# Exit without tearing down the h5py objects: freeing them for some files
|
||||||
|
# that hold references (hdf5's h5repack_attr_refs.h5, cve-2024-32623.h5)
|
||||||
|
# makes libhdf5 2.0 abort with "free(): chunks in smallbin corrupted"
|
||||||
|
# about half the time. That happens after the reading is done, so it says
|
||||||
|
# nothing about what h5py read, but it flipped those files between ok and
|
||||||
|
# h5py-cannot-read from one run to the next.
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1])
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""report.py <results_dir> <CONFORMANCE.md> <corpus_dir>
|
||||||
|
|
||||||
|
Render the sweep's results (compare.py's results.json plus the raw per-side
|
||||||
|
runs) as CONFORMANCE.md, and write <results_dir>/report-meta.json (commit,
|
||||||
|
date, versions) for check.py --update.
|
||||||
|
"""
|
||||||
|
import collections
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import h5py
|
||||||
|
import numpy
|
||||||
|
|
||||||
|
try:
|
||||||
|
import hdf5plugin
|
||||||
|
HDF5PLUGIN = hdf5plugin.version
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
HDF5PLUGIN = "not installed"
|
||||||
|
|
||||||
|
R, OUT_MD, CORPUS = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ROOT = os.path.dirname(HERE)
|
||||||
|
CLASSES = ["ok", "our-error", "mismatch", "h5py-cannot-read", "panic", "hang", "crash", "oom"]
|
||||||
|
|
||||||
|
|
||||||
|
def sh(*cmd, cwd=ROOT):
|
||||||
|
try:
|
||||||
|
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=30).stdout.strip()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_model():
|
||||||
|
try:
|
||||||
|
for ln in open("/proc/cpuinfo"):
|
||||||
|
if ln.startswith(("model name", "Model")):
|
||||||
|
return ln.split(":", 1)[1].strip()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return platform.processor() or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def mem_gib():
|
||||||
|
try:
|
||||||
|
for ln in open("/proc/meminfo"):
|
||||||
|
if ln.startswith("MemTotal:"):
|
||||||
|
return f"{int(ln.split()[1]) / 1048576:.0f} GiB"
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
|
res = json.load(open(os.path.join(R, "results.json")))
|
||||||
|
meta_run = json.load(open(os.path.join(R, "meta.json"))) if os.path.exists(os.path.join(R, "meta.json")) else {}
|
||||||
|
rows = res["rows"]
|
||||||
|
issues = res.get("issues", {})
|
||||||
|
|
||||||
|
# safe.directory: a checkout owned by another user (a container) is still ours to read
|
||||||
|
commit = sh("git", "-c", "safe.directory=*", "rev-parse", "HEAD") or os.environ.get("GITHUB_SHA", "unknown")
|
||||||
|
lib_dirty = sh("git", "-c", "safe.directory=*", "status", "--porcelain", "--", "crates", "Cargo.toml")
|
||||||
|
h5dump_v = sh("h5dump", "--version").replace("h5dump: ", "")
|
||||||
|
meta = {
|
||||||
|
"date": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
||||||
|
"commit": commit + (" (library sources modified)" if lib_dirty else ""),
|
||||||
|
"reference": f"h5py {h5py.__version__} / HDF5 {h5py.version.hdf5_version}",
|
||||||
|
}
|
||||||
|
json.dump(meta, open(os.path.join(R, "report-meta.json"), "w"), indent=1)
|
||||||
|
|
||||||
|
pins = []
|
||||||
|
for ln in open(os.path.join(HERE, "corpus.txt")):
|
||||||
|
if ln.strip() and not ln.lstrip().startswith("#"):
|
||||||
|
name, url, rev, root, *_ = ln.split()
|
||||||
|
pins.append((name, url, rev, root))
|
||||||
|
|
||||||
|
by_corpus = collections.defaultdict(collections.Counter)
|
||||||
|
for r in rows:
|
||||||
|
by_corpus[r["corpus"]][r["class"]] += 1
|
||||||
|
total = collections.Counter(r["class"] for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def ex_list(files, n=3):
|
||||||
|
s = ", ".join(f"`{f}`" for f in files[:n])
|
||||||
|
return s + (f" (+{len(files) - n} more)" if len(files) > n else "")
|
||||||
|
|
||||||
|
|
||||||
|
# --- known causes that are not clawhdf5 bugs --------------------------------
|
||||||
|
def is_h5py_be_vlen(i):
|
||||||
|
"""h5py returns the elements of a VL sequence of a big-endian base type
|
||||||
|
with their file (big-endian) bytes but a native-endian dtype."""
|
||||||
|
return (i["kind"] == "mismatch" and i["key"] in ("values", "attr-values")
|
||||||
|
and (i.get("ref_dtype") == "object") and (i.get("ours_dtype") or "").startswith("vlen(")
|
||||||
|
and ">" in (i.get("ours_dtype") or ""))
|
||||||
|
|
||||||
|
|
||||||
|
known = collections.defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
if r["class"] != "mismatch":
|
||||||
|
continue
|
||||||
|
iss = issues.get(r["file"], [])
|
||||||
|
if iss and all(is_h5py_be_vlen(i) for i in iss):
|
||||||
|
known["h5py-be-vlen"].append(r["file"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- the CVE corpus: clawhdf5 vs h5dump vs h5py ------------------------------
|
||||||
|
def side(run, name):
|
||||||
|
p = os.path.join(R, "runs", run, name)
|
||||||
|
if not os.path.exists(p + ".rc"):
|
||||||
|
return None
|
||||||
|
rc = int(open(p + ".rc").read().strip() or -1)
|
||||||
|
err = open(p + ".err", errors="replace").read()
|
||||||
|
try:
|
||||||
|
j = json.load(open(p + ".json"))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
j = None
|
||||||
|
return rc, err, j
|
||||||
|
|
||||||
|
|
||||||
|
def outcome(s, rust=False):
|
||||||
|
"""-> (bucket, text). bucket in read / error / panic / crash / hang / oom."""
|
||||||
|
if s is None:
|
||||||
|
return "missing", "not run"
|
||||||
|
rc, err, j = s
|
||||||
|
if rc in (137, 124):
|
||||||
|
return "hang", "hang (killed at timeout)"
|
||||||
|
if "memory allocation of" in err or "MemoryError" in err or "bad_alloc" in err or "Cannot allocate" in err:
|
||||||
|
return "oom", "out of memory"
|
||||||
|
if rust and (rc == 101 or "PANIC:" in err):
|
||||||
|
return "panic", "panic"
|
||||||
|
if "overflowed its stack" in err:
|
||||||
|
return "crash", "stack overflow"
|
||||||
|
if rc == 139:
|
||||||
|
return "crash", "SIGSEGV"
|
||||||
|
if rc == 134:
|
||||||
|
return "crash", "SIGABRT" + (" (heap corruption)" if ("corrupted" in err or "free()" in err) else "")
|
||||||
|
if rc > 128:
|
||||||
|
return "crash", f"signal {rc - 128}"
|
||||||
|
if j is None:
|
||||||
|
return ("error", "error exit") if rc in (0, 1) else ("crash", f"exit {rc}")
|
||||||
|
if "open_error" in j:
|
||||||
|
return "error", "open error"
|
||||||
|
objs = j.get("objects", [])
|
||||||
|
ne = sum(1 for o in objs for k in ("error", "attrs_error", "list_error") if k in o)
|
||||||
|
ne += sum(1 for o in objs for a in (o.get("attrs") or {}).values() if "error" in a)
|
||||||
|
return "read", f"read {len(objs)} obj" + (f", {ne} errors" if ne else "")
|
||||||
|
|
||||||
|
|
||||||
|
def h5dump_outcome(s):
|
||||||
|
if s is None:
|
||||||
|
return "missing", "not run"
|
||||||
|
rc, err, _ = s
|
||||||
|
if rc in (137, 124):
|
||||||
|
return "hang", "hang (killed at timeout)"
|
||||||
|
if "memory allocation" in err or "Cannot allocate" in err:
|
||||||
|
return "oom", "out of memory"
|
||||||
|
if rc == 139:
|
||||||
|
return "crash", "SIGSEGV"
|
||||||
|
if rc == 134:
|
||||||
|
return "crash", "SIGABRT" + (" (heap corruption)" if ("corrupted" in err or "free()" in err) else "")
|
||||||
|
if rc > 128:
|
||||||
|
return "crash", f"signal {rc - 128}"
|
||||||
|
return ("read", "ok") if rc == 0 else ("error", "error exit")
|
||||||
|
|
||||||
|
|
||||||
|
cve_rows = []
|
||||||
|
buckets = {"clawhdf5": collections.Counter(), "h5dump": collections.Counter(), "h5py": collections.Counter()}
|
||||||
|
ours_panic = {r["file"] for r in rows if r["class"] == "panic"}
|
||||||
|
for r in rows:
|
||||||
|
if r["corpus"] != "cve_hdf5":
|
||||||
|
continue
|
||||||
|
run = r["file"].replace("/", "__")
|
||||||
|
o = outcome(side(run, "ours"), rust=True)
|
||||||
|
if o[0] == "read" and r["file"] in ours_panic:
|
||||||
|
o = ("panic", "caught panic")
|
||||||
|
p = outcome(side(run, "ref"))
|
||||||
|
d = h5dump_outcome(side(run, "h5dump"))
|
||||||
|
buckets["clawhdf5"][o[0]] += 1
|
||||||
|
buckets["h5py"][p[0]] += 1
|
||||||
|
buckets["h5dump"][d[0]] += 1
|
||||||
|
cve_rows.append((r["file"].split("/", 1)[1], d[1], p[1], o[1], r["class"]))
|
||||||
|
|
||||||
|
# --- render -----------------------------------------------------------------
|
||||||
|
L = []
|
||||||
|
w = L.append
|
||||||
|
w("# clawhdf5 conformance report")
|
||||||
|
w("")
|
||||||
|
w("Every HDF5 file of eight public corpora (pinned by commit) is read twice — by")
|
||||||
|
w("clawhdf5 (`conformance/probe`, the same `clawhdf5-format` calls the facade")
|
||||||
|
w("makes) and by h5py/libhdf5 (`conformance/ref.py`) — and the two readings are")
|
||||||
|
w("compared object by object: the set of hard-linked objects, each dataset's and")
|
||||||
|
w("attribute's shape, and a SHA-256 of its values in a canonical encoding. The")
|
||||||
|
w("CVE corpus is also run through `h5dump`. Each side runs under a timeout and an")
|
||||||
|
w("address-space limit, so a hang, crash or runaway allocation is recorded, not")
|
||||||
|
w("fatal. This file is generated by `conformance/run.sh`; do not edit it by hand.")
|
||||||
|
w("")
|
||||||
|
w("## Run")
|
||||||
|
w("")
|
||||||
|
w("| | |")
|
||||||
|
w("|---|---|")
|
||||||
|
w(f"| date | {meta['date']} |")
|
||||||
|
w(f"| clawhdf5 commit | `{meta['commit']}` |")
|
||||||
|
w(f"| machine | `{platform.node()}`: {cpu_model()}, {os.cpu_count()} CPUs, {mem_gib()}, {platform.system()} {platform.release()} {platform.machine()} |")
|
||||||
|
w(f"| command | `{os.environ.get('CONFORMANCE_CMD', 'conformance/run.sh')}` |")
|
||||||
|
w(f"| rustc | {sh('rustc', '-V')} |")
|
||||||
|
w(f"| reference | h5py {h5py.__version__}, HDF5 {h5py.version.hdf5_version}, numpy {numpy.__version__}, hdf5plugin {HDF5PLUGIN}, Python {platform.python_version()} |")
|
||||||
|
w(f"| h5dump | {h5dump_v} (CVE corpus only) |")
|
||||||
|
if meta_run:
|
||||||
|
w(f"| limits | {meta_run.get('timeout_s')} s timeout (SIGKILL), {int(meta_run.get('mem_kb', 0)) // 1024} MiB address space, per process; {meta_run.get('jobs')} files in parallel |")
|
||||||
|
w(f"| runtime | {meta_run.get('probe_seconds')} s probing + comparing ({meta_run.get('build_seconds')} s fetch/build before it) |")
|
||||||
|
w("")
|
||||||
|
w("## Results")
|
||||||
|
w("")
|
||||||
|
w("A file's class is the first that applies:")
|
||||||
|
w("")
|
||||||
|
w("- **panic / hang / crash / oom** — clawhdf5 panicked (caught per object or not), hit the timeout, died on a signal, or failed an allocation. The CI gate fails on any of these.")
|
||||||
|
w("- **h5py-cannot-read** — libhdf5 could not open the file (or itself crashed or hung). Nothing to compare against; most are the deliberately malformed CVE reproducers.")
|
||||||
|
w("- **our-error** — clawhdf5 returned an error for something h5py reads.")
|
||||||
|
w("- **mismatch** — both read it, but the shapes, values, object set or attribute set differ.")
|
||||||
|
w("- **ok** — every object h5py reads, clawhdf5 reads identically.")
|
||||||
|
w("")
|
||||||
|
w("| corpus | files | " + " | ".join(CLASSES) + " |")
|
||||||
|
w("|---" * (len(CLASSES) + 2) + "|")
|
||||||
|
for c in sorted(by_corpus):
|
||||||
|
cnt = by_corpus[c]
|
||||||
|
w(f"| {c} | {sum(cnt.values())} | " + " | ".join(str(cnt.get(k, 0)) for k in CLASSES) + " |")
|
||||||
|
w(f"| **all** | **{len(rows)}** | " + " | ".join(f"**{total.get(k, 0)}**" for k in CLASSES) + " |")
|
||||||
|
w("")
|
||||||
|
n_known = sum(len(v) for v in known.values())
|
||||||
|
if n_known:
|
||||||
|
w(f"{n_known} of the {total.get('mismatch', 0)} mismatches are a known h5py bug, not ours (see *Known not-our-bug*).")
|
||||||
|
w("")
|
||||||
|
w("Corpora (fetched by `conformance/fetch-corpus.sh` into the gitignored `conformance/.cache/`):")
|
||||||
|
w("")
|
||||||
|
w("| corpus | source | commit |")
|
||||||
|
w("|---|---|---|")
|
||||||
|
for name, url, rev, root in pins:
|
||||||
|
w(f"| {name} | {url.removesuffix('.git')}" + ("" if root == "." else f" (`{root}`)") + f" | `{rev[:12]}` |")
|
||||||
|
w("")
|
||||||
|
|
||||||
|
w("## Panics, hangs, crashes, out-of-memory")
|
||||||
|
w("")
|
||||||
|
if not res["panics"]:
|
||||||
|
w("None.")
|
||||||
|
else:
|
||||||
|
for p in res["panics"]:
|
||||||
|
w(f"- `{p['file']}` [{p['class']}] {p['detail']}")
|
||||||
|
w("")
|
||||||
|
|
||||||
|
w("## Our-error root causes")
|
||||||
|
w("")
|
||||||
|
w("Grouped by normalised error message. *files* counts files whose class this cause affects.")
|
||||||
|
w("")
|
||||||
|
w("| files | objects | error | examples |")
|
||||||
|
w("|---:|---:|---|---|")
|
||||||
|
for k, v in res["root_causes"].items():
|
||||||
|
w(f"| {v['files']} | {v['count']} | `{k.replace('|', '/')}` | {ex_list(v['file_list'])} |")
|
||||||
|
w("")
|
||||||
|
w("## Mismatch root causes")
|
||||||
|
w("")
|
||||||
|
w("| files | objects | cause | examples |")
|
||||||
|
w("|---:|---:|---|---|")
|
||||||
|
for k, v in res["mismatch_causes"].items():
|
||||||
|
w(f"| {v['files']} | {v['count']} | `{k.replace('|', '/')}` | {ex_list(v['file_list'])} |")
|
||||||
|
w("")
|
||||||
|
|
||||||
|
w("## CVE corpus: clawhdf5 vs h5dump vs h5py")
|
||||||
|
w("")
|
||||||
|
w(f"The {len(cve_rows)} files of [HDFGroup/cve_hdf5](https://github.com/HDFGroup/cve_hdf5) — reproducers for")
|
||||||
|
w("published libhdf5 CVEs and fuzzer finds. *read* = produced output (possibly with per-object")
|
||||||
|
w("errors), *error* = refused cleanly. h5dump exits non-zero on any error anywhere in a file, so")
|
||||||
|
w("its read/error split is not comparable with the other two rows; the panic, crash, hang and oom")
|
||||||
|
w("columns are.")
|
||||||
|
w("")
|
||||||
|
w("| tool | read | error | panic | crash | hang | oom |")
|
||||||
|
w("|---|---:|---:|---:|---:|---:|---:|")
|
||||||
|
for tool, label in (("clawhdf5", "clawhdf5"), ("h5dump", f"h5dump {h5dump_v.split()[-1] if h5dump_v else ''}"),
|
||||||
|
("h5py", f"h5py {h5py.__version__} / HDF5 {h5py.version.hdf5_version}")):
|
||||||
|
b = buckets[tool]
|
||||||
|
w(f"| {label} | " + " | ".join(str(b.get(k, 0)) for k in ("read", "error", "panic", "crash", "hang", "oom")) + " |")
|
||||||
|
w("")
|
||||||
|
w("<details><summary>Per-file outcomes</summary>")
|
||||||
|
w("")
|
||||||
|
w("| file | h5dump | h5py | clawhdf5 | class |")
|
||||||
|
w("|---|---|---|---|---|")
|
||||||
|
for f, d, p, o, cls in cve_rows:
|
||||||
|
w(f"| {f} | {d} | {p} | {o} | {cls} |")
|
||||||
|
w("")
|
||||||
|
w("</details>")
|
||||||
|
w("")
|
||||||
|
|
||||||
|
w("## Known not-our-bug")
|
||||||
|
w("")
|
||||||
|
w("- **h5py big-endian variable-length sequences.** h5py returns the elements of a VL sequence")
|
||||||
|
w(" whose base type is big-endian with the file's big-endian bytes but a native (little-endian)")
|
||||||
|
w(" numpy dtype, so the values it reports are byte-swapped garbage; `h5dump` prints the values")
|
||||||
|
w(" clawhdf5 reads. Reproducer: `h5py.vlen_dtype(np.dtype('>f4'))` dataset holding `[1.0, 2.0]`")
|
||||||
|
w(" reads back in h5py as `[4.6e-41, 9.0e-44]`. Affected here: "
|
||||||
|
+ (ex_list(sorted(known["h5py-be-vlen"]), 10) if known["h5py-be-vlen"] else "none") + ".")
|
||||||
|
w("- **Non-IEEE floats and partial-precision integers (N-Bit).** libhdf5 converts a float whose")
|
||||||
|
w(" bit layout is not IEEE (e.g. `H5Tset_precision` for the N-Bit filter) or an integer with a")
|
||||||
|
w(" bit offset / reduced precision into the plain numpy type of the same size. The probe")
|
||||||
|
w(" compares such values as converted numbers, not raw file bytes (before 2026-09-25 it compared")
|
||||||
|
w(" raw bytes, which reported every N-Bit float dataset as a mismatch).")
|
||||||
|
if res["incomparable"]:
|
||||||
|
w("- **Types h5py widens.** Where h5py reads a type into a numpy type of a different size")
|
||||||
|
w(" (FP8 -> float16, bfloat16 -> float32, x87 long double -> float128) the values are not")
|
||||||
|
w(" compared (shape and presence still are): "
|
||||||
|
+ ", ".join(f"{k} ({n}x)" for k, n in res["incomparable"]) + ".")
|
||||||
|
w("- **References** are compared by presence only (`R`), not by target.")
|
||||||
|
w("")
|
||||||
|
if res.get("ref_only_errors"):
|
||||||
|
w("## Objects h5py fails on but clawhdf5 reads")
|
||||||
|
w("")
|
||||||
|
for k, n in res["ref_only_errors"][:15]:
|
||||||
|
w(f"- {n} x `{k}`")
|
||||||
|
w("")
|
||||||
|
w("## Reproduce")
|
||||||
|
w("")
|
||||||
|
w("```sh")
|
||||||
|
w("# needs: Rust, python3 with h5py numpy hdf5plugin (conformance/requirements.txt), h5dump (hdf5-tools), git")
|
||||||
|
w("CLAWHDF5_PYTHON=/path/to/venv/bin/python conformance/run.sh")
|
||||||
|
w("```")
|
||||||
|
w("")
|
||||||
|
w("The corpus (about 450 MB of sparse checkouts) is cached in `conformance/.cache/`; results for")
|
||||||
|
w("every file, both sides' raw JSON and stderr, are in `conformance/.cache/results/`.")
|
||||||
|
w("`conformance/baseline.json` holds the ok files the nightly CI job (`.gitea/workflows/conformance.yml`)")
|
||||||
|
w("must keep; `conformance/run.sh --update-baseline` rewrites it.")
|
||||||
|
|
||||||
|
with open(OUT_MD, "w") as fh:
|
||||||
|
fh.write("\n".join(L) + "\n")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# The reference side of the conformance sweep. Pinned so the nightly job and a
|
||||||
|
# local run compare against the same libhdf5 (h5py wheels bundle it).
|
||||||
|
h5py==3.16.0
|
||||||
|
numpy==2.5.3
|
||||||
|
hdf5plugin==7.1.0
|
||||||
|
netCDF4==1.7.4
|
||||||
Executable
+88
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# conformance/run.sh — the clawhdf5 conformance sweep, end to end.
|
||||||
|
#
|
||||||
|
# fetch the pinned corpora (cached) -> build the probe -> probe every file
|
||||||
|
# with clawhdf5 and with h5py (and h5dump for the CVE corpus), each under a
|
||||||
|
# timeout and a memory limit -> compare -> write CONFORMANCE.md -> check the
|
||||||
|
# result against conformance/baseline.json.
|
||||||
|
#
|
||||||
|
# Usage: conformance/run.sh [--no-fetch] [--no-report] [--update-baseline]
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# CLAWHDF5_PYTHON python with h5py, numpy, hdf5plugin (default: repo .venv, then python3)
|
||||||
|
# CONFORMANCE_CACHE corpus / build / results cache (default: conformance/.cache)
|
||||||
|
# CONFORMANCE_OUT results directory (default: $CONFORMANCE_CACHE/results)
|
||||||
|
# CONFORMANCE_REPORT report path (default: CONFORMANCE.md at the repo root)
|
||||||
|
# JOBS parallel files (default: nproc)
|
||||||
|
# CONFORMANCE_PROBE use this prebuilt probe binary instead of building one
|
||||||
|
# TMO / MEM_KB per-process timeout in seconds (20) / address-space limit in KiB (4 GiB)
|
||||||
|
#
|
||||||
|
# Exit status: 0 = gate passed; 1 = a panic/hang/crash/oom in clawhdf5, or the
|
||||||
|
# ok count fell below the baseline, or a baseline-ok file regressed; 2 = setup error.
|
||||||
|
set -euo pipefail
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
ROOT="$(cd "$HERE/.." && pwd)"
|
||||||
|
FETCH=1 REPORT=1 UPDATE=0
|
||||||
|
for a in "$@"; do
|
||||||
|
case "$a" in
|
||||||
|
--no-fetch) FETCH=0 ;;
|
||||||
|
--no-report) REPORT=0 ;;
|
||||||
|
--update-baseline) UPDATE=1 ;;
|
||||||
|
-h|--help) sed -n '2,23p' "$0"; exit 0 ;;
|
||||||
|
*) echo "unknown argument: $a" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
CACHE="${CONFORMANCE_CACHE:-$HERE/.cache}"
|
||||||
|
mkdir -p "$CACHE"; CACHE="$(cd "$CACHE" && pwd)"
|
||||||
|
OUT="${CONFORMANCE_OUT:-$CACHE/results}"
|
||||||
|
REPORT_PATH="${CONFORMANCE_REPORT:-$ROOT/CONFORMANCE.md}"
|
||||||
|
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
|
||||||
|
if [ -n "${CLAWHDF5_PYTHON:-}" ]; then PY="$CLAWHDF5_PYTHON"
|
||||||
|
elif [ -x "$ROOT/.venv/bin/python" ]; then PY="$ROOT/.venv/bin/python"
|
||||||
|
else PY="$(command -v python3)"; fi
|
||||||
|
export PY TMO="${TMO:-20}" MEM_KB="${MEM_KB:-4194304}"
|
||||||
|
command -v h5dump >/dev/null || { echo "error: h5dump not found (install hdf5-tools)" >&2; exit 2; }
|
||||||
|
"$PY" -c 'import h5py, numpy, hdf5plugin' || { echo "error: $PY lacks h5py/numpy/hdf5plugin" >&2; exit 2; }
|
||||||
|
|
||||||
|
t0=$(date +%s)
|
||||||
|
[ "$FETCH" = 1 ] && bash "$HERE/fetch-corpus.sh" "$CACHE"
|
||||||
|
C="$CACHE/corpus"
|
||||||
|
[ -d "$C" ] || { echo "error: no corpus in $C (run without --no-fetch)" >&2; exit 2; }
|
||||||
|
|
||||||
|
if [ -n "${CONFORMANCE_PROBE:-}" ]; then
|
||||||
|
export PROBE="$CONFORMANCE_PROBE" # a prebuilt probe, e.g. an older one for a before/after
|
||||||
|
else
|
||||||
|
echo "== building the probe"
|
||||||
|
CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$CACHE/target}" \
|
||||||
|
cargo build -q --release --manifest-path "$HERE/probe/Cargo.toml"
|
||||||
|
export PROBE="${CARGO_TARGET_DIR:-$CACHE/target}/release/conformance-probe"
|
||||||
|
fi
|
||||||
|
t1=$(date +%s)
|
||||||
|
|
||||||
|
rm -rf "$OUT"; mkdir -p "$OUT"
|
||||||
|
"$PY" "$HERE/list_files.py" "$C" > "$OUT/files.txt"
|
||||||
|
echo "== probing $(wc -l <"$OUT/files.txt") files, $JOBS at a time (timeout ${TMO}s, limit $((MEM_KB / 1024)) MiB)"
|
||||||
|
export C OUT HERE
|
||||||
|
# The shell's "Segmentation fault (core dumped)" notices go to probe.log; the
|
||||||
|
# signals themselves are recorded in each side's .rc.
|
||||||
|
xargs -a "$OUT/files.txt" -d '\n' -P "$JOBS" -I{} bash -c '
|
||||||
|
f="$1"; d="$OUT/runs/${f//\//__}"
|
||||||
|
case "$f" in cve_hdf5/*) export WITH_H5DUMP=1 ;; esac
|
||||||
|
"$HERE/run_one.sh" "$C/$f" "$d"' _ {} 2>"$OUT/probe.log"
|
||||||
|
echo "== comparing"
|
||||||
|
"$PY" "$HERE/compare.py" "$OUT" >/dev/null
|
||||||
|
t2=$(date +%s)
|
||||||
|
cat > "$OUT/meta.json" <<EOF
|
||||||
|
{"build_seconds": $((t1 - t0)), "probe_seconds": $((t2 - t1)), "jobs": $JOBS, "timeout_s": $TMO, "mem_kb": $MEM_KB}
|
||||||
|
EOF
|
||||||
|
export CONFORMANCE_CMD="${CONFORMANCE_CMD:-conformance/run.sh${*:+ $*}}"
|
||||||
|
if [ "$REPORT" = 1 ]; then
|
||||||
|
"$PY" "$HERE/report.py" "$OUT" "$REPORT_PATH" "$C"
|
||||||
|
echo "== wrote $REPORT_PATH"
|
||||||
|
fi
|
||||||
|
if [ "$UPDATE" = 1 ]; then
|
||||||
|
"$PY" "$HERE/check.py" "$OUT" "$HERE/baseline.json" --update
|
||||||
|
fi
|
||||||
|
"$PY" "$HERE/check.py" "$OUT" "$HERE/baseline.json"
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run_one.sh <file> <outdir>
|
||||||
|
#
|
||||||
|
# Probe one file with clawhdf5 (PROBE) and with h5py (PY ref.py), and with
|
||||||
|
# h5dump too when WITH_H5DUMP is set. Each side runs under a timeout (TMO
|
||||||
|
# seconds, SIGKILL) and an address-space limit (MEM_KB), with core dumps off.
|
||||||
|
# Writes <outdir>/<side>.{json,err,rc}; rc 137 = killed by the timeout.
|
||||||
|
set -u
|
||||||
|
f="$1"; out="$2"; mkdir -p "$out"
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
: "${PROBE:?PROBE must name the conformance-probe binary}"
|
||||||
|
: "${PY:?PY must name a python with h5py}"
|
||||||
|
TMO="${TMO:-20}"
|
||||||
|
MEM_KB="${MEM_KB:-4194304}"
|
||||||
|
run() { # name cmd...
|
||||||
|
local name=$1; shift
|
||||||
|
( ulimit -v "$MEM_KB"; ulimit -c 0; RUST_BACKTRACE=1 exec timeout -s KILL "$TMO" "$@" ) \
|
||||||
|
>"$out/$name.json" 2>"$out/$name.err"
|
||||||
|
echo $? >"$out/$name.rc"
|
||||||
|
}
|
||||||
|
run ours "$PROBE" "$f"
|
||||||
|
run ref "$PY" "$HERE/ref.py" "$f"
|
||||||
|
if [ -n "${WITH_H5DUMP:-}" ]; then
|
||||||
|
run h5dump h5dump "$f"
|
||||||
|
: >"$out/h5dump.json" # h5dump's text dump is not compared, only its exit status
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-accel"
|
name = "clawhdf5-accel"
|
||||||
version = "2.2.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version.workspace = true
|
||||||
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://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||||
|
|||||||
@@ -25,6 +25,55 @@ unsafe fn hsum_256(v: __m256) -> f32 {
|
|||||||
_mm_cvtss_f32(result)
|
_mm_cvtss_f32(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AVX2 dot product of two `i8` slices, widened to `i32`.
|
||||||
|
///
|
||||||
|
/// Each 16-byte half is sign-extended to sixteen `i16` lanes and multiplied
|
||||||
|
/// pairwise with `madd_epi16`, which sums adjacent products straight into
|
||||||
|
/// eight `i32` lanes — the widening that an autovectorised scalar loop does
|
||||||
|
/// in several shuffles is one instruction here. A pair sum is at most
|
||||||
|
/// `2 * 127 * 127`, far inside `i32`.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Caller must verify is_x86_feature_detected!("avx2").
|
||||||
|
// SAFETY: Caller must have verified AVX2 via is_x86_feature_detected!.
|
||||||
|
#[target_feature(enable = "avx2")]
|
||||||
|
pub unsafe fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
|
||||||
|
// SAFETY: Caller guarantees AVX2 is available per the # Safety contract;
|
||||||
|
// every load reads 32 bytes at an index checked against `len` first.
|
||||||
|
unsafe {
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
let len = a.len();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut acc0 = _mm256_setzero_si256();
|
||||||
|
let mut acc1 = _mm256_setzero_si256();
|
||||||
|
|
||||||
|
while i + 32 <= len {
|
||||||
|
let va = _mm256_loadu_si256(a.as_ptr().add(i).cast());
|
||||||
|
let vb = _mm256_loadu_si256(b.as_ptr().add(i).cast());
|
||||||
|
let a_lo = _mm256_cvtepi8_epi16(_mm256_castsi256_si128(va));
|
||||||
|
let b_lo = _mm256_cvtepi8_epi16(_mm256_castsi256_si128(vb));
|
||||||
|
let a_hi = _mm256_cvtepi8_epi16(_mm256_extracti128_si256(va, 1));
|
||||||
|
let b_hi = _mm256_cvtepi8_epi16(_mm256_extracti128_si256(vb, 1));
|
||||||
|
acc0 = _mm256_add_epi32(acc0, _mm256_madd_epi16(a_lo, b_lo));
|
||||||
|
acc1 = _mm256_add_epi32(acc1, _mm256_madd_epi16(a_hi, b_hi));
|
||||||
|
i += 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal sum of the eight i32 lanes.
|
||||||
|
let v = _mm256_add_epi32(acc0, acc1);
|
||||||
|
let s128 = _mm_add_epi32(_mm256_castsi256_si128(v), _mm256_extracti128_si256(v, 1));
|
||||||
|
let s64 = _mm_add_epi32(s128, _mm_unpackhi_epi64(s128, s128));
|
||||||
|
let s32 = _mm_add_epi32(s64, _mm_shuffle_epi32(s64, 0b01));
|
||||||
|
let mut sum = _mm_cvtsi128_si32(s32);
|
||||||
|
|
||||||
|
while i < len {
|
||||||
|
sum += i32::from(a[i]) * i32::from(b[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// AVX2 dot product for f32 slices.
|
/// AVX2 dot product for f32 slices.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
@@ -111,7 +160,11 @@ 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 { 0.0 } else { dot / denom }
|
if denom < f32::EPSILON {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
dot / denom
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,11 @@ pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let denom = (norm_a * norm_b).sqrt();
|
let denom = (norm_a * norm_b).sqrt();
|
||||||
if denom < f32::EPSILON { 0.0 } else { dot / denom }
|
if denom < f32::EPSILON {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
dot / denom
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,14 @@ pub enum Backend {
|
|||||||
Scalar,
|
Scalar,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect the best available SIMD backend at runtime.
|
/// The best available SIMD backend, detected once per process. Every kernel
|
||||||
|
/// dispatches through this, so it sits in the innermost loop of every search.
|
||||||
pub fn detect_backend() -> Backend {
|
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
|
||||||
@@ -116,6 +122,36 @@ pub fn dot_product(a: &[f32], b: &[f32]) -> f32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dot product of two `i8` slices, widened to `i32`.
|
||||||
|
///
|
||||||
|
/// The kernel behind int8-quantised vector search. On x86-64 it uses the AVX2
|
||||||
|
/// path whenever AVX2 is present (including on AVX-512 machines, where it is
|
||||||
|
/// what the f32 kernels use too on a default build). On aarch64 it uses the
|
||||||
|
/// ARMv8.2 `SDOT` instruction when the CPU has the dot-product extension, and
|
||||||
|
/// plain NEON otherwise.
|
||||||
|
pub fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
|
||||||
|
match detect_backend() {
|
||||||
|
#[cfg(target_arch = "aarch64")]
|
||||||
|
Backend::Neon => {
|
||||||
|
if std::arch::is_aarch64_feature_detected!("dotprod") {
|
||||||
|
// SAFETY: the dotprod extension was just detected at runtime.
|
||||||
|
unsafe { neon::dot_i8_dotprod(a, b) }
|
||||||
|
} else {
|
||||||
|
// SAFETY: NEON is always available on aarch64.
|
||||||
|
unsafe { neon::dot_i8(a, b) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
|
// SAFETY: both variants imply AVX2 was detected at runtime (the
|
||||||
|
// AVX-512 backend is only selected on CPUs that also have AVX2).
|
||||||
|
Backend::Avx2 | Backend::Avx512 if is_x86_feature_detected!("avx2") => unsafe {
|
||||||
|
avx2::dot_i8(a, b)
|
||||||
|
},
|
||||||
|
_ => scalar::dot_i8(a, b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Compute the L2 norm (magnitude) of a vector.
|
/// Compute the L2 norm (magnitude) of a vector.
|
||||||
pub fn vector_norm(v: &[f32]) -> f32 {
|
pub fn vector_norm(v: &[f32]) -> f32 {
|
||||||
dot_product(v, v).sqrt()
|
dot_product(v, v).sqrt()
|
||||||
@@ -707,3 +743,78 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod dot_i8_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn codes(n: usize, seed: u64) -> Vec<i8> {
|
||||||
|
let mut state = seed;
|
||||||
|
(0..n)
|
||||||
|
.map(|_| {
|
||||||
|
state = state
|
||||||
|
.wrapping_mul(6_364_136_223_846_793_005)
|
||||||
|
.wrapping_add(1_442_695_040_888_963_407);
|
||||||
|
// Full range, including the extremes.
|
||||||
|
((state >> 56) as u8) as i8
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatched_kernel_matches_scalar_exactly() {
|
||||||
|
// Integer arithmetic: the SIMD path must agree bit for bit, at every
|
||||||
|
// length — including ones that are not multiples of the 32-byte block,
|
||||||
|
// which exercise the tail.
|
||||||
|
for len in [0, 1, 7, 31, 32, 33, 63, 64, 100, 384, 385, 1536] {
|
||||||
|
let a = codes(len, 1 + len as u64);
|
||||||
|
let b = codes(len, 1000 + len as u64);
|
||||||
|
assert_eq!(dot_i8(&a, &b), scalar::dot_i8(&a, &b), "len {len}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch only ever takes one path on a given CPU, so on a machine with
|
||||||
|
/// the dot-product extension the plain-NEON kernel would otherwise go
|
||||||
|
/// untested. Check each aarch64 kernel against scalar directly.
|
||||||
|
#[cfg(target_arch = "aarch64")]
|
||||||
|
#[test]
|
||||||
|
fn every_aarch64_kernel_matches_scalar_exactly() {
|
||||||
|
for len in [0, 1, 7, 15, 16, 17, 31, 32, 33, 63, 64, 100, 384, 385, 1536] {
|
||||||
|
let a = codes(len, 7 + len as u64);
|
||||||
|
let b = codes(len, 7000 + len as u64);
|
||||||
|
let want = scalar::dot_i8(&a, &b);
|
||||||
|
// SAFETY: NEON is always available on aarch64.
|
||||||
|
assert_eq!(unsafe { neon::dot_i8(&a, &b) }, want, "neon, len {len}");
|
||||||
|
if std::arch::is_aarch64_feature_detected!("dotprod") {
|
||||||
|
// SAFETY: the dotprod extension was just detected.
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { neon::dot_i8_dotprod(&a, &b) },
|
||||||
|
want,
|
||||||
|
"dotprod, len {len}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The extremes, through both kernels.
|
||||||
|
let lo = vec![-128i8; 4096];
|
||||||
|
let hi = vec![127i8; 4096];
|
||||||
|
// SAFETY: NEON is always available on aarch64.
|
||||||
|
assert_eq!(unsafe { neon::dot_i8(&lo, &lo) }, 4096 * 128 * 128);
|
||||||
|
// SAFETY: NEON is always available on aarch64.
|
||||||
|
assert_eq!(unsafe { neon::dot_i8(&lo, &hi) }, -4096 * 128 * 127);
|
||||||
|
if std::arch::is_aarch64_feature_detected!("dotprod") {
|
||||||
|
// SAFETY: the dotprod extension was just detected.
|
||||||
|
assert_eq!(unsafe { neon::dot_i8_dotprod(&lo, &lo) }, 4096 * 128 * 128);
|
||||||
|
// SAFETY: the dotprod extension was just detected.
|
||||||
|
assert_eq!(unsafe { neon::dot_i8_dotprod(&lo, &hi) }, -4096 * 128 * 127);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extremes_do_not_overflow() {
|
||||||
|
// -128 * -128 is the largest product; a long run of it must still fit.
|
||||||
|
let a = vec![-128i8; 4096];
|
||||||
|
assert_eq!(dot_i8(&a, &a), 4096 * 128 * 128);
|
||||||
|
let b = vec![127i8; 4096];
|
||||||
|
assert_eq!(dot_i8(&a, &b), -4096 * 128 * 127);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,7 +94,11 @@ 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 { 0.0 } else { dot / denom }
|
if denom < f32::EPSILON {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
dot / denom
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NEON L2 distance.
|
/// NEON L2 distance.
|
||||||
@@ -176,3 +180,130 @@ pub fn checksum_fletcher32(data: &[u8]) -> u32 {
|
|||||||
|
|
||||||
(sum2 << 16) | sum1
|
(sum2 << 16) | sum1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// NEON dot product of two `i8` slices, widened to `i32`, for any aarch64 CPU.
|
||||||
|
///
|
||||||
|
/// `vmull_s8` multiplies eight lanes into `i16` — even `-128 * -128` is 16 384,
|
||||||
|
/// inside `i16` — and `vpadalq_s16` adds adjacent pairs of those into `i32`
|
||||||
|
/// accumulators, so nothing can overflow before the final horizontal sum.
|
||||||
|
///
|
||||||
|
/// CPUs with the ARMv8.2 dot-product extension should use
|
||||||
|
/// [`dot_i8_dotprod`], which does the multiply and the accumulate in one
|
||||||
|
/// instruction.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Caller must ensure aarch64 target (NEON always available).
|
||||||
|
// SAFETY: NEON is always available on aarch64 targets; caller guarantees aarch64.
|
||||||
|
#[target_feature(enable = "neon")]
|
||||||
|
pub unsafe fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
let len = a.len();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut acc0 = vdupq_n_s32(0);
|
||||||
|
let mut acc1 = vdupq_n_s32(0);
|
||||||
|
|
||||||
|
while i + 16 <= len {
|
||||||
|
// SAFETY: NEON is available per the # Safety contract, and both
|
||||||
|
// 16-byte loads start at an index checked against `len` above.
|
||||||
|
unsafe {
|
||||||
|
let va = vld1q_s8(a.as_ptr().add(i));
|
||||||
|
let vb = vld1q_s8(b.as_ptr().add(i));
|
||||||
|
acc0 = vpadalq_s16(acc0, vmull_s8(vget_low_s8(va), vget_low_s8(vb)));
|
||||||
|
acc1 = vpadalq_s16(acc1, vmull_high_s8(va, vb));
|
||||||
|
}
|
||||||
|
i += 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sum = vaddvq_s32(vaddq_s32(acc0, acc1));
|
||||||
|
while i < len {
|
||||||
|
sum += i32::from(a[i]) * i32::from(b[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
sum
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `SDOT`: for each of the four `i32` lanes of `acc`, add the dot
|
||||||
|
/// product of the corresponding four `i8` pairs from `a` and `b`.
|
||||||
|
///
|
||||||
|
/// Written as inline assembly because the `vdotq_s32` intrinsic is still
|
||||||
|
/// behind the unstable `stdarch_neon_dotprod` feature; inline assembly is
|
||||||
|
/// stable on aarch64.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Caller must ensure the CPU supports the `dotprod` extension.
|
||||||
|
#[inline]
|
||||||
|
#[target_feature(enable = "neon,dotprod")]
|
||||||
|
unsafe fn sdot(acc: int32x4_t, a: int8x16_t, b: int8x16_t) -> int32x4_t {
|
||||||
|
let mut acc = acc;
|
||||||
|
// SAFETY: `dotprod` is enabled for this function and the caller
|
||||||
|
// guarantees the CPU supports it. The instruction reads only its three
|
||||||
|
// vector registers and touches no memory.
|
||||||
|
unsafe {
|
||||||
|
std::arch::asm!(
|
||||||
|
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
|
||||||
|
acc = inout(vreg) acc,
|
||||||
|
a = in(vreg) a,
|
||||||
|
b = in(vreg) b,
|
||||||
|
options(pure, nomem, nostack),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NEON dot product of two `i8` slices using the ARMv8.2 dot-product
|
||||||
|
/// extension (`SDOT`): sixteen multiply-accumulates per instruction, straight
|
||||||
|
/// into `i32` lanes.
|
||||||
|
///
|
||||||
|
/// Present on the cores this crate actually runs on — Cortex-A76 and later
|
||||||
|
/// (Raspberry Pi 5, current Android phones), Neoverse-N1 (Graviton2, Ampere
|
||||||
|
/// Altra), and every Apple Silicon generation.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Caller must verify `is_aarch64_feature_detected!("dotprod")`.
|
||||||
|
// SAFETY: caller has verified the dotprod extension at runtime.
|
||||||
|
#[target_feature(enable = "neon,dotprod")]
|
||||||
|
pub unsafe fn dot_i8_dotprod(a: &[i8], b: &[i8]) -> i32 {
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
let len = a.len();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut acc0 = vdupq_n_s32(0);
|
||||||
|
let mut acc1 = vdupq_n_s32(0);
|
||||||
|
|
||||||
|
// Two independent accumulators so consecutive SDOTs are not serialised on
|
||||||
|
// one register.
|
||||||
|
while i + 32 <= len {
|
||||||
|
// SAFETY: dotprod is available per the # Safety contract, and every
|
||||||
|
// 16-byte load starts at an index checked against `len` above.
|
||||||
|
unsafe {
|
||||||
|
acc0 = sdot(
|
||||||
|
acc0,
|
||||||
|
vld1q_s8(a.as_ptr().add(i)),
|
||||||
|
vld1q_s8(b.as_ptr().add(i)),
|
||||||
|
);
|
||||||
|
acc1 = sdot(
|
||||||
|
acc1,
|
||||||
|
vld1q_s8(a.as_ptr().add(i + 16)),
|
||||||
|
vld1q_s8(b.as_ptr().add(i + 16)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
i += 32;
|
||||||
|
}
|
||||||
|
if i + 16 <= len {
|
||||||
|
// SAFETY: as above; the load is bounds-checked by this condition.
|
||||||
|
unsafe {
|
||||||
|
acc0 = sdot(
|
||||||
|
acc0,
|
||||||
|
vld1q_s8(a.as_ptr().add(i)),
|
||||||
|
vld1q_s8(b.as_ptr().add(i)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
i += 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sum = vaddvq_s32(vaddq_s32(acc0, acc1));
|
||||||
|
while i < len {
|
||||||
|
sum += i32::from(a[i]) * i32::from(b[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
sum
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ 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 { 0.0 } else { dot / denom }
|
if denom < f32::EPSILON {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
dot / denom
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn batch_cosine(query: &[f32], vectors: &[&[f32]], results: &mut [(usize, f32)]) {
|
pub fn batch_cosine(query: &[f32], vectors: &[&[f32]], results: &mut [(usize, f32)]) {
|
||||||
@@ -136,3 +140,33 @@ fn f16_to_f32_soft(h: u16) -> f32 {
|
|||||||
|
|
||||||
f32::from_bits(f32_bits)
|
f32::from_bits(f32_bits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dot product of two `i8` slices, widened to `i32`.
|
||||||
|
///
|
||||||
|
/// `dim` terms of at most `127 * 127` fit an `i32` for any realistic
|
||||||
|
/// dimension (over 130 000 terms before overflow is possible).
|
||||||
|
pub fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
// Four independent accumulators over 32-lane blocks: the widening product
|
||||||
|
// has to sit in a fixed-length chunk for the vectoriser to see it, and the
|
||||||
|
// separate accumulators keep it off one dependency chain.
|
||||||
|
const LANE: usize = 8;
|
||||||
|
let (a_blocks, a_tail) = a.as_chunks::<{ LANE * 4 }>();
|
||||||
|
let (b_blocks, b_tail) = b.as_chunks::<{ LANE * 4 }>();
|
||||||
|
let mut acc = [0i32; 4];
|
||||||
|
for (x, y) in a_blocks.iter().zip(b_blocks) {
|
||||||
|
for (lane, slot) in acc.iter_mut().enumerate() {
|
||||||
|
let mut sum = 0i32;
|
||||||
|
for k in 0..LANE {
|
||||||
|
sum += i32::from(x[lane * LANE + k]) * i32::from(y[lane * LANE + k]);
|
||||||
|
}
|
||||||
|
*slot += sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tail: i32 = a_tail
|
||||||
|
.iter()
|
||||||
|
.zip(b_tail)
|
||||||
|
.map(|(&x, &y)| i32::from(x) * i32::from(y))
|
||||||
|
.sum();
|
||||||
|
acc[0] + acc[1] + acc[2] + acc[3] + tail
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-agent"
|
name = "clawhdf5-agent"
|
||||||
version = "2.2.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version.workspace = true
|
||||||
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://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||||
@@ -10,14 +11,18 @@ keywords = ["agent", "memory", "hdf5", "vector-search", "embedding"]
|
|||||||
categories = ["database", "science", "algorithms"]
|
categories = ["database", "science", "algorithms"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0", features = ["parallel", "fast-checksum"] }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0", features = ["parallel", "fast-checksum"] }
|
||||||
clawhdf5 = { path = "../clawhdf5", version = "2.2.0" }
|
clawhdf5 = { path = "../clawhdf5", version = "2.7.0" }
|
||||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.2.0", features = ["mmap"] }
|
clawhdf5-io = { path = "../clawhdf5-io", version = "2.7.0", features = ["mmap"] }
|
||||||
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.2.0" }
|
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.7.0" }
|
||||||
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.2.0", optional = true }
|
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.7.0", optional = true }
|
||||||
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.2.0", optional = true, default-features = false }
|
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.7.0", optional = true, default-features = false }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
byteorder = "1"
|
byteorder = "1"
|
||||||
|
# Signed checkpoints (MemoryConfig-independent; see `signing`). Pure Rust.
|
||||||
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
half = { workspace = true, optional = true }
|
half = { workspace = true, optional = true }
|
||||||
rayon = { version = "1", optional = true }
|
rayon = { version = "1", optional = true }
|
||||||
matrixmultiply = { version = "0.3", optional = true }
|
matrixmultiply = { version = "0.3", optional = true }
|
||||||
@@ -44,17 +49,25 @@ harness = false
|
|||||||
name = "memory_bench"
|
name = "memory_bench"
|
||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "multimodal_bench"
|
||||||
|
harness = false
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["float16", "hnsw"]
|
default = ["float16", "hnsw", "parallel"]
|
||||||
float16 = ["half"]
|
float16 = ["half"]
|
||||||
parallel = ["rayon"]
|
# Rayon-parallel brute-force search strategies, and a parallel bulk build of
|
||||||
|
# the HNSW index (same graph, several times faster on a multi-core machine).
|
||||||
|
parallel = ["rayon", "clawhdf5-ann?/parallel"]
|
||||||
|
# Compress embeddings with Zstd instead of deflate when
|
||||||
|
# `MemoryConfig::compression` is on. Off by default: it links libzstd (C).
|
||||||
|
zstd = ["clawhdf5/zstd"]
|
||||||
# HNSW approximate-nearest-neighbour acceleration for the vector stage of
|
# 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
|
||||||
# `--no-default-features` (plus re-enabling other defaults) to force the exact
|
# `--no-default-features` (plus re-enabling other defaults) to force the exact
|
||||||
# linear cosine scan.
|
# linear cosine scan.
|
||||||
hnsw = ["clawhdf5-ann"]
|
hnsw = ["clawhdf5-ann"]
|
||||||
agent = []
|
|
||||||
gpu = ["clawhdf5-gpu/gpu-wgpu"]
|
gpu = ["clawhdf5-gpu/gpu-wgpu"]
|
||||||
fast-math = ["matrixmultiply"]
|
fast-math = ["matrixmultiply"]
|
||||||
accelerate = ["accelerate-src", "cblas-sys"]
|
accelerate = ["accelerate-src", "cblas-sys"]
|
||||||
|
|||||||
@@ -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 + num_cores - 1) / num_cores;
|
let chunk_size = n.div_ceil(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 + num_cores - 1) / num_cores;
|
let chunk_size = n.div_ceil(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,12 +766,22 @@ 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(&query, &vectors, &norms, &tombstones, 10, strat, None)
|
strategy::search_with_metrics(
|
||||||
|
&query,
|
||||||
|
&vectors,
|
||||||
|
&flat,
|
||||||
|
&norms,
|
||||||
|
&tombstones,
|
||||||
|
10,
|
||||||
|
strat,
|
||||||
|
None,
|
||||||
|
)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -781,6 +791,7 @@ fn adaptive_benches(c: &mut Criterion) {
|
|||||||
strategy::search_with_metrics(
|
strategy::search_with_metrics(
|
||||||
&query,
|
&query,
|
||||||
&vectors,
|
&vectors,
|
||||||
|
&flat,
|
||||||
&norms,
|
&norms,
|
||||||
&tombstones,
|
&tombstones,
|
||||||
10,
|
10,
|
||||||
@@ -795,6 +806,7 @@ 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,
|
||||||
@@ -809,6 +821,7 @@ 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,6 +1,7 @@
|
|||||||
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;
|
||||||
@@ -285,7 +286,12 @@ 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(chunk, embedding, MemorySource::User, now + i as f64);
|
engine.add_memory(
|
||||||
|
chunk,
|
||||||
|
embedding,
|
||||||
|
UntrustedSource::User,
|
||||||
|
now + i as f64,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
engine
|
engine
|
||||||
},
|
},
|
||||||
@@ -307,9 +313,10 @@ 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, MemorySource::User, now + i as f64);
|
engine.add_memory(chunk, embedding, UntrustedSource::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 =
|
||||||
@@ -317,7 +324,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, &records);
|
let surprise = ImportanceScorer::score_surprise(&query_embedding, &record_refs);
|
||||||
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)
|
||||||
@@ -354,7 +361,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
|
||||||
@@ -442,7 +449,8 @@ 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,
|
||||||
@@ -459,7 +467,12 @@ 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(chunk, embedding, MemorySource::User, now + i as f64);
|
engine.add_memory(
|
||||||
|
chunk,
|
||||||
|
embedding,
|
||||||
|
UntrustedSource::User,
|
||||||
|
now + i as f64,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
engine
|
engine
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! Multi-modal memory search benchmarks (`clawhdf5_agent::multimodal`).
|
||||||
|
//!
|
||||||
|
//! Covers `MultiModalStore::search_cross_modal` (every embedding of every
|
||||||
|
//! record, whatever its modality) and, for comparison,
|
||||||
|
//! `MultiModalStore::search_by_modality` restricted to one modality.
|
||||||
|
//!
|
||||||
|
//! Corpus: N records (1K and 10K), each carrying two 384-dim embeddings —
|
||||||
|
//! a text embedding of its caption plus one embedding of its primary modality,
|
||||||
|
//! cycling Image / Audio / Video — so a cross-modal query scores 2N vectors.
|
||||||
|
//! All data comes from a fixed-seed LCG, so every run sees the same corpus.
|
||||||
|
//!
|
||||||
|
//! Run: `cargo bench -p clawhdf5-agent --bench multimodal_bench`
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use clawhdf5_agent::multimodal::{
|
||||||
|
MediaRef, ModalEmbedding, Modality, MultiModalRecord, MultiModalStore,
|
||||||
|
};
|
||||||
|
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Simple deterministic PRNG (LCG), same as the other agent benches
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Rng(u32);
|
||||||
|
|
||||||
|
impl Rng {
|
||||||
|
fn new(seed: u32) -> Self {
|
||||||
|
Self(seed)
|
||||||
|
}
|
||||||
|
fn next_u32(&mut self) -> u32 {
|
||||||
|
self.0 = self.0.wrapping_mul(1103515245).wrapping_add(12345);
|
||||||
|
self.0 >> 16
|
||||||
|
}
|
||||||
|
fn next_f32(&mut self) -> f32 {
|
||||||
|
self.next_u32() as f32 / 65536.0 - 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_vec(rng: &mut Rng, dim: usize) -> Vec<f32> {
|
||||||
|
(0..dim).map(|_| rng.next_f32()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Corpus
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DIM: usize = 384;
|
||||||
|
const K: usize = 10;
|
||||||
|
|
||||||
|
const MEDIA: [(Modality, &str, &str); 3] = [
|
||||||
|
(Modality::Image, "image/png", "clip-vit-base"),
|
||||||
|
(Modality::Audio, "audio/wav", "clap-base"),
|
||||||
|
(Modality::Video, "video/mp4", "xclip-base"),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn build_store(n: usize, seed: u32) -> MultiModalStore {
|
||||||
|
let mut rng = Rng::new(seed);
|
||||||
|
let mut store = MultiModalStore::new();
|
||||||
|
for i in 0..n {
|
||||||
|
let (modality, mime, model) = &MEDIA[i % MEDIA.len()];
|
||||||
|
let embeddings = vec![
|
||||||
|
ModalEmbedding::new(Modality::Text, make_vec(&mut rng, DIM), "minilm-l6"),
|
||||||
|
ModalEmbedding::new(modality.clone(), make_vec(&mut rng, DIM), *model),
|
||||||
|
];
|
||||||
|
store.add_record(MultiModalRecord {
|
||||||
|
id: 0,
|
||||||
|
primary_modality: modality.clone(),
|
||||||
|
text_content: Some(format!("{modality} memory {i}")),
|
||||||
|
media_ref: Some(MediaRef::path(format!("/media/{i}"), *mime)),
|
||||||
|
embeddings,
|
||||||
|
observation: None,
|
||||||
|
timestamp: 1_700_000_000.0 + i as f64,
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmarks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn multimodal_search_benches(c: &mut Criterion) {
|
||||||
|
let query = make_vec(&mut Rng::new(99), DIM);
|
||||||
|
|
||||||
|
let mut group = c.benchmark_group("multimodal_search");
|
||||||
|
group.sample_size(50);
|
||||||
|
|
||||||
|
for (label, n) in [("1k", 1_000usize), ("10k", 10_000)] {
|
||||||
|
let store = build_store(n, 42);
|
||||||
|
assert_eq!(store.count(), n);
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("cross_modal", label), &n, |b, _| {
|
||||||
|
b.iter(|| store.search_cross_modal(&query, K));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.bench_with_input(BenchmarkId::new("by_modality_image", label), &n, |b, _| {
|
||||||
|
b.iter(|| store.search_by_modality(&Modality::Image, &query, K));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(multimodal_benches, multimodal_search_benches);
|
||||||
|
criterion_main!(multimodal_benches);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
target/
|
||||||
|
artifacts/
|
||||||
|
coverage/
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "clawhdf5-agent-fuzz"
|
||||||
|
version = "0.0.0"
|
||||||
|
publish = false
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
cargo-fuzz = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libfuzzer-sys = "0.4"
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
|
[dependencies.clawhdf5-agent]
|
||||||
|
path = ".."
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = ["."]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_wal_replay"
|
||||||
|
path = "fuzz_targets/fuzz_wal_replay.rs"
|
||||||
|
doc = false
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#![no_main]
|
||||||
|
//! Arbitrary bytes as a WAL file. Reading, and opening for append (which scans
|
||||||
|
//! the chain and truncates an unverifiable tail), must never panic, hang, or
|
||||||
|
//! allocate without bound — and after `open` repairs the file, everything
|
||||||
|
//! `read_entries` returned before must still be returned.
|
||||||
|
//!
|
||||||
|
//! The deterministic counterpart that runs in ordinary CI is
|
||||||
|
//! `tests/wal_properties.rs`; this target explores inputs it cannot reach.
|
||||||
|
|
||||||
|
use std::io::Write as _;
|
||||||
|
|
||||||
|
use clawhdf5_agent::wal::WalFile;
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
let Ok(mut tmp) = tempfile::NamedTempFile::new() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if tmp.write_all(data).and_then(|()| tmp.flush()).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let before = WalFile::read_entries(tmp.path()).map(|e| e.len());
|
||||||
|
// Only the chained formats (header versions 3 and 4) are repaired in
|
||||||
|
// place. `open` deliberately recreates a legacy-format file from scratch:
|
||||||
|
// `HDF5Memory::open` has already replayed its entries by then.
|
||||||
|
let chained = matches!(data.get(4), Some(3 | 4));
|
||||||
|
let opened = WalFile::open(tmp.path());
|
||||||
|
if !chained {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let (Ok(before), Ok(wal)) = (before, opened) {
|
||||||
|
drop(wal);
|
||||||
|
let after = WalFile::read_entries(tmp.path()).map(|e| e.len());
|
||||||
|
assert_eq!(after.ok(), Some(before), "open() changed what is replayable");
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -118,6 +118,10 @@ 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,
|
||||||
|
hnsw_m: 16,
|
||||||
|
hnsw_ef_construction: 64,
|
||||||
|
hnsw_ef_search: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ 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 {
|
||||||
@@ -189,6 +192,23 @@ 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())
|
||||||
@@ -437,7 +457,11 @@ mod tests {
|
|||||||
fn rate_anomaly_names_offending_session() {
|
fn rate_anomaly_names_offending_session() {
|
||||||
let mut det = WriteAnomalyDetector::new(cfg());
|
let mut det = WriteAnomalyDetector::new(cfg());
|
||||||
for i in 0..11 {
|
for i in 0..11 {
|
||||||
det.record_write(event(1.0 + i as f64 * 0.1, "flood-session", MemorySource::User));
|
det.record_write(event(
|
||||||
|
1.0 + i as f64 * 0.1,
|
||||||
|
"flood-session",
|
||||||
|
MemorySource::User,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let alert = det.check_rate_anomaly().unwrap();
|
let alert = det.check_rate_anomaly().unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -454,11 +478,19 @@ mod tests {
|
|||||||
let mut det = WriteAnomalyDetector::new(cfg());
|
let mut det = WriteAnomalyDetector::new(cfg());
|
||||||
// 5 sessions with 1 write each (below any per-session limit)...
|
// 5 sessions with 1 write each (below any per-session limit)...
|
||||||
for i in 0..5 {
|
for i in 0..5 {
|
||||||
det.record_write(event(1.0 + i as f64 * 0.1, "minor-session", MemorySource::User));
|
det.record_write(event(
|
||||||
|
1.0 + i as f64 * 0.1,
|
||||||
|
"minor-session",
|
||||||
|
MemorySource::User,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
// ...plus one session responsible for the majority of the flood.
|
// ...plus one session responsible for the majority of the flood.
|
||||||
for i in 0..8 {
|
for i in 0..8 {
|
||||||
det.record_write(event(2.0 + i as f64 * 0.1, "major-session", MemorySource::User));
|
det.record_write(event(
|
||||||
|
2.0 + i as f64 * 0.1,
|
||||||
|
"major-session",
|
||||||
|
MemorySource::User,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let alert = det.check_rate_anomaly().unwrap();
|
let alert = det.check_rate_anomaly().unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -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.7, 0.3, 5).await;
|
//! let results = mem.hybrid_search(emb, "query".into(), 0.4, 0.6, 5).await;
|
||||||
//! mem.shutdown().await?; // final flush + stop
|
//! mem.shutdown().await?; // final flush + stop
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
@@ -408,6 +408,10 @@ 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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+396
-118
@@ -3,10 +3,15 @@
|
|||||||
//! 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.
|
||||||
//!
|
//!
|
||||||
//! Optimizations:
|
//! The index is **incremental**: [`BM25Index::add_document`] and
|
||||||
//! - Cached IDF scores (don't recompute per query)
|
//! [`BM25Index::remove_document`] keep it exactly equivalent to one built from
|
||||||
//! - Sorted posting lists by doc_id for cache-friendly access
|
//! scratch over the same live documents, so a store can maintain one index for
|
||||||
//! - Block-Max WAND early termination
|
//! its lifetime instead of re-tokenising the whole corpus per query. To make
|
||||||
|
//! that possible IDF is computed at query time (it depends on the live
|
||||||
|
//! document count) rather than cached at build time.
|
||||||
|
//!
|
||||||
|
//! - Posting lists sorted by doc id
|
||||||
|
//! - Bounded-heap top-k; results ordered by score, then doc id (deterministic)
|
||||||
|
|
||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
use std::collections::{BinaryHeap, HashMap};
|
use std::collections::{BinaryHeap, HashMap};
|
||||||
@@ -41,10 +46,11 @@ 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.
|
||||||
@@ -53,19 +59,27 @@ 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
|
||||||
@@ -74,110 +88,171 @@ impl BM25Index {
|
|||||||
/// Search the index for a query, returning the top `k` results
|
/// Search the index for a query, returning the top `k` results
|
||||||
/// as `(doc_id, score)` pairs sorted by score descending.
|
/// as `(doc_id, score)` pairs sorted by score descending.
|
||||||
///
|
///
|
||||||
/// Uses Block-Max WAND for early termination when remaining documents
|
/// Scores every matching document exhaustively, then keeps the top `k`.
|
||||||
/// cannot beat the current top-k threshold.
|
/// There is no early termination (WAND, MaxScore): the store's hot path
|
||||||
|
/// is [`scores`](Self::scores), because score fusion normalises over the
|
||||||
|
/// whole matching set and so needs every score, which no pruning scheme
|
||||||
|
/// can skip. This method is for BM25-only callers.
|
||||||
pub fn search(&self, query: &str, k: usize) -> Vec<(usize, f32)> {
|
pub fn search(&self, query: &str, k: usize) -> Vec<(usize, f32)> {
|
||||||
if self.num_docs == 0 || k == 0 {
|
if k == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
// Top-k with a bounded min-heap: O(matches * log k) instead of sorting
|
||||||
let tokens = tokenize(query);
|
// every match. Ties break towards the lower doc id so results are
|
||||||
if tokens.is_empty() {
|
// deterministic.
|
||||||
return Vec::new();
|
let mut heap: BinaryHeap<Reverse<(HeapScore, Reverse<usize>)>> =
|
||||||
}
|
BinaryHeap::with_capacity(k.min(1024) + 1);
|
||||||
|
for (doc_id, score) in self.scores(query) {
|
||||||
// Collect posting lists and cached IDF scores for query tokens
|
heap.push(Reverse((HeapScore(score), Reverse(doc_id))));
|
||||||
type QueryTerm<'a> = (&'a str, f32, &'a [(usize, u32)]);
|
if heap.len() > k {
|
||||||
let mut query_terms: Vec<QueryTerm<'_>> = Vec::new();
|
heap.pop();
|
||||||
for token in &tokens {
|
|
||||||
if let (Some(postings), Some(&idf)) = (
|
|
||||||
self.inverted.get(token.as_str()),
|
|
||||||
self.idf_cache.get(token.as_str()),
|
|
||||||
) {
|
|
||||||
query_terms.push((token, idf, postings));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut results: Vec<(usize, f32)> = heap
|
||||||
|
.into_iter()
|
||||||
|
.map(|Reverse((HeapScore(score), Reverse(doc_id)))| (doc_id, score))
|
||||||
|
.collect();
|
||||||
|
results.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
if query_terms.is_empty() {
|
/// The BM25 score of **every** matching document, in doc-id order, unsorted
|
||||||
|
/// by score. Score fusion normalises over the whole matching set, so it
|
||||||
|
/// needs all of these but not their ranking; producing a ranked list of
|
||||||
|
/// every match (`search(query, corpus_len)`) spent most of its time sorting.
|
||||||
|
pub fn scores(&self, query: &str) -> Vec<(usize, f32)> {
|
||||||
|
if self.num_docs == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
// Term-at-a-time accumulation into a dense array: a common term has a
|
||||||
// Accumulate BM25 scores per document using WAND-style scoring
|
// posting per document, and hashing each one dominated query time.
|
||||||
let mut scores: HashMap<usize, f32> = HashMap::new();
|
// IDF is computed here rather than cached at build time: it depends on
|
||||||
|
// the live document count, which changes with every incremental
|
||||||
// Compute maximum possible contribution per term for WAND
|
// add/remove, and costs one `ln` per query term.
|
||||||
let max_tf_score: Vec<f32> = query_terms
|
let mut acc = vec![0.0f32; self.doc_lengths.len()];
|
||||||
.iter()
|
let mut matched = false;
|
||||||
.map(|(_, idf, _)| {
|
for token in tokenize_with(query, self.filter) {
|
||||||
// Upper bound: max TF contribution when tf is high and dl is short
|
let Some(postings) = self.inverted.get(token.as_str()) else {
|
||||||
let max_tf_num = 10.0 * (self.k1 + 1.0);
|
continue;
|
||||||
let max_tf_den = 10.0 + self.k1 * (1.0 - self.b);
|
};
|
||||||
idf * max_tf_num / max_tf_den
|
matched = true;
|
||||||
})
|
let df = postings.len() as f32;
|
||||||
.collect();
|
let idf = ((self.num_docs as f32 - df + 0.5) / (df + 0.5) + 1.0).ln();
|
||||||
|
for &(doc_id, freq) in postings {
|
||||||
let total_max_contribution: f32 = max_tf_score.iter().sum();
|
|
||||||
|
|
||||||
// Threshold for WAND early termination. `top_k_heap` is a min-heap of
|
|
||||||
// size k (worst-of-the-top-k at the head) so it can be maintained in
|
|
||||||
// O(log k) per update instead of re-sorting the whole buffer.
|
|
||||||
let mut threshold = 0.0f32;
|
|
||||||
let mut top_k_heap: BinaryHeap<Reverse<HeapScore>> = BinaryHeap::with_capacity(k);
|
|
||||||
|
|
||||||
for (term_idx, (_, idf, postings)) in query_terms.iter().enumerate() {
|
|
||||||
for &(doc_id, freq) in *postings {
|
|
||||||
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));
|
||||||
let contribution = idf * tf;
|
acc[doc_id] += idf * tf;
|
||||||
|
|
||||||
let entry = scores.entry(doc_id).or_insert(0.0);
|
|
||||||
*entry += contribution;
|
|
||||||
|
|
||||||
// WAND check: if this doc's current partial score + remaining
|
|
||||||
// max terms can't beat threshold, we can skip (but we still
|
|
||||||
// accumulate since we process term-at-a-time)
|
|
||||||
if term_idx == query_terms.len() - 1 {
|
|
||||||
// Last term: check if this doc beats threshold
|
|
||||||
let final_score = *entry;
|
|
||||||
if top_k_heap.len() >= k {
|
|
||||||
if final_score > threshold {
|
|
||||||
// Replace the current worst-of-top-k.
|
|
||||||
top_k_heap.pop();
|
|
||||||
top_k_heap.push(Reverse(HeapScore(final_score)));
|
|
||||||
threshold = top_k_heap.peek().map(|Reverse(s)| s.0).unwrap_or(0.0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
top_k_heap.push(Reverse(HeapScore(final_score)));
|
|
||||||
if top_k_heap.len() == k {
|
|
||||||
threshold = top_k_heap.peek().map(|Reverse(s)| s.0).unwrap_or(0.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// After processing each term, check if remaining terms can
|
|
||||||
// possibly produce results above threshold
|
|
||||||
let remaining_max: f32 = max_tf_score[term_idx + 1..].iter().sum();
|
|
||||||
if remaining_max < threshold && total_max_contribution > 0.0 {
|
|
||||||
// Early termination: remaining terms can't produce new top-k
|
|
||||||
// entries on their own. But existing partial scores may still
|
|
||||||
// be updated, so we continue (WAND is approximate here).
|
|
||||||
let _ = remaining_max; // hint to compiler
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !matched {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
// Every contribution is strictly positive (idf = ln(1 + x), x > 0), so
|
||||||
|
// a zero entry is a document no query term touched.
|
||||||
|
acc.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|&(_, score)| score > 0.0)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
let mut results: Vec<(usize, f32)> = scores.into_iter().collect();
|
/// The token filter this index was built with.
|
||||||
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
pub fn token_filter(&self) -> TokenFilter {
|
||||||
results.truncate(k);
|
self.filter
|
||||||
results
|
}
|
||||||
|
|
||||||
|
/// Number of document slots (live or not) the index covers. Ids are
|
||||||
|
/// positions in the document list it mirrors.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.doc_lengths.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` when the index covers no document slots.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.doc_lengths.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index `text` as document `doc_id`, which must be the next free id
|
||||||
|
/// (`self.len()`) or an existing slot that is currently empty (removed or
|
||||||
|
/// tombstoned). After any sequence of `add_document` / `remove_document`
|
||||||
|
/// calls the index scores exactly as one freshly built from the same live
|
||||||
|
/// documents.
|
||||||
|
pub fn add_document(&mut self, doc_id: usize, text: &str) {
|
||||||
|
if doc_id >= self.doc_lengths.len() {
|
||||||
|
self.doc_lengths.resize(doc_id + 1, 0);
|
||||||
|
}
|
||||||
|
debug_assert_eq!(self.doc_lengths[doc_id], 0, "slot {doc_id} is occupied");
|
||||||
|
|
||||||
|
let tokens = tokenize_with(text, self.filter);
|
||||||
|
let mut term_freqs: HashMap<&str, u32> = HashMap::new();
|
||||||
|
for token in &tokens {
|
||||||
|
*term_freqs.entry(token).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
for (token, freq) in term_freqs {
|
||||||
|
let postings = self.inverted.entry(token.to_string()).or_default();
|
||||||
|
// Posting lists stay sorted by doc id; appends are the common case.
|
||||||
|
match postings.last() {
|
||||||
|
Some(&(last, _)) if last >= doc_id => {
|
||||||
|
let at = postings.partition_point(|&(id, _)| id < doc_id);
|
||||||
|
postings.insert(at, (doc_id, freq));
|
||||||
|
}
|
||||||
|
_ => postings.push((doc_id, freq)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.doc_lengths[doc_id] = tokens.len() as u32;
|
||||||
|
self.total_length += tokens.len() as u64;
|
||||||
|
self.num_docs += 1;
|
||||||
|
self.refresh_avg_dl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extend the index to cover `len` document slots, leaving new ones empty.
|
||||||
|
/// Used for slots that hold no live document (tombstoned records).
|
||||||
|
pub fn pad_to(&mut self, len: usize) {
|
||||||
|
if len > self.doc_lengths.len() {
|
||||||
|
self.doc_lengths.resize(len, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove document `doc_id`, whose indexed text was `text`. The text is
|
||||||
|
/// needed to find its postings; pass exactly what was added.
|
||||||
|
pub fn remove_document(&mut self, doc_id: usize, text: &str) {
|
||||||
|
let tokens = tokenize_with(text, self.filter);
|
||||||
|
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||||
|
for token in &tokens {
|
||||||
|
if !seen.insert(token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(postings) = self.inverted.get_mut(token.as_str()) {
|
||||||
|
if let Ok(at) = postings.binary_search_by_key(&doc_id, |&(id, _)| id) {
|
||||||
|
postings.remove(at);
|
||||||
|
}
|
||||||
|
if postings.is_empty() {
|
||||||
|
self.inverted.remove(token.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(len) = self.doc_lengths.get_mut(doc_id) {
|
||||||
|
self.total_length = self.total_length.saturating_sub(u64::from(*len));
|
||||||
|
*len = 0;
|
||||||
|
}
|
||||||
|
self.num_docs = self.num_docs.saturating_sub(1);
|
||||||
|
self.refresh_avg_dl();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_avg_dl(&mut self) {
|
||||||
|
self.avg_dl = if self.num_docs > 0 {
|
||||||
|
self.total_length as f32 / self.num_docs as f32
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the index from scratch (e.g., after compaction).
|
/// 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);
|
||||||
@@ -193,7 +268,7 @@ impl BM25Index {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let tokens = tokenize(doc);
|
let tokens = tokenize_with(doc, self.filter);
|
||||||
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;
|
||||||
@@ -214,33 +289,98 @@ impl BM25Index {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.num_docs = count;
|
self.num_docs = count;
|
||||||
self.avg_dl = if count > 0 {
|
self.total_length = total_length;
|
||||||
total_length as f32 / count as f32
|
self.refresh_avg_dl();
|
||||||
} 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(|s| s.to_string())
|
.map(|token| match filter {
|
||||||
|
TokenFilter::Plain => token.to_string(),
|
||||||
|
TokenFilter::Stemmed => stem(token).to_string(),
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,24 +526,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cached_idf_consistent_with_computed() {
|
fn score_matches_the_bm25_formula() {
|
||||||
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 tombstones = vec![0, 0, 0];
|
let index = BM25Index::build(&docs, &[0, 0, 0]);
|
||||||
let index = BM25Index::build(&docs, &tombstones);
|
|
||||||
|
|
||||||
// IDF for "rust" (appears in 2 of 3 docs)
|
// "python": df = 1 of N = 3. Every doc has the average length (2) and
|
||||||
let idf_rust = index.idf_cache.get("rust").unwrap();
|
// tf = 1, so the tf factor is exactly 1 and the score is the IDF.
|
||||||
let expected_idf = ((3.0f32 - 2.0 + 0.5) / (2.0 + 0.5) + 1.0).ln();
|
let results = index.search("python", 3);
|
||||||
assert!(
|
let expected_idf = ((3.0f32 - 1.0 + 0.5) / (1.0 + 0.5) + 1.0).ln();
|
||||||
(idf_rust - expected_idf).abs() < 1e-6,
|
assert_eq!(results.len(), 1);
|
||||||
"cached IDF mismatch: {} vs {}",
|
assert_eq!(results[0].0, 2);
|
||||||
idf_rust,
|
assert!((results[0].1 - expected_idf).abs() < 1e-6, "{results:?}");
|
||||||
expected_idf
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -427,8 +564,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wand_returns_same_results_as_exhaustive() {
|
fn top_k_search_matches_ranking_every_score() {
|
||||||
// WAND-style search should produce same scores as exhaustive
|
// `search` must agree with ranking the full `scores` set — the
|
||||||
|
// bounded heap is an optimisation over sorting, not an approximation.
|
||||||
let docs: Vec<String> = (0..100)
|
let docs: Vec<String> = (0..100)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
if i % 3 == 0 {
|
if i % 3 == 0 {
|
||||||
@@ -467,4 +605,144 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Documents drawn from a small vocabulary so terms collide heavily.
|
||||||
|
fn random_doc(state: &mut u64) -> String {
|
||||||
|
const VOCAB: &[&str] = &[
|
||||||
|
"alpha", "beta", "gamma", "delta", "eps", "zeta", "eta", "x1",
|
||||||
|
];
|
||||||
|
let mut next = || {
|
||||||
|
*state = state
|
||||||
|
.wrapping_mul(6364136223846793005)
|
||||||
|
.wrapping_add(1442695040888963407);
|
||||||
|
(*state >> 33) as usize
|
||||||
|
};
|
||||||
|
let len = 1 + next() % 9;
|
||||||
|
(0..len)
|
||||||
|
.map(|_| VOCAB[next() % VOCAB.len()])
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incremental_updates_match_a_fresh_build_exactly() {
|
||||||
|
for seed in 0..60u64 {
|
||||||
|
let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
|
||||||
|
let mut docs: Vec<String> = Vec::new();
|
||||||
|
let mut tombstones: Vec<u8> = Vec::new();
|
||||||
|
let mut index = BM25Index::build(&docs, &tombstones);
|
||||||
|
|
||||||
|
for step in 0..80 {
|
||||||
|
state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||||
|
let live: Vec<usize> = (0..docs.len()).filter(|&i| tombstones[i] == 0).collect();
|
||||||
|
match (state >> 40) % 4 {
|
||||||
|
0 if !live.is_empty() => {
|
||||||
|
// delete
|
||||||
|
let id = live[(state >> 20) as usize % live.len()];
|
||||||
|
index.remove_document(id, &docs[id]);
|
||||||
|
tombstones[id] = 1;
|
||||||
|
}
|
||||||
|
1 if !live.is_empty() => {
|
||||||
|
// update in place
|
||||||
|
let id = live[(state >> 20) as usize % live.len()];
|
||||||
|
let new_text = random_doc(&mut state);
|
||||||
|
index.remove_document(id, &docs[id]);
|
||||||
|
index.add_document(id, &new_text);
|
||||||
|
docs[id] = new_text;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let text = random_doc(&mut state);
|
||||||
|
index.add_document(docs.len(), &text);
|
||||||
|
docs.push(text);
|
||||||
|
tombstones.push(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fresh = BM25Index::build(&docs, &tombstones);
|
||||||
|
for query in ["alpha", "beta gamma", "x1 zeta alpha delta", "missing"] {
|
||||||
|
let got = index.search(query, 5);
|
||||||
|
let want = fresh.search(query, 5);
|
||||||
|
assert_eq!(got.len(), want.len(), "seed {seed} step {step} {query:?}");
|
||||||
|
for (g, w) in got.iter().zip(&want) {
|
||||||
|
assert_eq!(
|
||||||
|
g.0, w.0,
|
||||||
|
"seed {seed} step {step} {query:?}: {got:?} vs {want:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(g.1 - w.1).abs() < 1e-5,
|
||||||
|
"seed {seed} step {step} {query:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scores_is_the_unranked_form_of_a_full_search() {
|
||||||
|
let mut state = 99u64;
|
||||||
|
let docs: Vec<String> = (0..200).map(|_| random_doc(&mut state)).collect();
|
||||||
|
let tombstones: Vec<u8> = (0..200).map(|i| u8::from(i % 7 == 0)).collect();
|
||||||
|
let index = BM25Index::build(&docs, &tombstones);
|
||||||
|
for query in ["alpha", "beta gamma x1", "missing", ""] {
|
||||||
|
let mut all = index.scores(query);
|
||||||
|
all.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||||
|
assert_eq!(all, index.search(query, docs.len()), "{query:?}");
|
||||||
|
assert!(all.iter().all(|(id, _)| tombstones[*id] == 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stemming_conflates_inflections_of_the_same_word() {
|
||||||
|
let stem_of = |w: &str| tokenize_with(w, TokenFilter::Stemmed).pop().unwrap();
|
||||||
|
// Pairs that should meet.
|
||||||
|
for (a, b) in [
|
||||||
|
("running", "runs"),
|
||||||
|
("trained", "training"),
|
||||||
|
("miles", "mile"),
|
||||||
|
("studies", "studied"),
|
||||||
|
("mentioned", "mentioning"),
|
||||||
|
("classes", "class"),
|
||||||
|
("planned", "planning"),
|
||||||
|
] {
|
||||||
|
assert_eq!(stem_of(a), stem_of(b), "{a} / {b} should share a stem");
|
||||||
|
}
|
||||||
|
// Pairs that must stay apart. Note which pairs are deliberately absent:
|
||||||
|
// "bed"/"bedding" and "gas"/"gassed" both collapse to one stem, which
|
||||||
|
// is what Porter does too and is right — they are related words.
|
||||||
|
for (a, b) in [
|
||||||
|
("universe", "university"),
|
||||||
|
("business", "busy"),
|
||||||
|
("this", "thing"),
|
||||||
|
] {
|
||||||
|
assert_ne!(stem_of(a), stem_of(b), "{a} / {b} must not be conflated");
|
||||||
|
}
|
||||||
|
// Short words and non-inflections are left alone.
|
||||||
|
for word in ["run", "bus", "is", "his", "data", "gas"] {
|
||||||
|
assert_eq!(stem_of(word), word, "{word} should be untouched");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stemming_is_off_by_default_and_applied_consistently() {
|
||||||
|
assert_eq!(tokenize("Running miles"), ["running", "miles"]);
|
||||||
|
assert_eq!(
|
||||||
|
tokenize_with("Running miles", TokenFilter::Stemmed),
|
||||||
|
["run", "mile"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// A query inflected differently from the document still matches.
|
||||||
|
let docs = vec!["I ran while training for the marathon".to_string()];
|
||||||
|
let plain = BM25Index::build_with(&docs, &[0], TokenFilter::Plain);
|
||||||
|
let stemmed = BM25Index::build_with(&docs, &[0], TokenFilter::Stemmed);
|
||||||
|
assert!(plain.search("trains", 1).is_empty());
|
||||||
|
assert_eq!(stemmed.search("trains", 1).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ties_break_towards_the_lower_doc_id() {
|
||||||
|
let docs: Vec<String> = (0..6).map(|_| "same text".to_string()).collect();
|
||||||
|
let index = BM25Index::build(&docs, &[0; 6]);
|
||||||
|
let ids: Vec<usize> = index.search("same", 3).into_iter().map(|r| r.0).collect();
|
||||||
|
assert_eq!(ids, [0, 1, 2]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,145 @@
|
|||||||
//! In-memory cache for memory entries, sessions, and knowledge graph.
|
//! In-memory cache for memory entries, sessions, and knowledge graph.
|
||||||
|
|
||||||
use crate::vector_search;
|
use crate::vector_search;
|
||||||
|
use clawhdf5_format::float16::round_to_f16;
|
||||||
|
|
||||||
|
/// 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: Vec<Vec<f32>>,
|
pub embeddings: Embeddings,
|
||||||
/// `embeddings` flattened into one contiguous `[N × embedding_dim]`
|
|
||||||
/// buffer, maintained incrementally alongside `embeddings` (push/update/
|
|
||||||
/// compact) so BLAS/Accelerate batch search can read it directly instead
|
|
||||||
/// of re-flattening the whole corpus on every query.
|
|
||||||
pub embeddings_flat: Vec<f32>,
|
|
||||||
pub source_channels: Vec<String>,
|
pub source_channels: Vec<String>,
|
||||||
pub timestamps: Vec<f64>,
|
pub timestamps: Vec<f64>,
|
||||||
pub session_ids: Vec<String>,
|
pub session_ids: Vec<String>,
|
||||||
@@ -22,14 +150,18 @@ pub struct MemoryCache {
|
|||||||
pub norms: Vec<f32>,
|
pub norms: Vec<f32>,
|
||||||
/// Hebbian activation weights (default 1.0 per entry).
|
/// Hebbian activation weights (default 1.0 per entry).
|
||||||
pub activation_weights: Vec<f32>,
|
pub activation_weights: Vec<f32>,
|
||||||
|
/// Round every embedding to IEEE half precision as it enters the cache,
|
||||||
|
/// so the cache holds exactly what a `float16` store writes to disk. Set
|
||||||
|
/// it with [`MemoryCache::set_half_precision`], which also rounds the
|
||||||
|
/// rows already held.
|
||||||
|
pub half_precision: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryCache {
|
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: Vec::new(),
|
embeddings: Embeddings::new(embedding_dim),
|
||||||
embeddings_flat: Vec::new(),
|
|
||||||
source_channels: Vec::new(),
|
source_channels: Vec::new(),
|
||||||
timestamps: Vec::new(),
|
timestamps: Vec::new(),
|
||||||
session_ids: Vec::new(),
|
session_ids: Vec::new(),
|
||||||
@@ -38,18 +170,52 @@ impl MemoryCache {
|
|||||||
embedding_dim,
|
embedding_dim,
|
||||||
norms: Vec::new(),
|
norms: Vec::new(),
|
||||||
activation_weights: Vec::new(),
|
activation_weights: Vec::new(),
|
||||||
|
half_precision: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild `embeddings_flat` from `embeddings` from scratch. Callers that
|
/// Switch half-precision rounding on or off. Turning it on rounds every
|
||||||
/// populate `embeddings` directly (bulk loads) must call this afterward.
|
/// embedding already held (and recomputes norms where one changed) —
|
||||||
pub fn rebuild_flat(&mut self) {
|
/// e.g. a `float16` store whose last checkpoint predates half-precision
|
||||||
self.embeddings_flat.clear();
|
/// storage and so is still `f32` on disk.
|
||||||
self.embeddings_flat
|
pub fn set_half_precision(&mut self, on: bool) {
|
||||||
.reserve(self.embeddings.len() * self.embedding_dim);
|
self.half_precision = on;
|
||||||
for emb in &self.embeddings {
|
if !on {
|
||||||
self.embeddings_flat.extend_from_slice(emb);
|
return;
|
||||||
}
|
}
|
||||||
|
for i in 0..self.embeddings.len() {
|
||||||
|
let row = &self.embeddings[i];
|
||||||
|
if row
|
||||||
|
.iter()
|
||||||
|
.all(|&v| round_to_f16(v).to_bits() == v.to_bits())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rounded: Vec<f32> = row.iter().map(|&v| round_to_f16(v)).collect();
|
||||||
|
self.norms[i] = vector_search::compute_norm(&rounded);
|
||||||
|
self.embeddings.set(i, &rounded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The embedding as the cache will hold it: rounded to half precision
|
||||||
|
/// when [`Self::half_precision`] is on, otherwise unchanged.
|
||||||
|
fn stored_form(&self, mut embedding: Vec<f32>) -> Vec<f32> {
|
||||||
|
if self.half_precision {
|
||||||
|
for v in &mut embedding {
|
||||||
|
*v = round_to_f16(*v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
embedding
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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).
|
||||||
@@ -77,10 +243,10 @@ impl MemoryCache {
|
|||||||
tags: String,
|
tags: String,
|
||||||
) -> usize {
|
) -> usize {
|
||||||
let idx = self.chunks.len();
|
let idx = self.chunks.len();
|
||||||
|
let embedding = self.stored_form(embedding);
|
||||||
let norm = vector_search::compute_norm(&embedding);
|
let norm = vector_search::compute_norm(&embedding);
|
||||||
self.chunks.push(chunk);
|
self.chunks.push(chunk);
|
||||||
self.embeddings_flat.extend_from_slice(&embedding);
|
self.embeddings.push(&embedding);
|
||||||
self.embeddings.push(embedding);
|
|
||||||
self.source_channels.push(source_channel);
|
self.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);
|
||||||
@@ -116,22 +282,10 @@ impl MemoryCache {
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
) {
|
) {
|
||||||
if idx < self.chunks.len() {
|
if idx < self.chunks.len() {
|
||||||
|
let embedding = self.stored_form(embedding);
|
||||||
let norm = vector_search::compute_norm(&embedding);
|
let norm = vector_search::compute_norm(&embedding);
|
||||||
self.chunks[idx] = chunk;
|
self.chunks[idx] = chunk;
|
||||||
let dim = self.embedding_dim;
|
self.embeddings.set(idx, &embedding);
|
||||||
let flat_start = idx * dim;
|
|
||||||
let matches_dim =
|
|
||||||
embedding.len() == dim && flat_start + dim <= self.embeddings_flat.len();
|
|
||||||
self.embeddings[idx] = embedding;
|
|
||||||
if matches_dim {
|
|
||||||
self.embeddings_flat[flat_start..flat_start + dim]
|
|
||||||
.copy_from_slice(&self.embeddings[idx]);
|
|
||||||
} else {
|
|
||||||
// Embedding length doesn't match embedding_dim (shouldn't
|
|
||||||
// happen in practice) — fall back to a full rebuild rather
|
|
||||||
// than leave embeddings_flat misaligned with embeddings.
|
|
||||||
self.rebuild_flat();
|
|
||||||
}
|
|
||||||
self.source_channels[idx] = source_channel;
|
self.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;
|
||||||
@@ -183,7 +337,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].clone());
|
new_embeddings.push(self.embeddings[i].to_vec());
|
||||||
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());
|
||||||
@@ -196,7 +350,8 @@ 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 = new_embeddings;
|
self.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;
|
||||||
@@ -204,16 +359,14 @@ impl MemoryCache {
|
|||||||
self.tombstones = new_tombstones;
|
self.tombstones = new_tombstones;
|
||||||
self.norms = new_norms;
|
self.norms = new_norms;
|
||||||
self.activation_weights = new_activation_weights;
|
self.activation_weights = new_activation_weights;
|
||||||
self.rebuild_flat();
|
|
||||||
|
|
||||||
(removed, index_map)
|
(removed, index_map)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flatten all embeddings into a single Vec<f32> for HDF5 storage.
|
/// All embeddings as one owned `[N x dim]` buffer, for HDF5 storage.
|
||||||
/// `embeddings_flat` is already maintained incrementally, so this just
|
/// Prefer [`MemoryCache::flat_embeddings`] where a borrow will do.
|
||||||
/// clones it — kept as a method for callers that want an owned copy.
|
pub fn flat_embeddings_owned(&self) -> Vec<f32> {
|
||||||
pub fn flat_embeddings(&self) -> Vec<f32> {
|
self.embeddings.as_flat().to_vec()
|
||||||
self.embeddings_flat.clone()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +377,7 @@ mod tests {
|
|||||||
/// `embeddings_flat` must always equal a from-scratch flatten of `embeddings`.
|
/// `embeddings_flat` must always equal a from-scratch flatten of `embeddings`.
|
||||||
fn assert_flat_in_sync(cache: &MemoryCache) {
|
fn assert_flat_in_sync(cache: &MemoryCache) {
|
||||||
let expected: Vec<f32> = cache.embeddings.iter().flatten().copied().collect();
|
let expected: Vec<f32> = cache.embeddings.iter().flatten().copied().collect();
|
||||||
assert_eq!(cache.embeddings_flat, expected);
|
assert_eq!(cache.embeddings.as_flat(), expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -247,7 +400,10 @@ mod tests {
|
|||||||
String::new(),
|
String::new(),
|
||||||
);
|
);
|
||||||
assert_flat_in_sync(&cache);
|
assert_flat_in_sync(&cache);
|
||||||
assert_eq!(cache.embeddings_flat, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
assert_eq!(
|
||||||
|
cache.embeddings.as_flat(),
|
||||||
|
vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -279,7 +435,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_flat_in_sync(&cache);
|
assert_flat_in_sync(&cache);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cache.embeddings_flat,
|
cache.embeddings.as_flat(),
|
||||||
vec![7.0, 8.0, 9.0, 4.0, 5.0, 6.0],
|
vec![7.0, 8.0, 9.0, 4.0, 5.0, 6.0],
|
||||||
"update must overwrite the correct flat slice, not just append"
|
"update must overwrite the correct flat slice, not just append"
|
||||||
);
|
);
|
||||||
@@ -315,14 +471,71 @@ mod tests {
|
|||||||
cache.mark_deleted(1);
|
cache.mark_deleted(1);
|
||||||
cache.compact();
|
cache.compact();
|
||||||
assert_flat_in_sync(&cache);
|
assert_flat_in_sync(&cache);
|
||||||
assert_eq!(cache.embeddings_flat, vec![1.0, 1.0, 3.0, 3.0]);
|
assert_eq!(cache.embeddings.as_flat(), vec![1.0, 1.0, 3.0, 3.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rebuild_flat_matches_manual_flatten() {
|
fn rebuild_flat_matches_manual_flatten() {
|
||||||
let mut cache = MemoryCache::new(2);
|
let mut cache = MemoryCache::new(2);
|
||||||
cache.embeddings = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
|
cache
|
||||||
cache.rebuild_flat();
|
.embeddings
|
||||||
assert_eq!(cache.embeddings_flat, vec![1.0, 2.0, 3.0, 4.0]);
|
.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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_half_precision_rounds_existing_rows_and_their_norms() {
|
||||||
|
// A store with float16 set whose checkpoint is still f32 on disk
|
||||||
|
// loads full-precision rows; switching rounding on must bring them to
|
||||||
|
// exactly what the next checkpoint will write.
|
||||||
|
let mut cache = MemoryCache::new(3);
|
||||||
|
cache.push(
|
||||||
|
"a".into(),
|
||||||
|
vec![0.1, 0.2, 0.3],
|
||||||
|
"c".into(),
|
||||||
|
0.0,
|
||||||
|
"s".into(),
|
||||||
|
"".into(),
|
||||||
|
);
|
||||||
|
cache.push(
|
||||||
|
"b".into(),
|
||||||
|
vec![0.5, 0.25, 1.0],
|
||||||
|
"c".into(),
|
||||||
|
0.0,
|
||||||
|
"s".into(),
|
||||||
|
"".into(),
|
||||||
|
);
|
||||||
|
let exact_norm = cache.norms[0];
|
||||||
|
|
||||||
|
cache.set_half_precision(true);
|
||||||
|
let row0: Vec<f32> = [0.1f32, 0.2, 0.3]
|
||||||
|
.iter()
|
||||||
|
.map(|&v| round_to_f16(v))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(&cache.embeddings[0], row0.as_slice());
|
||||||
|
assert_eq!(cache.norms[0], vector_search::compute_norm(&row0));
|
||||||
|
assert_ne!(cache.norms[0], exact_norm);
|
||||||
|
// Already representable: untouched.
|
||||||
|
assert_eq!(&cache.embeddings[1], &[0.5, 0.25, 1.0]);
|
||||||
|
|
||||||
|
// New rows are rounded as they arrive, and updates too.
|
||||||
|
cache.push(
|
||||||
|
"c".into(),
|
||||||
|
vec![0.1, 0.0, 0.0],
|
||||||
|
"c".into(),
|
||||||
|
0.0,
|
||||||
|
"s".into(),
|
||||||
|
"".into(),
|
||||||
|
);
|
||||||
|
assert_eq!(cache.embeddings[2][0], round_to_f16(0.1));
|
||||||
|
cache.update(
|
||||||
|
2,
|
||||||
|
"c".into(),
|
||||||
|
vec![0.3, 0.0, 0.0],
|
||||||
|
"c".into(),
|
||||||
|
0.0,
|
||||||
|
"s".into(),
|
||||||
|
);
|
||||||
|
assert_eq!(cache.embeddings[2][0], round_to_f16(0.3));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,9 +144,44 @@ pub struct ConsolidationStats {
|
|||||||
|
|
||||||
pub struct ImportanceScorer;
|
pub struct ImportanceScorer;
|
||||||
|
|
||||||
|
/// Sum of squares, in 8-wide lanes so it vectorises.
|
||||||
|
fn sum_of_squares(a: &[f32]) -> f32 {
|
||||||
|
let (blocks, tail) = a.as_chunks::<8>();
|
||||||
|
let mut acc = [0.0f32; 8];
|
||||||
|
for b in blocks {
|
||||||
|
for i in 0..8 {
|
||||||
|
acc[i] += b[i] * b[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acc.iter().sum::<f32>() + tail.iter().map(|x| x * x).sum::<f32>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(a · b, |b|²)` in one pass over equal-length slices, in 8-wide lanes.
|
||||||
|
fn dot_and_norm2(a: &[f32], b: &[f32]) -> (f32, f32) {
|
||||||
|
let (a_blocks, a_tail) = a.as_chunks::<8>();
|
||||||
|
let (b_blocks, b_tail) = b.as_chunks::<8>();
|
||||||
|
let mut dot = [0.0f32; 8];
|
||||||
|
let mut nb = [0.0f32; 8];
|
||||||
|
for (x, y) in a_blocks.iter().zip(b_blocks) {
|
||||||
|
for i in 0..8 {
|
||||||
|
dot[i] += x[i] * y[i];
|
||||||
|
nb[i] += y[i] * y[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut d = dot.iter().sum::<f32>();
|
||||||
|
let mut n = nb.iter().sum::<f32>();
|
||||||
|
for (x, y) in a_tail.iter().zip(b_tail) {
|
||||||
|
d += x * y;
|
||||||
|
n += y * y;
|
||||||
|
}
|
||||||
|
(d, n)
|
||||||
|
}
|
||||||
|
|
||||||
impl ImportanceScorer {
|
impl ImportanceScorer {
|
||||||
/// Cosine similarity between two embedding slices.
|
/// Cosine similarity between two embedding slices.
|
||||||
/// Returns 0.0 if either norm is zero.
|
/// Returns 0.0 if either norm is zero. The reference that
|
||||||
|
/// [`Self::score_surprise`] is tested against.
|
||||||
|
#[cfg(test)]
|
||||||
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||||
let len = a.len().min(b.len());
|
let len = a.len().min(b.len());
|
||||||
if len == 0 {
|
if len == 0 {
|
||||||
@@ -167,13 +202,54 @@ 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.
|
||||||
|
///
|
||||||
|
/// Same result as the reference cosine similarity against each record, but the
|
||||||
|
/// new embedding's norm is computed once rather than per record, each
|
||||||
|
/// record costs one fused pass (dot product and its norm together) rather
|
||||||
|
/// than three, and a large working set is scored in parallel. Every insert
|
||||||
|
/// scores against the whole working tier, so this is what an unbounded
|
||||||
|
/// working tier pays for: at 100K records it was the difference between a
|
||||||
|
/// benchmark finishing and not (`BENCHMARKS.md`, "Consolidation Efficiency").
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
let query_norm2 = sum_of_squares(embedding);
|
||||||
|
let similarity = |r: &&MemoryRecord| -> f32 {
|
||||||
|
let other = &r.embedding;
|
||||||
|
let len = embedding.len().min(other.len());
|
||||||
|
if len == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let (dot, other_norm2) = dot_and_norm2(&embedding[..len], &other[..len]);
|
||||||
|
// A shorter record compares against the query's matching prefix.
|
||||||
|
let q2 = if len == embedding.len() {
|
||||||
|
query_norm2
|
||||||
|
} else {
|
||||||
|
sum_of_squares(&embedding[..len])
|
||||||
|
};
|
||||||
|
if q2 == 0.0 || other_norm2 == 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
dot / (q2.sqrt() * other_norm2.sqrt())
|
||||||
|
};
|
||||||
|
#[cfg(feature = "parallel")]
|
||||||
|
let max_sim = if existing_memories.len() >= 4096 {
|
||||||
|
use rayon::prelude::*;
|
||||||
|
existing_memories
|
||||||
|
.par_iter()
|
||||||
|
.map(similarity)
|
||||||
|
.reduce(|| f32::NEG_INFINITY, f32::max)
|
||||||
|
} else {
|
||||||
|
existing_memories
|
||||||
|
.iter()
|
||||||
|
.map(similarity)
|
||||||
|
.fold(f32::NEG_INFINITY, f32::max)
|
||||||
|
};
|
||||||
|
#[cfg(not(feature = "parallel"))]
|
||||||
let max_sim = existing_memories
|
let max_sim = existing_memories
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| Self::cosine_similarity(embedding, &r.embedding))
|
.map(similarity)
|
||||||
.fold(f32::NEG_INFINITY, f32::max);
|
.fold(f32::NEG_INFINITY, f32::max);
|
||||||
(1.0 - max_sim).clamp(0.0, 1.0)
|
(1.0 - max_sim).clamp(0.0, 1.0)
|
||||||
}
|
}
|
||||||
@@ -471,6 +547,54 @@ impl ConsolidationEngine {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn score_surprise_matches_the_reference_cosine() {
|
||||||
|
let mut x = 0x2545_F491_4F6C_DD1Du64;
|
||||||
|
let mut next = || {
|
||||||
|
x ^= x << 13;
|
||||||
|
x ^= x >> 7;
|
||||||
|
x ^= x << 17;
|
||||||
|
(x >> 40) as f32 / (1u64 << 24) as f32 - 0.5
|
||||||
|
};
|
||||||
|
let make = |id: u64, v: Vec<f32>| MemoryRecord {
|
||||||
|
id,
|
||||||
|
chunk: String::new(),
|
||||||
|
embedding: v,
|
||||||
|
tier: MemoryTier::Working,
|
||||||
|
importance: 0.0,
|
||||||
|
access_count: 0,
|
||||||
|
last_accessed: 0.0,
|
||||||
|
created_at: 0.0,
|
||||||
|
source: MemorySource::User,
|
||||||
|
};
|
||||||
|
// Ordinary rows, a shorter one, an empty one and a zero vector; and
|
||||||
|
// enough rows to take the parallel path too.
|
||||||
|
for n in [5usize, 5000] {
|
||||||
|
let mut recs: Vec<MemoryRecord> = (0..n as u64)
|
||||||
|
.map(|i| make(i, (0..37).map(|_| next()).collect()))
|
||||||
|
.collect();
|
||||||
|
recs.push(make(9_000, (0..20).map(|_| next()).collect()));
|
||||||
|
recs.push(make(9_001, Vec::new()));
|
||||||
|
recs.push(make(9_002, vec![0.0; 37]));
|
||||||
|
let refs: Vec<&MemoryRecord> = recs.iter().collect();
|
||||||
|
for _ in 0..5 {
|
||||||
|
let q: Vec<f32> = (0..37).map(|_| next()).collect();
|
||||||
|
let expected = (1.0
|
||||||
|
- refs
|
||||||
|
.iter()
|
||||||
|
.map(|r| ImportanceScorer::cosine_similarity(&q, &r.embedding))
|
||||||
|
.fold(f32::NEG_INFINITY, f32::max))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
let got = ImportanceScorer::score_surprise(&q, &refs);
|
||||||
|
assert!((got - expected).abs() < 1e-5, "n={n}: {got} vs {expected}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
ImportanceScorer::score_surprise(&[0.0; 4], &[&make(1, vec![1.0; 4])]),
|
||||||
|
1.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: build a simple normalised embedding of given dimension.
|
// Helper: build a simple normalised embedding of given dimension.
|
||||||
fn unit_vec(dim: usize, hot: usize) -> Vec<f32> {
|
fn unit_vec(dim: usize, hot: usize) -> Vec<f32> {
|
||||||
let mut v = vec![0.0f32; dim];
|
let mut v = vec![0.0f32; dim];
|
||||||
@@ -563,7 +687,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 = vec![MemoryRecord {
|
let existing = [MemoryRecord {
|
||||||
id: 0,
|
id: 0,
|
||||||
chunk: "existing".to_string(),
|
chunk: "existing".to_string(),
|
||||||
embedding: emb.clone(),
|
embedding: emb.clone(),
|
||||||
@@ -603,23 +727,20 @@ 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("word")
|
let fifty_words = std::iter::repeat_n("word", 50)
|
||||||
.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("word")
|
let hundred_words = std::iter::repeat_n("word", 100)
|
||||||
.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("word")
|
let two_hundred = std::iter::repeat_n("word", 200)
|
||||||
.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);
|
||||||
@@ -693,9 +814,11 @@ mod tests {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
#[test]
|
#[test]
|
||||||
fn test_consolidate_eviction_working() {
|
fn test_consolidate_eviction_working() {
|
||||||
let mut cfg = ConsolidationConfig::default();
|
let cfg = ConsolidationConfig {
|
||||||
cfg.working_capacity = 3;
|
working_capacity: 3,
|
||||||
cfg.working_to_episodic_threshold = 2.0; // never promote in this test
|
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.
|
||||||
@@ -800,7 +923,12 @@ 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("chunk".to_string(), unit_vec(4, 0), UntrustedSource::User, 0.0);
|
let id = engine.add_memory(
|
||||||
|
"chunk".to_string(),
|
||||||
|
unit_vec(4, 0),
|
||||||
|
UntrustedSource::User,
|
||||||
|
0.0,
|
||||||
|
);
|
||||||
|
|
||||||
engine.access_memory(id, 5000.0);
|
engine.access_memory(id, 5000.0);
|
||||||
let rec = engine.get_by_id(id).unwrap();
|
let rec = engine.get_by_id(id).unwrap();
|
||||||
@@ -853,9 +981,11 @@ mod tests {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
#[test]
|
#[test]
|
||||||
fn test_consolidate_episodic_eviction() {
|
fn test_consolidate_episodic_eviction() {
|
||||||
let mut cfg = ConsolidationConfig::default();
|
let cfg = ConsolidationConfig {
|
||||||
cfg.episodic_capacity = 3;
|
episodic_capacity: 3,
|
||||||
cfg.working_to_episodic_threshold = 2.0; // never auto-promote from Working
|
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.
|
||||||
|
|||||||
@@ -777,8 +777,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tech_disabled() {
|
fn test_tech_disabled() {
|
||||||
let mut config = ExtractorConfig::default();
|
let config = ExtractorConfig {
|
||||||
config.extract_technology = false;
|
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!(
|
||||||
@@ -847,8 +849,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_date_disabled() {
|
fn test_date_disabled() {
|
||||||
let mut config = ExtractorConfig::default();
|
let config = ExtractorConfig {
|
||||||
config.extract_dates = false;
|
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!(
|
||||||
@@ -981,8 +985,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_confidence_filter() {
|
fn test_confidence_filter() {
|
||||||
let mut config = ExtractorConfig::default();
|
let config = ExtractorConfig {
|
||||||
config.min_confidence = 0.95;
|
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.");
|
||||||
@@ -1002,7 +1008,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.iter().map(|s| *s).collect::<Vec<_>>());
|
let entities = e.extract_batch(&texts);
|
||||||
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");
|
||||||
}
|
}
|
||||||
@@ -1011,7 +1017,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.iter().map(|s| *s).collect::<Vec<_>>());
|
let entities = e.extract_batch(&texts);
|
||||||
assert!(
|
assert!(
|
||||||
entities
|
entities
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -28,39 +28,69 @@ 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: &[Vec<f32>],
|
vectors: &(impl crate::vector_search::VectorSet + Sync + ?Sized),
|
||||||
_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.
|
||||||
// Use parallel search when rayon feature is enabled and vector count > 10K.
|
let vec_scores = exact_vector_scores(query_embedding, vectors, tombstones);
|
||||||
let vec_scores = {
|
let kw_scores = bm25_index.scores(query_text);
|
||||||
#[cfg(feature = "parallel")]
|
|
||||||
{
|
|
||||||
if vectors.len() > 10_000 {
|
|
||||||
vector_search::parallel_cosine_batch(
|
|
||||||
query_embedding,
|
|
||||||
vectors,
|
|
||||||
tombstones,
|
|
||||||
vectors.len(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "parallel"))]
|
|
||||||
{
|
|
||||||
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let kw_scores = bm25_index.search(query_text, vectors.len());
|
|
||||||
|
|
||||||
merge_vector_keyword(vec_scores, kw_scores, vector_weight, keyword_weight, k)
|
fuse(vec_scores, kw_scores, fusion, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cosine similarity of `query_embedding` to every vector whose `skip` byte is
|
||||||
|
/// 0 (a tombstone, or any other exclusion mask). Parallel above 10K vectors
|
||||||
|
/// when the `parallel` feature is on.
|
||||||
|
pub fn exact_vector_scores(
|
||||||
|
query_embedding: &[f32],
|
||||||
|
vectors: &(impl crate::vector_search::VectorSet + Sync + ?Sized),
|
||||||
|
skip: &[u8],
|
||||||
|
) -> Vec<(usize, f32)> {
|
||||||
|
#[cfg(feature = "parallel")]
|
||||||
|
{
|
||||||
|
if vectors.count() > 10_000 {
|
||||||
|
return vector_search::parallel_cosine_batch(
|
||||||
|
query_embedding,
|
||||||
|
vectors,
|
||||||
|
skip,
|
||||||
|
vectors.count(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vector_search::cosine_similarity_batch(query_embedding, vectors, skip)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge pre-computed vector-similarity and keyword scores into a single ranking.
|
/// Merge pre-computed vector-similarity and keyword scores into a single ranking.
|
||||||
@@ -76,29 +106,120 @@ pub fn merge_vector_keyword(
|
|||||||
keyword_weight: f32,
|
keyword_weight: f32,
|
||||||
k: usize,
|
k: usize,
|
||||||
) -> Vec<(usize, f32)> {
|
) -> Vec<(usize, f32)> {
|
||||||
// Normalize each set to [0, 1].
|
fuse(
|
||||||
let vec_normalized = normalize_scores(&vec_scores);
|
vec_scores,
|
||||||
let kw_normalized = normalize_scores(&kw_scores);
|
kw_scores,
|
||||||
|
Fusion::Weighted {
|
||||||
|
vector: vector_weight,
|
||||||
|
keyword: keyword_weight,
|
||||||
|
},
|
||||||
|
k,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Merge scores with weights.
|
/// How the vector and keyword stages are combined into one ranking.
|
||||||
let mut merged: HashMap<usize, f32> = HashMap::new();
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum Fusion {
|
||||||
|
/// Min-max normalise each stage over its own candidates, then take a
|
||||||
|
/// weighted sum. Uses the *scores*, so a stage that separates its
|
||||||
|
/// candidates sharply keeps that separation — and a stage whose candidates
|
||||||
|
/// are all near-identical contributes little.
|
||||||
|
Weighted {
|
||||||
|
/// Weight on the vector stage.
|
||||||
|
vector: f32,
|
||||||
|
/// Weight on the keyword stage.
|
||||||
|
keyword: f32,
|
||||||
|
},
|
||||||
|
/// Reciprocal rank fusion: each stage contributes `1 / (k + rank)`,
|
||||||
|
/// ignoring score magnitudes entirely. Robust when the two stages'
|
||||||
|
/// scores aren't comparable, at the cost of discarding confidence.
|
||||||
|
Rrf {
|
||||||
|
/// The rank-damping constant; 60 is the value from the original paper.
|
||||||
|
k: f32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
for (idx, score) in &vec_normalized {
|
impl Default for Fusion {
|
||||||
*merged.entry(*idx).or_insert(0.0) += vector_weight * score;
|
fn default() -> Self {
|
||||||
|
DEFAULT_FUSION
|
||||||
}
|
}
|
||||||
for (idx, score) in &kw_normalized {
|
}
|
||||||
*merged.entry(*idx).or_insert(0.0) += keyword_weight * score;
|
|
||||||
|
/// The fusion `hybrid_search` uses unless told otherwise.
|
||||||
|
///
|
||||||
|
/// The weights are not a guess: a sweep of every 0.1 step over the full
|
||||||
|
/// LongMemEval haystack (500 questions, real MiniLM embeddings) found the
|
||||||
|
/// long-standing 0.7/0.3 default *strictly dominated* — 0.4/0.6 is better at
|
||||||
|
/// Hit@1, Hit@5, Hit@10 and MRR, at both turn and session granularity. See
|
||||||
|
/// `BENCHMARKS.md`, "Weight sweep".
|
||||||
|
pub const DEFAULT_FUSION: Fusion = Fusion::Weighted {
|
||||||
|
vector: 0.4,
|
||||||
|
keyword: 0.6,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Combine one ranked candidate list from each stage into a single top-`k`.
|
||||||
|
///
|
||||||
|
/// Neither list need be sorted; both are consumed.
|
||||||
|
pub fn fuse(
|
||||||
|
vec_scores: Vec<(usize, f32)>,
|
||||||
|
kw_scores: Vec<(usize, f32)>,
|
||||||
|
fusion: Fusion,
|
||||||
|
k: usize,
|
||||||
|
) -> Vec<(usize, f32)> {
|
||||||
|
let mut merged: HashMap<usize, f32> = HashMap::new();
|
||||||
|
match fusion {
|
||||||
|
Fusion::Weighted { vector, keyword } => {
|
||||||
|
// Normalize each set to [0, 1].
|
||||||
|
for (idx, score) in &normalize_scores(&vec_scores) {
|
||||||
|
*merged.entry(*idx).or_insert(0.0) += vector * score;
|
||||||
|
}
|
||||||
|
for (idx, score) in &normalize_scores(&kw_scores) {
|
||||||
|
*merged.entry(*idx).or_insert(0.0) += keyword * score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Fusion::Rrf { k: damping } => {
|
||||||
|
for mut stage in [vec_scores, kw_scores] {
|
||||||
|
// Rank 1 is the best score. Ties break by index so a stage's
|
||||||
|
// contribution doesn't depend on the candidate order it
|
||||||
|
// happened to be produced in.
|
||||||
|
stage.sort_by(|a, b| {
|
||||||
|
b.1.partial_cmp(&a.1)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
.then(a.0.cmp(&b.0))
|
||||||
|
});
|
||||||
|
for (rank, (idx, _)) in stage.iter().enumerate() {
|
||||||
|
*merged.entry(*idx).or_insert(0.0) += 1.0 / (damping + (rank + 1) as f32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut results: Vec<(usize, f32)> = merged.into_iter().collect();
|
let mut results: Vec<(usize, f32)> = merged.into_iter().collect();
|
||||||
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
// Index tie-break: `merged` is a HashMap, so without it the ties that
|
||||||
results.truncate(k);
|
// survive differ from run to run.
|
||||||
|
let by_score_then_id = |a: &(usize, f32), b: &(usize, f32)| {
|
||||||
|
b.1.partial_cmp(&a.1)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
.then(a.0.cmp(&b.0))
|
||||||
|
};
|
||||||
|
// Only the top k are wanted: partition them out, then order just those,
|
||||||
|
// instead of sorting every candidate (the keyword side can be the corpus).
|
||||||
|
if k == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if results.len() > k {
|
||||||
|
results.select_nth_unstable_by(k - 1, by_score_then_id);
|
||||||
|
results.truncate(k);
|
||||||
|
}
|
||||||
|
results.sort_by(by_score_then_id);
|
||||||
results
|
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, returns 0.0 for each entry.
|
/// If all scores are identical there is no spread to normalise: each entry
|
||||||
|
/// gets 1.0 when that score is positive (all equally the best match) and 0.0
|
||||||
|
/// otherwise (nothing matched).
|
||||||
fn normalize_scores(scores: &[(usize, f32)]) -> Vec<(usize, f32)> {
|
fn normalize_scores(scores: &[(usize, f32)]) -> Vec<(usize, f32)> {
|
||||||
if scores.is_empty() {
|
if scores.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -112,7 +233,13 @@ 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 {
|
||||||
return scores.iter().map(|(idx, _)| (*idx, 0.0)).collect();
|
// All candidates scored the same (including the single-candidate
|
||||||
|
// case), so min-max has no spread to work with. They are all equally
|
||||||
|
// the best match if that score is positive, and all non-matches
|
||||||
|
// otherwise. This used to return 0.0 unconditionally, which erased a
|
||||||
|
// lone perfect match from the fused score.
|
||||||
|
let level = if max > 0.0 { 1.0 } else { 0.0 };
|
||||||
|
return scores.iter().map(|(idx, _)| (*idx, level)).collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
scores
|
scores
|
||||||
@@ -146,7 +273,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: &[Vec<f32>],
|
vectors: &(impl crate::vector_search::VectorSet + Sync + ?Sized),
|
||||||
_chunks: &[String],
|
_chunks: &[String],
|
||||||
tombstones: &[u8],
|
tombstones: &[u8],
|
||||||
bm25_index: &BM25Index,
|
bm25_index: &BM25Index,
|
||||||
@@ -158,12 +285,12 @@ pub fn rrf_hybrid_search(
|
|||||||
let mut vec_scores = {
|
let mut vec_scores = {
|
||||||
#[cfg(feature = "parallel")]
|
#[cfg(feature = "parallel")]
|
||||||
{
|
{
|
||||||
if vectors.len() > 10_000 {
|
if vectors.count() > 10_000 {
|
||||||
vector_search::parallel_cosine_batch(
|
vector_search::parallel_cosine_batch(
|
||||||
query_embedding,
|
query_embedding,
|
||||||
vectors,
|
vectors,
|
||||||
tombstones,
|
tombstones,
|
||||||
vectors.len(),
|
vectors.count(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
|
vector_search::cosine_similarity_batch(query_embedding, vectors, tombstones)
|
||||||
@@ -174,7 +301,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.len());
|
let mut kw_scores = bm25_index.search(query_text, vectors.count());
|
||||||
|
|
||||||
// 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));
|
||||||
@@ -324,10 +451,80 @@ 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);
|
||||||
// Single score normalizes to 0.0 (range is 0)
|
assert_eq!(result[0].1, 1.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]
|
||||||
|
|||||||
@@ -163,12 +163,13 @@ fn levenshtein(a: &str, b: &str) -> usize {
|
|||||||
/// entities-slice-index map, and an entity-id -> relation-indices map (edges
|
/// entities-slice-index map, and an entity-id -> relation-indices map (edges
|
||||||
/// touching that entity as either source or target).
|
/// touching that entity as either source or target).
|
||||||
///
|
///
|
||||||
/// Built fresh per traversal call rather than cached on `KnowledgeCache`:
|
/// Cached on `KnowledgeCache` and checked against a fingerprint of the graph
|
||||||
/// entities/relations are plain `pub` `Vec`s that get pushed to directly
|
/// on every use ([`graph_fingerprint`]). entities/relations are plain `pub`
|
||||||
/// (e.g. `schema.rs`'s load path bypasses `add_entity`/`add_relation`), so a
|
/// `Vec`s that get changed directly (e.g. `schema.rs`'s load path bypasses
|
||||||
/// persistent index would need extra bookkeeping to avoid drifting stale. A
|
/// `add_entity`/`add_relation`), so the cache cannot rely on being told about
|
||||||
/// one-off O(V+E) build per call is still a large win over the O(V·E) (BFS)
|
/// changes; the fingerprint notices any of them. Rebuilding it on every
|
||||||
/// / O(steps·active·E) (spreading activation) scans it replaces.
|
/// traversal instead made a 2-hop BFS over 1K entities 6.5x slower than the
|
||||||
|
/// scan it replaced (24 -> 155 µs; `BENCHMARKS.md`, "Knowledge Graph").
|
||||||
struct AdjacencyIndex {
|
struct AdjacencyIndex {
|
||||||
entity_index: HashMap<u64, usize>,
|
entity_index: HashMap<u64, usize>,
|
||||||
by_entity: HashMap<u64, Vec<usize>>,
|
by_entity: HashMap<u64, Vec<usize>>,
|
||||||
@@ -204,6 +205,45 @@ impl AdjacencyIndex {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A hash of everything [`AdjacencyIndex`] depends on — each entity's id and
|
||||||
|
/// position, each relation's endpoints and position. One linear pass, no
|
||||||
|
/// allocation: far cheaper than building the index, which hashes the same
|
||||||
|
/// values into two maps.
|
||||||
|
fn graph_fingerprint(entities: &[Entity], relations: &[Relation]) -> u64 {
|
||||||
|
// splitmix64-style mixing; order matters, so positions are covered.
|
||||||
|
fn mix(h: u64, v: u64) -> u64 {
|
||||||
|
let mut z = (h ^ v).wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||||
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||||
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||||
|
z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
let mut h = mix(entities.len() as u64, relations.len() as u64);
|
||||||
|
for e in entities {
|
||||||
|
h = mix(h, e.id);
|
||||||
|
}
|
||||||
|
for r in relations {
|
||||||
|
h = mix(mix(h, r.src), r.tgt);
|
||||||
|
}
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cached [`AdjacencyIndex`] and the fingerprint it was built for.
|
||||||
|
/// Cloning a `KnowledgeCache` starts the clone with an empty cache.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct AdjacencyCache(std::sync::Mutex<Option<(u64, std::sync::Arc<AdjacencyIndex>)>>);
|
||||||
|
|
||||||
|
impl Clone for AdjacencyCache {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for AdjacencyCache {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("AdjacencyCache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// KnowledgeCache
|
// KnowledgeCache
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -216,6 +256,7 @@ pub struct KnowledgeCache {
|
|||||||
pub alias_strings: Vec<String>,
|
pub alias_strings: Vec<String>,
|
||||||
pub alias_entity_ids: Vec<i64>,
|
pub alias_entity_ids: Vec<i64>,
|
||||||
next_entity_id: u64,
|
next_entity_id: u64,
|
||||||
|
adjacency: AdjacencyCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KnowledgeCache {
|
impl KnowledgeCache {
|
||||||
@@ -226,6 +267,7 @@ impl KnowledgeCache {
|
|||||||
alias_strings: Vec::new(),
|
alias_strings: Vec::new(),
|
||||||
alias_entity_ids: Vec::new(),
|
alias_entity_ids: Vec::new(),
|
||||||
next_entity_id: 0,
|
next_entity_id: 0,
|
||||||
|
adjacency: AdjacencyCache::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,9 +278,29 @@ impl KnowledgeCache {
|
|||||||
alias_strings: Vec::new(),
|
alias_strings: Vec::new(),
|
||||||
alias_entity_ids: Vec::new(),
|
alias_entity_ids: Vec::new(),
|
||||||
next_entity_id: next_id,
|
next_entity_id: next_id,
|
||||||
|
adjacency: AdjacencyCache::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The adjacency index for the graph as it is now: the cached one if the
|
||||||
|
/// graph's fingerprint still matches, otherwise rebuilt and cached.
|
||||||
|
fn adjacency_index(&self) -> std::sync::Arc<AdjacencyIndex> {
|
||||||
|
let fp = graph_fingerprint(&self.entities, &self.relations);
|
||||||
|
let mut slot = self
|
||||||
|
.adjacency
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
if let Some((cached_fp, idx)) = slot.as_ref()
|
||||||
|
&& *cached_fp == fp
|
||||||
|
{
|
||||||
|
return idx.clone();
|
||||||
|
}
|
||||||
|
let idx = std::sync::Arc::new(AdjacencyIndex::build(&self.entities, &self.relations));
|
||||||
|
*slot = Some((fp, idx.clone()));
|
||||||
|
idx
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Entity management
|
// Entity management
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -397,7 +459,7 @@ 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 idx = self.adjacency_index();
|
||||||
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();
|
||||||
@@ -502,7 +564,7 @@ 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 idx = self.adjacency_index();
|
||||||
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.
|
||||||
@@ -631,6 +693,51 @@ impl Default for KnowledgeCache {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cached_adjacency_sees_direct_changes_to_the_graph() {
|
||||||
|
// The index is cached across traversals, but entities/relations are
|
||||||
|
// pub Vecs anyone can edit; every kind of edit must be seen.
|
||||||
|
let mut kg = KnowledgeCache::new();
|
||||||
|
let a = kg.add_entity("a", "t", -1);
|
||||||
|
let b = kg.add_entity("b", "t", -1);
|
||||||
|
let c = kg.add_entity("c", "t", -1);
|
||||||
|
kg.add_relation(a, b, "r", 1.0);
|
||||||
|
let ids = |kg: &KnowledgeCache| -> Vec<u64> {
|
||||||
|
let mut v: Vec<u64> = kg.bfs_neighbors(a, 3).iter().map(|(e, _)| e.id).collect();
|
||||||
|
v.sort();
|
||||||
|
v
|
||||||
|
};
|
||||||
|
assert_eq!(ids(&kg), vec![b]);
|
||||||
|
assert_eq!(ids(&kg), vec![b], "cached index reused");
|
||||||
|
|
||||||
|
// Pushed directly, bypassing add_relation.
|
||||||
|
kg.relations.push(Relation {
|
||||||
|
src: b,
|
||||||
|
tgt: c,
|
||||||
|
..Relation::default()
|
||||||
|
});
|
||||||
|
assert_eq!(ids(&kg), vec![b, c]);
|
||||||
|
|
||||||
|
// Rewired in place: same lengths, different edge.
|
||||||
|
kg.relations[1].tgt = a;
|
||||||
|
assert_eq!(ids(&kg), vec![b]);
|
||||||
|
|
||||||
|
// Removed and replaced: same lengths again.
|
||||||
|
kg.relations.pop();
|
||||||
|
kg.relations.push(Relation {
|
||||||
|
src: a,
|
||||||
|
tgt: c,
|
||||||
|
..Relation::default()
|
||||||
|
});
|
||||||
|
assert_eq!(ids(&kg), vec![b, c]);
|
||||||
|
let act: Vec<u64> = kg
|
||||||
|
.spreading_activation(&[a], 0.5, 0.0, 2)
|
||||||
|
.iter()
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect();
|
||||||
|
assert!(act.contains(&c));
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Original tests — must remain passing
|
// Original tests — must remain passing
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
+1329
-232
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
|||||||
//! OpenClaw Integration Layer.
|
//! A Markdown-oriented memory backend over [`crate::HDF5Memory`].
|
||||||
//!
|
//!
|
||||||
//! Bridge between OpenClaw agent gateway (Markdown + sqlite-vec) and the
|
//! Named for OpenClaw, whose workspace memory is Markdown, but **not an
|
||||||
//! clawhdf5 HDF5-backed memory backend. Provides:
|
//! OpenClaw plugin**: nothing here registers with OpenClaw, and the
|
||||||
|
//! integration it was written for never worked (see `docs/openclaw.md`).
|
||||||
|
//! Provides:
|
||||||
//!
|
//!
|
||||||
//! - [`MemoryBackend`] — the trait OpenClaw implements against.
|
//! - [`MemoryBackend`] — search / read back / write / ingest / export.
|
||||||
//! - [`ClawhdfBackend`] — concrete HDF5-backed implementation.
|
//! - [`ClawhdfBackend`] — the HDF5-backed implementation.
|
||||||
//! - [`MarkdownParser`] — splits Markdown into [`MarkdownSection`] records.
|
//! - [`MarkdownParser`] — splits Markdown into [`MarkdownSection`] records.
|
||||||
//! - [`MarkdownExporter`] — renders sections back to Markdown text.
|
//! - [`MarkdownExporter`] — renders sections back to Markdown text.
|
||||||
|
|
||||||
@@ -13,9 +15,8 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry,
|
AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry, SearchOptions,
|
||||||
confidence::{ConfidenceConfig, ScoredResult, reject_low_confidence},
|
confidence::ConfidenceConfig, reranker::ReRankConfig,
|
||||||
reranker::{ReRankConfig, RerankInput, rerank},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -62,7 +63,8 @@ pub struct BackendStats {
|
|||||||
// MemoryBackend trait
|
// MemoryBackend trait
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Interface that OpenClaw uses to interact with a memory backend.
|
/// A Markdown-oriented memory backend: search, read back by path, write,
|
||||||
|
/// ingest and export.
|
||||||
///
|
///
|
||||||
/// Implementors provide persistent storage, full-text + vector search,
|
/// Implementors provide persistent storage, full-text + vector search,
|
||||||
/// Markdown ingestion / export, and statistics.
|
/// Markdown ingestion / export, and statistics.
|
||||||
@@ -319,7 +321,7 @@ impl MarkdownExporter {
|
|||||||
///
|
///
|
||||||
/// # Path mapping
|
/// # Path mapping
|
||||||
///
|
///
|
||||||
/// OpenClaw addresses memories by file path (e.g. `"memory/user.md"`).
|
/// Memories are addressed by file path (e.g. `"memory/user.md"`).
|
||||||
/// Internally every [`MemoryEntry`] stores the originating path as its
|
/// Internally every [`MemoryEntry`] stores the originating path as its
|
||||||
/// `source_channel`. Section sub-paths are stored as
|
/// `source_channel`. Section sub-paths are stored as
|
||||||
/// `"<path>::<heading>"`.
|
/// `"<path>::<heading>"`.
|
||||||
@@ -422,7 +424,7 @@ impl ClawhdfBackend {
|
|||||||
|
|
||||||
// ── Compaction & Consolidation hooks (7.6) ────────────────────────────
|
// ── Compaction & Consolidation hooks (7.6) ────────────────────────────
|
||||||
|
|
||||||
/// Run a compaction cycle — called by OpenClaw during session compaction.
|
/// Run a compaction cycle (decay, compaction, WAL flush).
|
||||||
///
|
///
|
||||||
/// Sequence:
|
/// Sequence:
|
||||||
/// 1. `tick_session()` — apply Hebbian decay to all activation weights.
|
/// 1. `tick_session()` — apply Hebbian decay to all activation weights.
|
||||||
@@ -466,7 +468,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].clone(),
|
embedding: cache.embeddings[i].to_vec(),
|
||||||
tier: MemoryTier::Working,
|
tier: MemoryTier::Working,
|
||||||
importance: cache.activation_weights[i],
|
importance: cache.activation_weights[i],
|
||||||
access_count: 0,
|
access_count: 0,
|
||||||
@@ -524,67 +526,27 @@ impl ClawhdfBackend {
|
|||||||
|
|
||||||
impl MemoryBackend for ClawhdfBackend {
|
impl MemoryBackend for ClawhdfBackend {
|
||||||
/// Search using hybrid vector + BM25 retrieval, then re-rank and
|
/// Search using hybrid vector + BM25 retrieval, then re-rank and
|
||||||
/// confidence-filter.
|
/// confidence-filter — [`HDF5Memory::search`] with both stages on.
|
||||||
fn search(
|
fn search(
|
||||||
&mut self,
|
&mut self,
|
||||||
query_text: &str,
|
query_text: &str,
|
||||||
query_embedding: &[f32],
|
query_embedding: &[f32],
|
||||||
k: usize,
|
k: usize,
|
||||||
) -> Vec<MemorySearchResult> {
|
) -> Vec<MemorySearchResult> {
|
||||||
// 1. Hybrid retrieval (RRF-blended vector + BM25).
|
let options = SearchOptions::new(k)
|
||||||
let candidates = k.saturating_mul(3).max(10);
|
.with_rerank(self.rerank_config)
|
||||||
let raw = self
|
.with_confidence(self.confidence_config.clone())
|
||||||
.memory
|
.at_time(Self::now_secs());
|
||||||
.hybrid_search(query_embedding, query_text, 0.7, 0.3, candidates);
|
self.memory
|
||||||
|
.search(query_embedding, query_text, &options)
|
||||||
if raw.is_empty() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Self::now_secs();
|
|
||||||
|
|
||||||
// 2. Re-rank using temporal recency, source authority, Hebbian weight.
|
|
||||||
let rerank_inputs: Vec<RerankInput> = raw
|
|
||||||
.iter()
|
|
||||||
.map(|r| RerankInput {
|
|
||||||
index: r.index,
|
|
||||||
timestamp: r.timestamp,
|
|
||||||
source_channel: r.source_channel.clone(),
|
|
||||||
raw_activation: r.activation,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let reranked = rerank(&rerank_inputs, &self.rerank_config, now);
|
|
||||||
|
|
||||||
// 3. Confidence rejection.
|
|
||||||
let scored: Vec<ScoredResult> = reranked
|
|
||||||
.iter()
|
|
||||||
.map(|r| ScoredResult {
|
|
||||||
index: r.index,
|
|
||||||
score: r.combined_score,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let confident = reject_low_confidence(&scored, &self.confidence_config);
|
|
||||||
|
|
||||||
// 4. Map back to MemorySearchResult; preserve raw text via index lookup.
|
|
||||||
let raw_by_idx: HashMap<usize, &crate::SearchResult> =
|
|
||||||
raw.iter().map(|r| (r.index, r)).collect();
|
|
||||||
|
|
||||||
confident
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.take(k)
|
.map(|r| MemorySearchResult {
|
||||||
.filter_map(|sr| {
|
text: r.chunk,
|
||||||
let r = raw_by_idx.get(&sr.index)?;
|
score: r.score,
|
||||||
let path = r.source_channel.clone();
|
path: r.source_channel.clone(),
|
||||||
Some(MemorySearchResult {
|
line_range: None,
|
||||||
text: r.chunk.clone(),
|
timestamp: Some(r.timestamp),
|
||||||
score: sr.score,
|
source: r.source_channel,
|
||||||
path: path.clone(),
|
|
||||||
line_range: None,
|
|
||||||
timestamp: Some(r.timestamp),
|
|
||||||
source: path,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -713,11 +675,13 @@ 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
|
||||||
.embeddings
|
.norms
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(i, emb)| cache.tombstones[*i] == 0 && !emb.is_empty())
|
.filter(|(i, norm)| cache.tombstones[*i] == 0 && **norm > 0.0)
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
let file_size_bytes = std::fs::metadata(&self.hdf5_path)
|
let file_size_bytes = std::fs::metadata(&self.hdf5_path)
|
||||||
@@ -748,6 +712,69 @@ impl MemoryBackend for ClawhdfBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Ephemeral tier methods on ClawhdfBackend
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
impl ClawhdfBackend {
|
||||||
|
/// Enable the ephemeral (in-memory only) working memory tier.
|
||||||
|
pub fn enable_ephemeral(&mut self, config: crate::ephemeral::EphemeralConfig) {
|
||||||
|
self.memory.enable_ephemeral(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a text value in ephemeral memory.
|
||||||
|
///
|
||||||
|
/// Returns an error string if the ephemeral tier has not been enabled.
|
||||||
|
pub fn ephemeral_set(
|
||||||
|
&mut self,
|
||||||
|
key: &str,
|
||||||
|
value: &str,
|
||||||
|
ttl_secs: Option<f64>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
match self.memory.ephemeral_mut() {
|
||||||
|
Some(s) => {
|
||||||
|
s.set_text(key, value, ttl_secs);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err("ephemeral tier not enabled".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a text value from ephemeral memory.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the tier is disabled, the key is absent, or the
|
||||||
|
/// entry has expired.
|
||||||
|
pub fn ephemeral_get(&mut self, key: &str) -> Option<String> {
|
||||||
|
self.memory
|
||||||
|
.ephemeral_mut()?
|
||||||
|
.get_text(key)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a key from ephemeral memory.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the key existed and was removed.
|
||||||
|
pub fn ephemeral_delete(&mut self, key: &str) -> bool {
|
||||||
|
self.memory.ephemeral_mut().is_some_and(|s| s.delete(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a snapshot of ephemeral tier statistics, or `None` if the tier
|
||||||
|
/// is not enabled.
|
||||||
|
pub fn ephemeral_stats(&self) -> Option<crate::ephemeral::EphemeralStats> {
|
||||||
|
self.memory.ephemeral().map(|s| s.stats())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Promote frequently-accessed ephemeral entries to persistent HDF5 storage.
|
||||||
|
///
|
||||||
|
/// Entries with `access_count >= min_access_count` are moved from the
|
||||||
|
/// ephemeral store into the persistent cache. Returns the count promoted.
|
||||||
|
pub fn promote_ephemeral(&mut self, min_access_count: u32) -> Result<usize, String> {
|
||||||
|
self.memory
|
||||||
|
.promote_ephemeral(min_access_count)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Tests
|
// Tests
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -1333,66 +1360,3 @@ 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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 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 *accidental* corruption and trace data lineage.
|
||||||
//! The hash is unkeyed (see [`fnv1a_64`]) — this is not a tamper-evidence or
|
//! The hash is unkeyed (FNV-1a) — this is not a tamper-evidence or
|
||||||
//! authenticity guarantee.
|
//! authenticity guarantee.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -105,6 +105,23 @@ 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)
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
//! - 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;
|
||||||
|
|
||||||
@@ -340,20 +345,87 @@ 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 {
|
||||||
case_insensitive_replace(text, from, to)
|
replace_first(text, from, to, MatchKind::WholeWord)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn case_insensitive_replace(text: &str, from: &str, to: &str) -> String {
|
fn case_insensitive_replace(text: &str, from: &str, to: &str) -> String {
|
||||||
let lower = text.to_lowercase();
|
replace_first(text, from, to, MatchKind::Substring)
|
||||||
let lower_from = from.to_lowercase();
|
}
|
||||||
if let Some(pos) = lower.find(&lower_from) {
|
|
||||||
let end = pos + from.len();
|
/// Whether a match may fall inside a larger word.
|
||||||
format!("{}{}{}", &text[..pos], to, &text[end..])
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
} else {
|
enum MatchKind {
|
||||||
text.to_string()
|
/// Match anywhere, including inside another word.
|
||||||
|
Substring,
|
||||||
|
/// Match only when both ends sit on a word boundary.
|
||||||
|
WholeWord,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the first case-insensitive match of `from` in `text` with `to`.
|
||||||
|
///
|
||||||
|
/// Matching walks the *original* string rather than a lowercased copy. The
|
||||||
|
/// previous implementation searched `text.to_lowercase()` and then sliced
|
||||||
|
/// `text` with the offsets it found, which only holds while lowercasing
|
||||||
|
/// preserves byte length. It does not: Turkish `İ` (2 bytes) lowercases to
|
||||||
|
/// `i` + U+0307 (3 bytes), so every later offset was wrong — silently
|
||||||
|
/// corrupting the output, or panicking when an offset landed inside a
|
||||||
|
/// character or past the end. `"İ AI"` was enough to panic.
|
||||||
|
fn replace_first(text: &str, from: &str, to: &str, kind: MatchKind) -> String {
|
||||||
|
match find_case_insensitive(text, from, kind) {
|
||||||
|
Some((start, end)) => {
|
||||||
|
let mut out = String::with_capacity(text.len() - (end - start) + to.len());
|
||||||
|
out.push_str(&text[..start]);
|
||||||
|
out.push_str(to);
|
||||||
|
out.push_str(&text[end..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
None => text.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Byte range of the first case-insensitive match of `needle` in `haystack`.
|
||||||
|
fn find_case_insensitive(haystack: &str, needle: &str, kind: MatchKind) -> Option<(usize, usize)> {
|
||||||
|
if needle.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lowered: Vec<char> = needle.chars().flat_map(char::to_lowercase).collect();
|
||||||
|
let is_word = |c: char| c.is_alphanumeric() || c == '_';
|
||||||
|
|
||||||
|
for (start, _) in haystack.char_indices() {
|
||||||
|
if kind == MatchKind::WholeWord
|
||||||
|
&& haystack[..start].chars().next_back().is_some_and(is_word)
|
||||||
|
{
|
||||||
|
continue; // mid-word: "ai" inside "training"
|
||||||
|
}
|
||||||
|
let mut matched = 0usize;
|
||||||
|
let mut end = start;
|
||||||
|
for (offset, ch) in haystack[start..].char_indices() {
|
||||||
|
if matched == lowered.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut consumed_all = true;
|
||||||
|
for lc in ch.to_lowercase() {
|
||||||
|
if lowered.get(matched) != Some(&lc) {
|
||||||
|
consumed_all = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
matched += 1;
|
||||||
|
}
|
||||||
|
if !consumed_all {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
end = start + offset + ch.len_utf8();
|
||||||
|
}
|
||||||
|
if matched == lowered.len()
|
||||||
|
&& !(kind == MatchKind::WholeWord
|
||||||
|
&& haystack[end..].chars().next().is_some_and(is_word))
|
||||||
|
{
|
||||||
|
return Some((start, end));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Simple whitespace/punctuation tokenizer.
|
/// Simple whitespace/punctuation tokenizer.
|
||||||
fn tokenize(text: &str) -> Vec<String> {
|
fn tokenize(text: &str) -> Vec<String> {
|
||||||
text.split(|c: char| !c.is_alphanumeric())
|
text.split(|c: char| !c.is_alphanumeric())
|
||||||
@@ -637,4 +709,86 @@ 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,8 +4,10 @@
|
|||||||
//! 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)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
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).
|
||||||
@@ -20,6 +22,9 @@ 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,
|
||||||
@@ -41,6 +46,8 @@ 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.
|
||||||
@@ -105,6 +112,15 @@ 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.
|
||||||
@@ -138,7 +154,8 @@ 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.temporal_weight * ts
|
let combined = config.relevance_weight * inp.relevance
|
||||||
|
+ config.temporal_weight * ts
|
||||||
+ config.authority_weight * auth
|
+ config.authority_weight * auth
|
||||||
+ config.activation_weight * act;
|
+ config.activation_weight * act;
|
||||||
|
|
||||||
@@ -148,6 +165,7 @@ 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();
|
||||||
@@ -253,22 +271,51 @@ 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();
|
||||||
@@ -302,6 +349,7 @@ 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,
|
||||||
@@ -312,6 +360,7 @@ 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,10 +12,22 @@ 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";
|
||||||
|
/// Writer-version tag stored in `/meta` as `edgehdf5_version`. Kept for file
|
||||||
|
/// compatibility; despite the name it has nothing to do with ZeroClaw, which
|
||||||
|
/// does not use clawhdf5.
|
||||||
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";
|
||||||
|
const SIG_VERSION_ATTR: &str = "sig_version";
|
||||||
|
|
||||||
/// 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,
|
||||||
@@ -23,6 +35,64 @@ 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,
|
||||||
|
..CheckpointMeta::default()
|
||||||
|
};
|
||||||
|
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>,
|
||||||
|
/// The checkpoint carries an Ed25519 signature (see [`crate::signing`]).
|
||||||
|
/// Read-only: whether a checkpoint is *written* signed is decided by the
|
||||||
|
/// signature passed to [`build_hdf5_file_signed`].
|
||||||
|
pub signed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`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> {
|
||||||
|
build_hdf5_file_signed(config, cache, sessions, knowledge, checkpoint, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`build_hdf5_file_with_meta`], plus a signed manifest of the contents
|
||||||
|
/// (see [`crate::signing`]).
|
||||||
|
pub fn build_hdf5_file_signed(
|
||||||
|
config: &MemoryConfig,
|
||||||
|
cache: &MemoryCache,
|
||||||
|
sessions: &SessionCache,
|
||||||
|
knowledge: &KnowledgeCache,
|
||||||
|
checkpoint: &CheckpointMeta,
|
||||||
|
signature: Option<&crate::signing::StoredSignature>,
|
||||||
|
) -> 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
|
||||||
@@ -34,15 +104,89 @@ pub fn build_hdf5_file(
|
|||||||
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("hnsw_m", AttrValue::I64(config.hnsw_m as i64));
|
||||||
|
meta.set_attr(
|
||||||
|
"hnsw_ef_construction",
|
||||||
|
AttrValue::I64(config.hnsw_ef_construction as i64),
|
||||||
|
);
|
||||||
|
meta.set_attr(
|
||||||
|
"hnsw_ef_search",
|
||||||
|
AttrValue::I64(config.hnsw_ef_search as i64),
|
||||||
|
);
|
||||||
meta.set_attr(
|
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));
|
||||||
|
}
|
||||||
|
if let Some(sig) = signature {
|
||||||
|
use crate::signing::to_hex;
|
||||||
|
let m = &sig.manifest;
|
||||||
|
meta.set_attr(
|
||||||
|
SIG_VERSION_ATTR,
|
||||||
|
AttrValue::I64(crate::signing::MANIFEST_VERSION),
|
||||||
|
);
|
||||||
|
meta.set_attr("sig_algorithm", AttrValue::String("ed25519".into()));
|
||||||
|
meta.set_attr("sig_public_key", AttrValue::String(to_hex(&sig.public_key)));
|
||||||
|
meta.set_attr("sig_signature", AttrValue::String(to_hex(&sig.signature)));
|
||||||
|
meta.set_attr("sig_record_count", AttrValue::I64(m.record_count as i64));
|
||||||
|
meta.set_attr(
|
||||||
|
"sig_records_root",
|
||||||
|
AttrValue::String(to_hex(&m.records_root)),
|
||||||
|
);
|
||||||
|
meta.set_attr("sig_settings", AttrValue::String(to_hex(&m.settings)));
|
||||||
|
meta.set_attr("sig_sessions", AttrValue::String(to_hex(&m.sessions)));
|
||||||
|
meta.set_attr("sig_graph", AttrValue::String(to_hex(&m.graph)));
|
||||||
|
}
|
||||||
// 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();
|
||||||
builder.add_group(finished_meta);
|
builder.add_group(finished_meta);
|
||||||
|
|
||||||
|
// /integrity: the signed per-record hashes, so verification can say
|
||||||
|
// which records changed.
|
||||||
|
if let Some(sig) = signature {
|
||||||
|
let mut group = builder.create_group("integrity");
|
||||||
|
let flat: Vec<u8> = sig.record_hashes.iter().flatten().copied().collect();
|
||||||
|
group
|
||||||
|
.create_dataset("record_hashes")
|
||||||
|
.with_u8_data(&flat)
|
||||||
|
.with_shape(&[sig.record_hashes.len() as u64, 32]);
|
||||||
|
builder.add_group(group.finish());
|
||||||
|
}
|
||||||
|
|
||||||
// /memory group
|
// /memory group
|
||||||
build_memory_group(&mut builder, config, cache)?;
|
build_memory_group(&mut builder, config, cache)?;
|
||||||
|
|
||||||
@@ -67,31 +211,56 @@ fn build_memory_group(
|
|||||||
// chunks: fixed-length string array
|
// chunks: fixed-length string array
|
||||||
write_string_dataset(&mut group, "chunks", &cache.chunks);
|
write_string_dataset(&mut group, "chunks", &cache.chunks);
|
||||||
|
|
||||||
// embeddings: f32 [N x D]
|
// embeddings: [N x D], f32 — or IEEE half precision for a `float16`
|
||||||
|
// store. The cache already holds half-rounded values then, so this
|
||||||
|
// conversion is exact and a reopened store sees the same numbers.
|
||||||
let n = cache.embeddings.len() as u64;
|
let n = cache.embeddings.len() as u64;
|
||||||
let d = cache.embedding_dim as u64;
|
let d = cache.embedding_dim as u64;
|
||||||
let flat = cache.flat_embeddings();
|
let flat = cache.flat_embeddings();
|
||||||
{
|
{
|
||||||
let ds = group
|
let ds = group.create_dataset("embeddings");
|
||||||
.create_dataset("embeddings")
|
let elem_bytes: u64 = if config.float16 {
|
||||||
.with_f32_data(&flat)
|
ds.with_f16_data(flat);
|
||||||
.with_shape(&[n, d]);
|
2
|
||||||
|
} else {
|
||||||
|
ds.with_f32_data(flat);
|
||||||
|
4
|
||||||
|
};
|
||||||
|
ds.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
|
||||||
if n > 0 && d > 0 {
|
if n > 0 && d > 0 {
|
||||||
let target_chunk_bytes: u64 = 256 * 1024;
|
let target_chunk_bytes: u64 = 256 * 1024;
|
||||||
let rows_per_chunk = (target_chunk_bytes / (d * 4)).max(1).min(n);
|
let rows_per_chunk = (target_chunk_bytes / (d * elem_bytes)).max(1).min(n);
|
||||||
ds.with_chunks(&[rows_per_chunk, d]);
|
ds.with_chunks(&[rows_per_chunk, d]);
|
||||||
|
|
||||||
// Compression: Zstd for embeddings — faster than deflate at same ratio.
|
// Compression. Shuffle is applied automatically (auto-shuffle
|
||||||
// Shuffle is applied automatically (auto-shuffle pre-filter).
|
// pre-filter). Zstd is faster than deflate at the same ratio but
|
||||||
|
// pulls in libzstd, so it is opt-in via the `zstd` feature; the
|
||||||
|
// default build uses deflate, which is always available. (This
|
||||||
|
// used to call `with_zstd` unconditionally, so without the
|
||||||
|
// feature every checkpoint of a compressed store failed with
|
||||||
|
// "unsupported filter: 32015".) Both are standard HDF5 filters;
|
||||||
|
// reading a zstd-compressed store needs a zstd-enabled build.
|
||||||
if config.compression {
|
if config.compression {
|
||||||
let level = if config.compression_level > 0 {
|
#[cfg(feature = "zstd")]
|
||||||
config.compression_level.min(22)
|
{
|
||||||
} else {
|
let level = if config.compression_level > 0 {
|
||||||
3 // Zstd level 3: fast + good ratio for f32 embeddings
|
config.compression_level.min(22)
|
||||||
};
|
} else {
|
||||||
ds.with_zstd(level);
|
3 // fast + good ratio for f32 embeddings
|
||||||
|
};
|
||||||
|
ds.with_zstd(level);
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "zstd"))]
|
||||||
|
{
|
||||||
|
let level = if config.compression_level > 0 {
|
||||||
|
config.compression_level.min(9)
|
||||||
|
} else {
|
||||||
|
4
|
||||||
|
};
|
||||||
|
ds.with_deflate(level);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,17 +477,122 @@ fn write_string_dataset(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `/meta`'s attributes, failing if any of them cannot be read.
|
||||||
|
///
|
||||||
|
/// `Group::attrs` leaves out an attribute it cannot decode. For the store's
|
||||||
|
/// settings that would silently fall back to defaults (e.g. `float16`, the
|
||||||
|
/// WAL mark), so an unreadable attribute is an error here, as it was before
|
||||||
|
/// `attrs` became tolerant.
|
||||||
|
fn meta_attrs(
|
||||||
|
file: &clawhdf5::File,
|
||||||
|
) -> Result<std::collections::HashMap<String, AttrValue>, MemoryError> {
|
||||||
|
let meta = file
|
||||||
|
.group("meta")
|
||||||
|
.map_err(|e| MemoryError::Schema(format!("missing /meta group: {e}")))?;
|
||||||
|
let (attrs, errors) = meta
|
||||||
|
.attrs_with_errors()
|
||||||
|
.map_err(|e| MemoryError::Schema(format!("cannot read /meta attrs: {e}")))?;
|
||||||
|
if let Some(e) = errors.first() {
|
||||||
|
return Err(MemoryError::Schema(format!(
|
||||||
|
"cannot read /meta attrs: {} unreadable, first: {e}",
|
||||||
|
errors.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(attrs)
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 = meta_attrs(file).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 a checkpoint's signature, if it has one. A signature whose
|
||||||
|
/// attributes are present but malformed is an error, not "unsigned".
|
||||||
|
pub fn read_signature(
|
||||||
|
file: &clawhdf5::File,
|
||||||
|
) -> Result<Option<crate::signing::StoredSignature>, MemoryError> {
|
||||||
|
use crate::signing::{Manifest, StoredSignature, from_hex};
|
||||||
|
let attrs = meta_attrs(file)?;
|
||||||
|
let version = match attrs.get(SIG_VERSION_ATTR) {
|
||||||
|
None => return Ok(None),
|
||||||
|
Some(AttrValue::I64(v)) => *v,
|
||||||
|
Some(_) => return Err(MemoryError::Schema("malformed sig_version".into())),
|
||||||
|
};
|
||||||
|
if version != crate::signing::MANIFEST_VERSION {
|
||||||
|
return Err(MemoryError::Schema(format!(
|
||||||
|
"unsupported signature version {version}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
fn hex<const N: usize>(
|
||||||
|
attrs: &std::collections::HashMap<String, AttrValue>,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<[u8; N], MemoryError> {
|
||||||
|
match attrs.get(name) {
|
||||||
|
Some(AttrValue::String(s)) => from_hex::<N>(s),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
.ok_or_else(|| MemoryError::Schema(format!("malformed or missing {name}")))
|
||||||
|
}
|
||||||
|
let record_count = match attrs.get("sig_record_count") {
|
||||||
|
Some(AttrValue::I64(v)) if *v >= 0 => *v as u64,
|
||||||
|
_ => return Err(MemoryError::Schema("malformed sig_record_count".into())),
|
||||||
|
};
|
||||||
|
let group = file
|
||||||
|
.group("integrity")
|
||||||
|
.map_err(|e| MemoryError::Schema(format!("signed checkpoint without /integrity: {e}")))?;
|
||||||
|
let flat = read_u8_dataset(&group, "record_hashes")?;
|
||||||
|
if flat.len() % 32 != 0 {
|
||||||
|
return Err(MemoryError::Schema(
|
||||||
|
"/integrity/record_hashes is not a whole number of hashes".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let record_hashes = flat.as_chunks::<32>().0.to_vec();
|
||||||
|
Ok(Some(StoredSignature {
|
||||||
|
manifest: Manifest {
|
||||||
|
record_count,
|
||||||
|
records_root: hex::<32>(&attrs, "sig_records_root")?,
|
||||||
|
settings: hex::<32>(&attrs, "sig_settings")?,
|
||||||
|
sessions: hex::<32>(&attrs, "sig_sessions")?,
|
||||||
|
graph: hex::<32>(&attrs, "sig_graph")?,
|
||||||
|
},
|
||||||
|
record_hashes,
|
||||||
|
public_key: hex::<32>(&attrs, "sig_public_key")?,
|
||||||
|
signature: hex::<64>(&attrs, "sig_signature")?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the checkpoint bookkeeping from `/meta`.
|
||||||
|
pub fn read_checkpoint_meta(file: &clawhdf5::File) -> CheckpointMeta {
|
||||||
|
let ann_generation =
|
||||||
|
meta_attrs(file)
|
||||||
|
.ok()
|
||||||
|
.and_then(|attrs| match attrs.get(ANN_GENERATION_ATTR) {
|
||||||
|
Some(AttrValue::I64(v)) => Some(*v as u64),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let signed = meta_attrs(file).is_ok_and(|attrs| attrs.contains_key(SIG_VERSION_ATTR));
|
||||||
|
CheckpointMeta {
|
||||||
|
wal_applied: read_wal_mark(file),
|
||||||
|
ann_generation,
|
||||||
|
signed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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> {
|
||||||
// Read /meta group attributes
|
// Read /meta group attributes
|
||||||
let meta = file
|
let attrs = meta_attrs(file)?;
|
||||||
.group("meta")
|
|
||||||
.map_err(|e| MemoryError::Schema(format!("missing /meta group: {e}")))?;
|
|
||||||
let attrs = meta
|
|
||||||
.attrs()
|
|
||||||
.map_err(|e| MemoryError::Schema(format!("cannot read /meta attrs: {e}")))?;
|
|
||||||
|
|
||||||
let schema_version = match attrs.get("schema_version") {
|
let schema_version = match attrs.get("schema_version") {
|
||||||
Some(AttrValue::String(s)) => s.clone(),
|
Some(AttrValue::String(s)) => s.clone(),
|
||||||
@@ -344,19 +618,45 @@ pub fn validate_and_load(
|
|||||||
embedding_dim,
|
embedding_dim,
|
||||||
chunk_size,
|
chunk_size,
|
||||||
overlap,
|
overlap,
|
||||||
float16: false,
|
float16: optional_bool_attr(&attrs, "float16", false),
|
||||||
compression: false,
|
compression: optional_bool_attr(&attrs, "compression", false),
|
||||||
compression_level: 0,
|
compression_level: optional_i64_attr(&attrs, "compression_level")
|
||||||
compact_threshold: 0.3,
|
.and_then(|v| u32::try_from(v).ok())
|
||||||
hebbian_boost: 0.15,
|
.unwrap_or(0),
|
||||||
decay_factor: 0.98,
|
compact_threshold: optional_f32_attr(&attrs, "compact_threshold", 0.3),
|
||||||
|
hebbian_boost: optional_f32_attr(&attrs, "hebbian_boost", 0.15),
|
||||||
|
decay_factor: optional_f32_attr(&attrs, "decay_factor", 0.98),
|
||||||
created_at,
|
created_at,
|
||||||
wal_enabled: true,
|
wal_enabled: optional_bool_attr(&attrs, "wal_enabled", true),
|
||||||
wal_max_entries: 500,
|
wal_max_entries: optional_i64_attr(&attrs, "wal_max_entries")
|
||||||
|
.and_then(|v| usize::try_from(v).ok())
|
||||||
|
.unwrap_or(500),
|
||||||
|
// `false`, not the new-store default: a store written before this
|
||||||
|
// setting existed was built with an f32 index, and reopening it must
|
||||||
|
// not silently change that.
|
||||||
|
quantized_index: optional_bool_attr(&attrs, "quantized_index", false),
|
||||||
|
hnsw_m: optional_i64_attr(&attrs, "hnsw_m")
|
||||||
|
.and_then(|v| usize::try_from(v).ok())
|
||||||
|
.unwrap_or(16),
|
||||||
|
hnsw_ef_construction: optional_i64_attr(&attrs, "hnsw_ef_construction")
|
||||||
|
.and_then(|v| usize::try_from(v).ok())
|
||||||
|
.unwrap_or(64),
|
||||||
|
hnsw_ef_search: optional_i64_attr(&attrs, "hnsw_ef_search")
|
||||||
|
.and_then(|v| usize::try_from(v).ok())
|
||||||
|
.unwrap_or(0),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load /memory group
|
// Load /memory group
|
||||||
let memory_cache = load_memory_group(file, embedding_dim)?;
|
let mut memory_cache = load_memory_group(file, embedding_dim)?;
|
||||||
|
// A float16 store's cache holds half-rounded embeddings. Embeddings read
|
||||||
|
// from an f16 dataset already are; a float16 store whose last checkpoint
|
||||||
|
// predates half-precision storage is still f32 on disk and is rounded
|
||||||
|
// here.
|
||||||
|
if config.float16 && embeddings_are_f16(file) {
|
||||||
|
memory_cache.half_precision = true;
|
||||||
|
} else {
|
||||||
|
memory_cache.set_half_precision(config.float16);
|
||||||
|
}
|
||||||
|
|
||||||
// Load /sessions group
|
// Load /sessions group
|
||||||
let session_cache = load_sessions_group(file)?;
|
let session_cache = load_sessions_group(file)?;
|
||||||
@@ -391,27 +691,48 @@ 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")?;
|
||||||
|
|
||||||
// Read norms if present, otherwise compute from embeddings
|
// Every per-record dataset must describe exactly `n` records. Without
|
||||||
let norms = match read_f32_dataset(&group, "norms") {
|
// this, a truncated or hand-edited file loads "successfully" and then
|
||||||
Ok(n) if n.len() == n.len() => n,
|
// panics on the first out-of-bounds index during search/delete.
|
||||||
_ => {
|
if embedding_dim == 0 {
|
||||||
// Compute norms from flat embeddings
|
return Err(MemoryError::Schema(format!(
|
||||||
flat_embeddings
|
"/memory has {n} records but embedding_dim is 0"
|
||||||
.chunks(embedding_dim)
|
)));
|
||||||
.map(|chunk| {
|
}
|
||||||
let sq_sum: f32 = chunk.iter().map(|x| x * x).sum();
|
let expected_flat = n.checked_mul(embedding_dim).ok_or_else(|| {
|
||||||
sq_sum.sqrt()
|
MemoryError::Schema(format!("/memory size overflow: {n} x {embedding_dim}"))
|
||||||
})
|
})?;
|
||||||
.collect()
|
let check_len = |name: &str, actual: usize, expected: usize| {
|
||||||
|
if actual == expected {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(MemoryError::Schema(format!(
|
||||||
|
"/memory/{name} has {actual} entries, expected {expected} \
|
||||||
|
({n} records)"
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
check_len("embeddings", flat_embeddings.len(), expected_flat)?;
|
||||||
|
check_len("source_channel", source_channels.len(), n)?;
|
||||||
|
check_len("timestamps", timestamps.len(), n)?;
|
||||||
|
check_len("session_ids", session_ids.len(), n)?;
|
||||||
|
check_len("tags", tags.len(), n)?;
|
||||||
|
check_len("tombstones", tombstones.len(), n)?;
|
||||||
|
|
||||||
// Unflatten embeddings
|
// Norms are derived data: use the stored ones only if they are present
|
||||||
let embeddings: Vec<Vec<f32>> = flat_embeddings
|
// and the right length, otherwise recompute from the embeddings.
|
||||||
.chunks(embedding_dim)
|
let norms = match read_f32_dataset(&group, "norms") {
|
||||||
.map(|c| c.to_vec())
|
Ok(stored) if stored.len() == n => stored,
|
||||||
.collect();
|
_ => flat_embeddings
|
||||||
|
.chunks(embedding_dim)
|
||||||
|
.map(|chunk| {
|
||||||
|
let sq_sum: f32 = chunk.iter().map(|x| x * x).sum();
|
||||||
|
sq_sum.sqrt()
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// No unflattening: the cache stores the buffer as it is on disk.
|
||||||
// Read activation_weights if present, default to vec![1.0; N] for backward compat
|
// 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,
|
||||||
@@ -419,7 +740,7 @@ fn load_memory_group(
|
|||||||
};
|
};
|
||||||
|
|
||||||
cache.chunks = chunks;
|
cache.chunks = chunks;
|
||||||
cache.embeddings = embeddings;
|
cache.embeddings.set_flat(embedding_dim, flat_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;
|
||||||
@@ -427,7 +748,6 @@ fn load_memory_group(
|
|||||||
cache.tombstones = tombstones;
|
cache.tombstones = tombstones;
|
||||||
cache.norms = norms;
|
cache.norms = norms;
|
||||||
cache.activation_weights = activation_weights;
|
cache.activation_weights = activation_weights;
|
||||||
cache.rebuild_flat();
|
|
||||||
|
|
||||||
Ok(cache)
|
Ok(cache)
|
||||||
}
|
}
|
||||||
@@ -531,6 +851,27 @@ fn extract_string_attr(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MetaAttrs = std::collections::HashMap<String, AttrValue>;
|
||||||
|
|
||||||
|
fn optional_i64_attr(attrs: &MetaAttrs, name: &str) -> Option<i64> {
|
||||||
|
match attrs.get(name) {
|
||||||
|
Some(AttrValue::I64(v)) => Some(*v),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_bool_attr(attrs: &MetaAttrs, name: &str, default: bool) -> bool {
|
||||||
|
optional_i64_attr(attrs, name).map_or(default, |v| v != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finite values only: a NaN threshold/decay would poison every comparison.
|
||||||
|
fn optional_f32_attr(attrs: &MetaAttrs, name: &str, default: f32) -> f32 {
|
||||||
|
match attrs.get(name) {
|
||||||
|
Some(AttrValue::F64(v)) if v.is_finite() => *v as f32,
|
||||||
|
_ => default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_i64_attr(
|
fn extract_i64_attr(
|
||||||
attrs: &std::collections::HashMap<String, AttrValue>,
|
attrs: &std::collections::HashMap<String, AttrValue>,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -558,6 +899,13 @@ fn read_string_dataset_from_group(
|
|||||||
.map_err(|e| MemoryError::Hdf5(format!("cannot read strings from {name}: {e}")))
|
.map_err(|e| MemoryError::Hdf5(format!("cannot read strings from {name}: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `/memory/embeddings` is stored as IEEE half precision.
|
||||||
|
fn embeddings_are_f16(file: &clawhdf5::File) -> bool {
|
||||||
|
file.dataset("memory/embeddings")
|
||||||
|
.and_then(|ds| ds.dtype())
|
||||||
|
.is_ok_and(|dt| matches!(dt, clawhdf5::DType::Other(ref s) if s == "float16"))
|
||||||
|
}
|
||||||
|
|
||||||
fn read_f32_dataset(group: &clawhdf5::Group<'_>, name: &str) -> Result<Vec<f32>, MemoryError> {
|
fn read_f32_dataset(group: &clawhdf5::Group<'_>, name: &str) -> Result<Vec<f32>, MemoryError> {
|
||||||
let ds = group
|
let ds = group
|
||||||
.dataset(name)
|
.dataset(name)
|
||||||
@@ -616,3 +964,108 @@ fn read_u8_dataset(group: &clawhdf5::Group<'_>, name: &str) -> Result<Vec<u8>, M
|
|||||||
.map_err(|e| MemoryError::Hdf5(format!("cannot read u8 from {name}: {e}")))?;
|
.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(|_| ())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,59 +2,196 @@
|
|||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::bm25;
|
use crate::bm25;
|
||||||
|
use crate::confidence::{ConfidenceConfig, ScoredResult, reject_low_confidence};
|
||||||
use crate::hybrid;
|
use crate::hybrid;
|
||||||
use crate::{HDF5Memory, MemoryError, Result, SearchResult};
|
use crate::reranker::{ReRankConfig, RerankInput, rerank};
|
||||||
|
use crate::{HDF5Memory, MAX_ACTIVATION_WEIGHT, MemoryError, Result, SearchResult};
|
||||||
|
|
||||||
|
/// Options for [`HDF5Memory::search`].
|
||||||
|
///
|
||||||
|
/// [`SearchOptions::new`] is plain hybrid search with the tuned default
|
||||||
|
/// fusion — the same as `hybrid_search_with(.., hybrid::DEFAULT_FUSION, k)`.
|
||||||
|
/// Every stage beyond that is opt-in.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SearchOptions {
|
||||||
|
/// Number of results to return.
|
||||||
|
pub k: usize,
|
||||||
|
/// How the vector and keyword stages are combined.
|
||||||
|
pub fusion: hybrid::Fusion,
|
||||||
|
/// Only consider records whose `source_channel` is one of these. The
|
||||||
|
/// filter applies *before* ranking, so a filtered search still returns up
|
||||||
|
/// to `k` results and scores are normalised over the records it can
|
||||||
|
/// return. `None` searches everything; an empty list matches nothing.
|
||||||
|
pub source_channels: Option<Vec<String>>,
|
||||||
|
/// Re-rank a candidate pool by retrieval relevance, recency, source
|
||||||
|
/// authority and activation — the pipeline the OpenClaw backend runs.
|
||||||
|
pub rerank: Option<ReRankConfig>,
|
||||||
|
/// Candidates retrieved for re-ranking; 0 means `max(3k, 10)`.
|
||||||
|
pub rerank_pool: usize,
|
||||||
|
/// Drop low-confidence results (after re-ranking, when that is on).
|
||||||
|
pub confidence: Option<ConfidenceConfig>,
|
||||||
|
/// The time recency is measured from, in seconds since the epoch.
|
||||||
|
/// `None` uses the system clock.
|
||||||
|
pub now: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchOptions {
|
||||||
|
pub fn new(k: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
k,
|
||||||
|
fusion: hybrid::DEFAULT_FUSION,
|
||||||
|
source_channels: None,
|
||||||
|
rerank: None,
|
||||||
|
rerank_pool: 0,
|
||||||
|
confidence: None,
|
||||||
|
now: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_fusion(mut self, fusion: hybrid::Fusion) -> Self {
|
||||||
|
self.fusion = fusion;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search only records from these source channels.
|
||||||
|
pub fn with_sources<S: Into<String>>(mut self, channels: impl IntoIterator<Item = S>) -> Self {
|
||||||
|
self.source_channels = Some(channels.into_iter().map(Into::into).collect());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_rerank(mut self, config: ReRankConfig) -> Self {
|
||||||
|
self.rerank = Some(config);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_confidence(mut self, config: ConfidenceConfig) -> Self {
|
||||||
|
self.confidence = Some(config);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measure recency from `now` (seconds since the epoch) instead of the
|
||||||
|
/// system clock — for reproducible results and tests.
|
||||||
|
pub fn at_time(mut self, now: f64) -> Self {
|
||||||
|
self.now = Some(now);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SearchOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl HDF5Memory {
|
impl HDF5Memory {
|
||||||
/// Vector + keyword scoring stage of [`HDF5Memory::hybrid_search`].
|
/// Vector + keyword scoring stage of [`HDF5Memory::search`].
|
||||||
///
|
///
|
||||||
/// Without the `hnsw` feature this is a full linear cosine scan (the exact
|
/// Without the `hnsw` feature this is a full linear cosine scan (the exact
|
||||||
/// previous behaviour, also used as the correctness oracle in tests). With
|
/// previous behaviour, also used as the correctness oracle in tests). With
|
||||||
/// `hnsw` enabled and an index available, the vector candidates come from an
|
/// `hnsw` enabled and an index available, the vector candidates come from an
|
||||||
/// approximate-nearest-neighbour search over an over-fetched pool, then merge
|
/// approximate-nearest-neighbour search over an over-fetched pool, then merge
|
||||||
/// with BM25 via the shared [`hybrid::merge_vector_keyword`].
|
/// with BM25 via the shared [`hybrid::merge_vector_keyword`].
|
||||||
|
///
|
||||||
|
/// `exclude`, when given, marks records that must not be returned (1 =
|
||||||
|
/// excluded; it covers tombstones too). The index is over-fetched in
|
||||||
|
/// proportion to how much the mask removes. Surfacing `pool` candidates
|
||||||
|
/// costs the index roughly `pool × M` distance evaluations, while an exact
|
||||||
|
/// scan of the allowed records costs one each — so whenever that scan is
|
||||||
|
/// the cheaper of the two it is used instead, and it is also the fallback
|
||||||
|
/// if the pool comes back with too few allowed hits (the allowed records
|
||||||
|
/// sit away from the query). A filtered search never comes back short.
|
||||||
#[cfg(feature = "hnsw")]
|
#[cfg(feature = "hnsw")]
|
||||||
fn vector_keyword_search(
|
fn vector_keyword_search(
|
||||||
&mut self,
|
&mut self,
|
||||||
query_embedding: &[f32],
|
query_embedding: &[f32],
|
||||||
query_text: &str,
|
query_text: &str,
|
||||||
bm25: &bm25::BM25Index,
|
bm25: &bm25::BM25Index,
|
||||||
vector_weight: f32,
|
fusion: hybrid::Fusion,
|
||||||
keyword_weight: f32,
|
|
||||||
k: usize,
|
k: usize,
|
||||||
|
exclude: Option<&[u8]>,
|
||||||
) -> Vec<(usize, f32)> {
|
) -> Vec<(usize, f32)> {
|
||||||
self.ensure_hnsw_fresh();
|
self.ensure_hnsw_fresh();
|
||||||
|
let n = self.cache.len();
|
||||||
|
// Over-fetch so the merge sees a useful vector pool. `ef` is
|
||||||
|
// configurable, but the pool the fusion stage sees is not tied to it:
|
||||||
|
// a caller lowering `ef` for speed should not silently narrow what
|
||||||
|
// fusion has to work with.
|
||||||
|
let mut pool = (k * 8).max(64);
|
||||||
|
let mut allowed = n;
|
||||||
|
if let Some(ex) = exclude {
|
||||||
|
allowed = ex.iter().filter(|&&e| e == 0).count();
|
||||||
|
if allowed == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
// Expect `pool` allowed hits if the filter is independent of the
|
||||||
|
// query's neighbourhood.
|
||||||
|
pool = pool.saturating_mul(n).div_ceil(allowed);
|
||||||
|
if allowed <= pool.saturating_mul(self.hnsw_m()) {
|
||||||
|
return self.exact_masked_search(query_embedding, query_text, bm25, fusion, k, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
match self.hnsw.as_ref() {
|
match self.hnsw.as_ref() {
|
||||||
Some(index) if !index.is_empty() && index.dimension() == query_embedding.len() => {
|
Some(index) if !index.is_empty() && index.dimension() == query_embedding.len() => {
|
||||||
// Over-fetch so the merge sees a useful vector pool; cosine
|
let ef = self.hnsw_ef_search(k).max(pool);
|
||||||
// distance from the index converts back to similarity (1 - d).
|
let candidates = index.search(query_embedding, pool, ef);
|
||||||
let pool = (k * 8).max(64);
|
// A quantised index returns approximate distances, and no
|
||||||
let vec_scores: Vec<(usize, f32)> = index
|
// amount of `ef` fixes that — the loss is in the distances,
|
||||||
.search(query_embedding, pool, pool)
|
// 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)| (id, 1.0 - dist))
|
.filter(|(id, _)| exclude.is_none_or(|ex| ex[*id] == 0))
|
||||||
|
.map(|(id, dist)| {
|
||||||
|
let score = if exact {
|
||||||
|
crate::vector_search::cosine_similarity(
|
||||||
|
query_embedding,
|
||||||
|
&self.cache.embeddings[id],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
1.0 - dist
|
||||||
|
};
|
||||||
|
(id, score)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let kw_scores = bm25.search(query_text, self.cache.len());
|
// Fusion normalises over every keyword match, so it needs all
|
||||||
hybrid::merge_vector_keyword(
|
// the scores — but not ranked.
|
||||||
vec_scores,
|
let mut kw_scores = bm25.scores(query_text);
|
||||||
kw_scores,
|
if let Some(ex) = exclude {
|
||||||
vector_weight,
|
if vec_scores.len() < k.min(allowed) {
|
||||||
keyword_weight,
|
// The allowed records are not where the index looked.
|
||||||
k,
|
return self.exact_masked_search(
|
||||||
)
|
query_embedding,
|
||||||
|
query_text,
|
||||||
|
bm25,
|
||||||
|
fusion,
|
||||||
|
k,
|
||||||
|
ex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
kw_scores.retain(|(id, _)| ex[*id] == 0);
|
||||||
|
}
|
||||||
|
hybrid::fuse(vec_scores, kw_scores, fusion, k)
|
||||||
}
|
}
|
||||||
_ => hybrid::hybrid_search(
|
_ => match exclude {
|
||||||
query_embedding,
|
Some(ex) => {
|
||||||
query_text,
|
self.exact_masked_search(query_embedding, query_text, bm25, fusion, k, ex)
|
||||||
&self.cache.embeddings,
|
}
|
||||||
&self.cache.chunks,
|
None => hybrid::hybrid_search_fused(
|
||||||
&self.cache.tombstones,
|
query_embedding,
|
||||||
bm25,
|
query_text,
|
||||||
vector_weight,
|
&self.cache.embeddings,
|
||||||
keyword_weight,
|
&self.cache.chunks,
|
||||||
k,
|
&self.cache.tombstones,
|
||||||
),
|
bm25,
|
||||||
|
fusion,
|
||||||
|
k,
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,21 +201,52 @@ impl HDF5Memory {
|
|||||||
query_embedding: &[f32],
|
query_embedding: &[f32],
|
||||||
query_text: &str,
|
query_text: &str,
|
||||||
bm25: &bm25::BM25Index,
|
bm25: &bm25::BM25Index,
|
||||||
vector_weight: f32,
|
fusion: hybrid::Fusion,
|
||||||
keyword_weight: f32,
|
|
||||||
k: usize,
|
k: usize,
|
||||||
|
exclude: Option<&[u8]>,
|
||||||
) -> Vec<(usize, f32)> {
|
) -> Vec<(usize, f32)> {
|
||||||
hybrid::hybrid_search(
|
match exclude {
|
||||||
query_embedding,
|
Some(ex) => self.exact_masked_search(query_embedding, query_text, bm25, fusion, k, ex),
|
||||||
query_text,
|
None => hybrid::hybrid_search_fused(
|
||||||
&self.cache.embeddings,
|
query_embedding,
|
||||||
&self.cache.chunks,
|
query_text,
|
||||||
&self.cache.tombstones,
|
&self.cache.embeddings,
|
||||||
bm25,
|
&self.cache.chunks,
|
||||||
vector_weight,
|
&self.cache.tombstones,
|
||||||
keyword_weight,
|
bm25,
|
||||||
k,
|
fusion,
|
||||||
)
|
k,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact hybrid search over the records `exclude` leaves (0 = allowed).
|
||||||
|
fn exact_masked_search(
|
||||||
|
&self,
|
||||||
|
query_embedding: &[f32],
|
||||||
|
query_text: &str,
|
||||||
|
bm25: &bm25::BM25Index,
|
||||||
|
fusion: hybrid::Fusion,
|
||||||
|
k: usize,
|
||||||
|
exclude: &[u8],
|
||||||
|
) -> Vec<(usize, f32)> {
|
||||||
|
let vec_scores =
|
||||||
|
hybrid::exact_vector_scores(query_embedding, &self.cache.embeddings, exclude);
|
||||||
|
let mut kw_scores = bm25.scores(query_text);
|
||||||
|
kw_scores.retain(|(id, _)| exclude.get(*id) == Some(&0));
|
||||||
|
hybrid::fuse(vec_scores, kw_scores, fusion, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exclusion mask for a source-channel filter: 1 for a tombstoned
|
||||||
|
/// record or one from a channel not in `channels`.
|
||||||
|
fn source_mask(&self, channels: &[String]) -> Vec<u8> {
|
||||||
|
let allowed: HashSet<&str> = channels.iter().map(String::as_str).collect();
|
||||||
|
self.cache
|
||||||
|
.source_channels
|
||||||
|
.iter()
|
||||||
|
.zip(&self.cache.tombstones)
|
||||||
|
.map(|(ch, &t)| u8::from(t != 0 || !allowed.contains(ch.as_str())))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform hybrid search combining cosine vector similarity and BM25 keyword search.
|
/// Perform hybrid search combining cosine vector similarity and BM25 keyword search.
|
||||||
@@ -90,15 +258,76 @@ impl HDF5Memory {
|
|||||||
keyword_weight: f32,
|
keyword_weight: f32,
|
||||||
k: usize,
|
k: usize,
|
||||||
) -> Vec<SearchResult> {
|
) -> Vec<SearchResult> {
|
||||||
let bm25 = bm25::BM25Index::build(&self.cache.chunks, &self.cache.tombstones);
|
self.hybrid_search_with(
|
||||||
|
query_embedding,
|
||||||
|
query_text,
|
||||||
|
hybrid::Fusion::Weighted {
|
||||||
|
vector: vector_weight,
|
||||||
|
keyword: keyword_weight,
|
||||||
|
},
|
||||||
|
k,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`HDF5Memory::hybrid_search`] with the fusion method chosen explicitly.
|
||||||
|
///
|
||||||
|
/// [`hybrid::DEFAULT_FUSION`] is what the weighted form defaults to;
|
||||||
|
/// [`hybrid::Fusion::Rrf`] combines the two stages by rank instead of by
|
||||||
|
/// score.
|
||||||
|
pub fn hybrid_search_with(
|
||||||
|
&mut self,
|
||||||
|
query_embedding: &[f32],
|
||||||
|
query_text: &str,
|
||||||
|
fusion: hybrid::Fusion,
|
||||||
|
k: usize,
|
||||||
|
) -> Vec<SearchResult> {
|
||||||
|
self.search(
|
||||||
|
query_embedding,
|
||||||
|
query_text,
|
||||||
|
&SearchOptions::new(k).with_fusion(fusion),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hybrid search with optional source filtering, re-ranking and
|
||||||
|
/// confidence rejection — see [`SearchOptions`].
|
||||||
|
///
|
||||||
|
/// Stages, in order: vector + keyword retrieval over the records the
|
||||||
|
/// source filter allows; fusion; scaling by Hebbian activation; re-ranking
|
||||||
|
/// (if on) of a `rerank_pool` of candidates; confidence rejection (if on);
|
||||||
|
/// the top `k`. The records returned with a positive score get their
|
||||||
|
/// Hebbian boost.
|
||||||
|
pub fn search(
|
||||||
|
&mut self,
|
||||||
|
query_embedding: &[f32],
|
||||||
|
query_text: &str,
|
||||||
|
options: &SearchOptions,
|
||||||
|
) -> Vec<SearchResult> {
|
||||||
|
let k = options.k;
|
||||||
|
let fetch = match options.rerank {
|
||||||
|
Some(_) if options.rerank_pool > 0 => options.rerank_pool.max(k),
|
||||||
|
Some(_) => k.saturating_mul(3).max(10),
|
||||||
|
None => k,
|
||||||
|
};
|
||||||
|
let exclude = options
|
||||||
|
.source_channels
|
||||||
|
.as_deref()
|
||||||
|
.map(|channels| self.source_mask(channels));
|
||||||
|
|
||||||
|
// 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(
|
let scored = self.vector_keyword_search(
|
||||||
query_embedding,
|
query_embedding,
|
||||||
query_text,
|
query_text,
|
||||||
&bm25,
|
&bm25,
|
||||||
vector_weight,
|
options.fusion,
|
||||||
keyword_weight,
|
fetch,
|
||||||
k,
|
exclude.as_deref(),
|
||||||
);
|
);
|
||||||
|
self.bm25 = Some(bm25);
|
||||||
|
|
||||||
let mut results: Vec<SearchResult> = scored
|
let mut results: Vec<SearchResult> = scored
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(idx, score)| {
|
.map(|(idx, score)| {
|
||||||
@@ -113,23 +342,98 @@ 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))
|
||||||
});
|
});
|
||||||
|
|
||||||
let hit_indices: Vec<usize> = results.iter().map(|r| r.index).collect();
|
if let Some(config) = &options.rerank {
|
||||||
|
results = Self::rerank_results(results, config, options.now);
|
||||||
|
}
|
||||||
|
if let Some(config) = &options.confidence {
|
||||||
|
let scored: Vec<ScoredResult> = results
|
||||||
|
.iter()
|
||||||
|
.map(|r| ScoredResult {
|
||||||
|
index: r.index,
|
||||||
|
score: r.score,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let keep: HashSet<usize> = reject_low_confidence(&scored, config)
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.index)
|
||||||
|
.collect();
|
||||||
|
results.retain(|r| keep.contains(&r.index));
|
||||||
|
}
|
||||||
|
results.truncate(k);
|
||||||
|
|
||||||
|
// Only reinforce records that actually matched. When fewer than `k`
|
||||||
|
// records are relevant, the rest of the list is zero-score filler;
|
||||||
|
// boosting it would teach the store that arbitrary records are
|
||||||
|
// important just because they were nearby in iteration order.
|
||||||
|
let hit_indices: Vec<usize> = results
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.score > 0.0)
|
||||||
|
.map(|r| r.index)
|
||||||
|
.collect();
|
||||||
self.apply_hebbian_boost(&hit_indices);
|
self.apply_hebbian_boost(&hit_indices);
|
||||||
self.flush().ok();
|
|
||||||
|
|
||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reorder by the re-ranker's combined score, which also becomes each
|
||||||
|
/// result's `score`.
|
||||||
|
fn rerank_results(
|
||||||
|
results: Vec<SearchResult>,
|
||||||
|
config: &ReRankConfig,
|
||||||
|
now: Option<f64>,
|
||||||
|
) -> Vec<SearchResult> {
|
||||||
|
let now = now.unwrap_or_else(|| {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs_f64())
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
});
|
||||||
|
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 mut by_index: std::collections::HashMap<usize, SearchResult> =
|
||||||
|
results.into_iter().map(|r| (r.index, r)).collect();
|
||||||
|
rerank(&inputs, config, now)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|rr| {
|
||||||
|
let mut r = by_index.remove(&rr.index)?;
|
||||||
|
r.score = rr.combined_score;
|
||||||
|
Some(r)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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]) {
|
||||||
for &idx in hit_indices {
|
if hit_indices.is_empty() || self.config.hebbian_boost == 0.0 {
|
||||||
self.cache.activation_weights[idx] += self.config.hebbian_boost;
|
return;
|
||||||
}
|
}
|
||||||
|
for &idx in hit_indices {
|
||||||
|
let w = &mut self.cache.activation_weights[idx];
|
||||||
|
*w = (*w + self.config.hebbian_boost).min(MAX_ACTIVATION_WEIGHT);
|
||||||
|
}
|
||||||
|
self.activations_dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the chunk text for a memory entry by index.
|
/// Get the chunk text for a memory entry by index.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ impl SessionCache {
|
|||||||
self.entries.is_empty()
|
self.entries.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a new session with its summary.
|
/// Add a new session with its summary, timestamped now.
|
||||||
pub fn add(
|
pub fn add(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -47,6 +47,21 @@ impl SessionCache {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs_f64()
|
.as_secs_f64()
|
||||||
* 1_000_000.0; // microseconds
|
* 1_000_000.0; // microseconds
|
||||||
|
self.add_at(id, start_idx, end_idx, channel, summary, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a session with an explicit timestamp (Unix **microseconds**, the
|
||||||
|
/// unit [`SessionEntry::ts`] uses) — for importers carrying sessions over
|
||||||
|
/// from another store, whose original time should be kept.
|
||||||
|
pub fn add_at(
|
||||||
|
&mut self,
|
||||||
|
id: &str,
|
||||||
|
start_idx: usize,
|
||||||
|
end_idx: usize,
|
||||||
|
channel: &str,
|
||||||
|
summary: &str,
|
||||||
|
ts: f64,
|
||||||
|
) {
|
||||||
self.entries.push(SessionEntry {
|
self.entries.push(SessionEntry {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
start_idx: start_idx as u64,
|
start_idx: start_idx as u64,
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
//! Ed25519-signed checkpoints.
|
||||||
|
//!
|
||||||
|
//! When a signing key is set ([`crate::HDF5Memory::set_signing_key`]), every
|
||||||
|
//! checkpoint writes a signed manifest of the store: a SHA-256 per memory
|
||||||
|
//! record rolled into a Merkle root, plus hashes of the store's settings, its
|
||||||
|
//! sessions and its knowledge graph. [`verify_store`] recomputes all of it from
|
||||||
|
//! the file and checks the signature against a public key the caller trusts,
|
||||||
|
//! so any change to the checkpointed file — a record's text or embedding, a
|
||||||
|
//! setting, a session, a graph edge, made through this crate or any other HDF5
|
||||||
|
//! tool — is detected, and the per-record hashes say which records changed.
|
||||||
|
//!
|
||||||
|
//! What it does not cover: saves still only in the WAL (made since the last
|
||||||
|
//! checkpoint). [`VerifyReport::wal_entries_unsigned`] counts them.
|
||||||
|
//!
|
||||||
|
//! The hashes cover exactly what the file persists, in the form the loader
|
||||||
|
//! returns it, so a store verifies after any number of reopen/checkpoint
|
||||||
|
//! cycles. Derived data (L2 norms, the vector index) is not covered; it is
|
||||||
|
//! recomputed from covered data.
|
||||||
|
|
||||||
|
use ed25519_dalek::{Signature, Signer, Verifier};
|
||||||
|
pub use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::MemoryConfig;
|
||||||
|
use crate::cache::MemoryCache;
|
||||||
|
use crate::knowledge::KnowledgeCache;
|
||||||
|
use crate::session::SessionCache;
|
||||||
|
use crate::wal::WalMark;
|
||||||
|
|
||||||
|
/// Version of the manifest encoding; part of what is signed.
|
||||||
|
pub const MANIFEST_VERSION: i64 = 1;
|
||||||
|
|
||||||
|
type Hash = [u8; 32];
|
||||||
|
|
||||||
|
/// The hashes a signature covers.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Manifest {
|
||||||
|
pub record_count: u64,
|
||||||
|
/// Merkle root over the per-record hashes.
|
||||||
|
pub records_root: Hash,
|
||||||
|
/// Settings persisted in `/meta`, plus the checkpoint's WAL mark.
|
||||||
|
pub settings: Hash,
|
||||||
|
pub sessions: Hash,
|
||||||
|
pub graph: Hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Manifest {
|
||||||
|
/// The exact bytes that are signed.
|
||||||
|
pub fn signed_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut m = Vec::with_capacity(160);
|
||||||
|
m.extend_from_slice(b"clawhdf5-agent signed checkpoint\0");
|
||||||
|
m.extend_from_slice(&MANIFEST_VERSION.to_le_bytes());
|
||||||
|
m.extend_from_slice(&self.record_count.to_le_bytes());
|
||||||
|
m.extend_from_slice(&self.records_root);
|
||||||
|
m.extend_from_slice(&self.settings);
|
||||||
|
m.extend_from_slice(&self.sessions);
|
||||||
|
m.extend_from_slice(&self.graph);
|
||||||
|
m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signature as stored in a checkpoint.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StoredSignature {
|
||||||
|
pub manifest: Manifest,
|
||||||
|
pub record_hashes: Vec<Hash>,
|
||||||
|
pub public_key: [u8; 32],
|
||||||
|
pub signature: [u8; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the manifest (and per-record hashes) for the state about to be
|
||||||
|
/// checkpointed, and sign it.
|
||||||
|
pub fn sign(
|
||||||
|
key: &SigningKey,
|
||||||
|
config: &MemoryConfig,
|
||||||
|
cache: &MemoryCache,
|
||||||
|
sessions: &SessionCache,
|
||||||
|
knowledge: &KnowledgeCache,
|
||||||
|
wal_applied: Option<WalMark>,
|
||||||
|
) -> StoredSignature {
|
||||||
|
let (manifest, record_hashes) = manifest(config, cache, sessions, knowledge, wal_applied);
|
||||||
|
let signature = key.sign(&manifest.signed_bytes()).to_bytes();
|
||||||
|
StoredSignature {
|
||||||
|
manifest,
|
||||||
|
record_hashes,
|
||||||
|
public_key: key.verifying_key().to_bytes(),
|
||||||
|
signature,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the manifest of a store's state.
|
||||||
|
pub fn manifest(
|
||||||
|
config: &MemoryConfig,
|
||||||
|
cache: &MemoryCache,
|
||||||
|
sessions: &SessionCache,
|
||||||
|
knowledge: &KnowledgeCache,
|
||||||
|
wal_applied: Option<WalMark>,
|
||||||
|
) -> (Manifest, Vec<Hash>) {
|
||||||
|
let record_hashes: Vec<Hash> = (0..cache.len()).map(|i| record_hash(cache, i)).collect();
|
||||||
|
let manifest = Manifest {
|
||||||
|
record_count: cache.len() as u64,
|
||||||
|
records_root: merkle_root(&record_hashes),
|
||||||
|
settings: settings_hash(config, wal_applied),
|
||||||
|
sessions: sessions_hash(sessions),
|
||||||
|
graph: graph_hash(knowledge),
|
||||||
|
};
|
||||||
|
(manifest, record_hashes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Canonical encoding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A SHA-256 over length-prefixed fields, so no two different field lists
|
||||||
|
/// hash the same bytes.
|
||||||
|
struct Fields(Sha256);
|
||||||
|
|
||||||
|
impl Fields {
|
||||||
|
fn new(domain: &str) -> Self {
|
||||||
|
let mut h = Sha256::new();
|
||||||
|
h.update((domain.len() as u64).to_le_bytes());
|
||||||
|
h.update(domain.as_bytes());
|
||||||
|
Self(h)
|
||||||
|
}
|
||||||
|
fn bytes(&mut self, b: &[u8]) -> &mut Self {
|
||||||
|
self.0.update((b.len() as u64).to_le_bytes());
|
||||||
|
self.0.update(b);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
/// Strings as the loader returns them: stored null-padded, so a trailing
|
||||||
|
/// NUL cannot survive a round trip and must not be part of the hash.
|
||||||
|
fn str(&mut self, s: &str) -> &mut Self {
|
||||||
|
self.bytes(s.trim_end_matches('\0').as_bytes())
|
||||||
|
}
|
||||||
|
fn u64(&mut self, v: u64) -> &mut Self {
|
||||||
|
self.0.update(v.to_le_bytes());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn f64(&mut self, v: f64) -> &mut Self {
|
||||||
|
self.0.update(v.to_bits().to_le_bytes());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn f32(&mut self, v: f32) -> &mut Self {
|
||||||
|
self.0.update(v.to_bits().to_le_bytes());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn finish(self) -> Hash {
|
||||||
|
self.0.finalize().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything persisted about record `i`, including its position. The
|
||||||
|
/// embedding is hashed as the cache holds it — for a `float16` store that is
|
||||||
|
/// the half-rounded value the file holds.
|
||||||
|
fn record_hash(cache: &MemoryCache, i: usize) -> Hash {
|
||||||
|
let mut f = Fields::new("clawhdf5-agent/record");
|
||||||
|
f.u64(i as u64).str(&cache.chunks[i]);
|
||||||
|
let emb: Vec<u8> = cache.embeddings[i]
|
||||||
|
.iter()
|
||||||
|
.flat_map(|v| v.to_bits().to_le_bytes())
|
||||||
|
.collect();
|
||||||
|
f.bytes(&emb)
|
||||||
|
.str(&cache.source_channels[i])
|
||||||
|
.f64(cache.timestamps[i])
|
||||||
|
.str(&cache.session_ids[i])
|
||||||
|
.str(&cache.tags[i])
|
||||||
|
.u64(u64::from(cache.tombstones[i]))
|
||||||
|
.f32(cache.activation_weights[i]);
|
||||||
|
f.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binary Merkle tree: leaves are the record hashes; a parent hashes its two
|
||||||
|
/// children with a node prefix; an odd node is carried up unchanged.
|
||||||
|
fn merkle_root(leaves: &[Hash]) -> Hash {
|
||||||
|
if leaves.is_empty() {
|
||||||
|
return Fields::new("clawhdf5-agent/merkle-empty").finish();
|
||||||
|
}
|
||||||
|
let mut level: Vec<Hash> = leaves.to_vec();
|
||||||
|
while level.len() > 1 {
|
||||||
|
level = level
|
||||||
|
.chunks(2)
|
||||||
|
.map(|pair| match pair {
|
||||||
|
[l, r] => {
|
||||||
|
let mut h = Sha256::new();
|
||||||
|
h.update([1u8]);
|
||||||
|
h.update(l);
|
||||||
|
h.update(r);
|
||||||
|
h.finalize().into()
|
||||||
|
}
|
||||||
|
[only] => *only,
|
||||||
|
_ => unreachable!(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
level[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_hash(c: &MemoryConfig, wal_applied: Option<WalMark>) -> Hash {
|
||||||
|
let mut f = Fields::new("clawhdf5-agent/settings");
|
||||||
|
f.str(crate::schema::SCHEMA_VERSION)
|
||||||
|
.str(&c.created_at)
|
||||||
|
.str(&c.agent_id)
|
||||||
|
.str(&c.embedder)
|
||||||
|
.u64(c.embedding_dim as u64)
|
||||||
|
.u64(c.chunk_size as u64)
|
||||||
|
.u64(c.overlap as u64)
|
||||||
|
.u64(u64::from(c.float16))
|
||||||
|
.u64(u64::from(c.compression))
|
||||||
|
.u64(u64::from(c.compression_level))
|
||||||
|
.f32(c.compact_threshold)
|
||||||
|
.f32(c.hebbian_boost)
|
||||||
|
.f32(c.decay_factor)
|
||||||
|
.u64(u64::from(c.wal_enabled))
|
||||||
|
.u64(c.wal_max_entries as u64)
|
||||||
|
.u64(u64::from(c.quantized_index))
|
||||||
|
.u64(c.hnsw_m as u64)
|
||||||
|
.u64(c.hnsw_ef_construction as u64)
|
||||||
|
.u64(c.hnsw_ef_search as u64);
|
||||||
|
// An empty mark is not written to the file, so it must hash as none.
|
||||||
|
match wal_applied.filter(|m| m.len > 0) {
|
||||||
|
Some(m) => f.u64(1).u64(m.len).u64(u64::from(m.crc)),
|
||||||
|
None => f.u64(0),
|
||||||
|
};
|
||||||
|
f.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sessions_hash(s: &SessionCache) -> Hash {
|
||||||
|
let mut f = Fields::new("clawhdf5-agent/sessions");
|
||||||
|
f.u64(s.entries.len() as u64);
|
||||||
|
for (i, e) in s.entries.iter().enumerate() {
|
||||||
|
f.str(&e.id)
|
||||||
|
.u64(e.start_idx)
|
||||||
|
.u64(e.end_idx)
|
||||||
|
.str(&e.channel)
|
||||||
|
.f64(e.ts)
|
||||||
|
.str(s.summaries.get(i).map(String::as_str).unwrap_or(""));
|
||||||
|
}
|
||||||
|
f.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_hash(k: &KnowledgeCache) -> Hash {
|
||||||
|
let mut f = Fields::new("clawhdf5-agent/graph");
|
||||||
|
f.u64(k.entities.len() as u64);
|
||||||
|
for e in &k.entities {
|
||||||
|
f.u64(e.id)
|
||||||
|
.str(&e.name)
|
||||||
|
.str(&e.entity_type)
|
||||||
|
.u64(e.embedding_idx as u64);
|
||||||
|
}
|
||||||
|
f.u64(k.relations.len() as u64);
|
||||||
|
for r in &k.relations {
|
||||||
|
f.u64(r.src)
|
||||||
|
.u64(r.tgt)
|
||||||
|
.str(&r.relation)
|
||||||
|
.f32(r.weight)
|
||||||
|
.f64(r.ts);
|
||||||
|
}
|
||||||
|
f.u64(k.alias_strings.len() as u64);
|
||||||
|
for (s, id) in k.alias_strings.iter().zip(&k.alias_entity_ids) {
|
||||||
|
f.str(s).u64(*id as u64);
|
||||||
|
}
|
||||||
|
f.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Verification
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The outcome of [`verify_store`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct VerifyReport {
|
||||||
|
/// The checkpoint carries a signature.
|
||||||
|
pub signed: bool,
|
||||||
|
/// The signature was made by the key the caller trusts.
|
||||||
|
pub key_matches: bool,
|
||||||
|
/// The signature over the stored manifest is valid.
|
||||||
|
pub signature_valid: bool,
|
||||||
|
/// The file's current contents match the signed manifest.
|
||||||
|
pub records_match: bool,
|
||||||
|
pub settings_match: bool,
|
||||||
|
pub sessions_match: bool,
|
||||||
|
pub graph_match: bool,
|
||||||
|
/// Records whose contents differ from what was signed (by position),
|
||||||
|
/// when the stored per-record hashes are themselves authentic.
|
||||||
|
pub changed_records: Vec<usize>,
|
||||||
|
/// Records in the file versus in the signed manifest.
|
||||||
|
pub record_count: u64,
|
||||||
|
pub signed_record_count: u64,
|
||||||
|
/// The public key the checkpoint claims to be signed by.
|
||||||
|
pub public_key: Option<[u8; 32]>,
|
||||||
|
/// Saves in the WAL after the checkpoint: not covered by the signature.
|
||||||
|
pub wal_entries_unsigned: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VerifyReport {
|
||||||
|
/// Signed by the trusted key, signature valid, and every part of the
|
||||||
|
/// file unchanged since it was signed.
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.signed
|
||||||
|
&& self.key_matches
|
||||||
|
&& self.signature_valid
|
||||||
|
&& self.records_match
|
||||||
|
&& self.settings_match
|
||||||
|
&& self.sessions_match
|
||||||
|
&& self.graph_match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check a store file against the public key the caller trusts.
|
||||||
|
///
|
||||||
|
/// Reads the checkpoint (not the WAL), recomputes every hash from its
|
||||||
|
/// contents and checks the signature. Never writes.
|
||||||
|
pub fn verify_store(
|
||||||
|
path: &std::path::Path,
|
||||||
|
trusted: &VerifyingKey,
|
||||||
|
) -> Result<VerifyReport, crate::MemoryError> {
|
||||||
|
let file = clawhdf5::File::open(path)
|
||||||
|
.map_err(|e| crate::MemoryError::Hdf5(format!("cannot open {}: {e}", path.display())))?;
|
||||||
|
let (config, cache, sessions, knowledge) = crate::schema::validate_and_load(&file)?;
|
||||||
|
let checkpoint = crate::schema::read_checkpoint_meta(&file);
|
||||||
|
let stored = crate::schema::read_signature(&file)?;
|
||||||
|
let wal_entries_unsigned = count_wal_entries_after(path, checkpoint.wal_applied);
|
||||||
|
|
||||||
|
let (current, current_hashes) = manifest(
|
||||||
|
&config,
|
||||||
|
&cache,
|
||||||
|
&sessions,
|
||||||
|
&knowledge,
|
||||||
|
checkpoint.wal_applied,
|
||||||
|
);
|
||||||
|
|
||||||
|
let Some(stored) = stored else {
|
||||||
|
return Ok(VerifyReport {
|
||||||
|
signed: false,
|
||||||
|
key_matches: false,
|
||||||
|
signature_valid: false,
|
||||||
|
records_match: false,
|
||||||
|
settings_match: false,
|
||||||
|
sessions_match: false,
|
||||||
|
graph_match: false,
|
||||||
|
changed_records: Vec::new(),
|
||||||
|
record_count: current.record_count,
|
||||||
|
signed_record_count: 0,
|
||||||
|
public_key: None,
|
||||||
|
wal_entries_unsigned,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let key_matches = stored.public_key == trusted.to_bytes();
|
||||||
|
let signature_valid = trusted
|
||||||
|
.verify(
|
||||||
|
&stored.manifest.signed_bytes(),
|
||||||
|
&Signature::from_bytes(&stored.signature),
|
||||||
|
)
|
||||||
|
.is_ok();
|
||||||
|
// The stored per-record hashes can localise a change only if they are
|
||||||
|
// the ones that were signed.
|
||||||
|
let hashes_authentic = signature_valid
|
||||||
|
&& stored.record_hashes.len() as u64 == stored.manifest.record_count
|
||||||
|
&& merkle_root(&stored.record_hashes) == stored.manifest.records_root;
|
||||||
|
let changed_records = if hashes_authentic {
|
||||||
|
let n = current_hashes.len().max(stored.record_hashes.len());
|
||||||
|
(0..n)
|
||||||
|
.filter(|&i| current_hashes.get(i) != stored.record_hashes.get(i))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(VerifyReport {
|
||||||
|
signed: true,
|
||||||
|
key_matches,
|
||||||
|
signature_valid,
|
||||||
|
records_match: signature_valid
|
||||||
|
&& current.record_count == stored.manifest.record_count
|
||||||
|
&& current.records_root == stored.manifest.records_root,
|
||||||
|
settings_match: signature_valid && current.settings == stored.manifest.settings,
|
||||||
|
sessions_match: signature_valid && current.sessions == stored.manifest.sessions,
|
||||||
|
graph_match: signature_valid && current.graph == stored.manifest.graph,
|
||||||
|
changed_records,
|
||||||
|
record_count: current.record_count,
|
||||||
|
signed_record_count: stored.manifest.record_count,
|
||||||
|
public_key: Some(stored.public_key),
|
||||||
|
wal_entries_unsigned,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_wal_entries_after(store: &std::path::Path, mark: Option<WalMark>) -> usize {
|
||||||
|
let wal = store.with_extension("h5.wal");
|
||||||
|
if !wal.exists() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
crate::wal::WalFile::read_entries_for_migration(&wal, mark)
|
||||||
|
.map(|e| e.len())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new random signing key from the operating system's RNG.
|
||||||
|
pub fn generate_key() -> SigningKey {
|
||||||
|
SigningKey::generate(&mut rand_core::OsRng)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hex encoding for keys and signatures in attributes and the CLI.
|
||||||
|
pub fn to_hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse hex into exactly `N` bytes.
|
||||||
|
pub fn from_hex<const N: usize>(s: &str) -> Option<[u8; N]> {
|
||||||
|
let s = s.trim();
|
||||||
|
if s.len() != 2 * N {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = [0u8; N];
|
||||||
|
for (i, byte) in out.iter_mut().enumerate() {
|
||||||
|
*byte = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ 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(
|
||||||
@@ -20,7 +21,50 @@ pub fn write_to_disk(
|
|||||||
sessions: &SessionCache,
|
sessions: &SessionCache,
|
||||||
knowledge: &KnowledgeCache,
|
knowledge: &KnowledgeCache,
|
||||||
) -> Result<(), MemoryError> {
|
) -> Result<(), MemoryError> {
|
||||||
let bytes = schema::build_hdf5_file(config, cache, sessions, knowledge)?;
|
write_to_disk_with_mark(path, config, cache, sessions, knowledge, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`write_to_disk`] for a checkpoint: `wal_applied` is the mark of the WAL
|
||||||
|
/// prefix whose entries `cache` already contains.
|
||||||
|
pub fn write_to_disk_with_mark(
|
||||||
|
path: &Path,
|
||||||
|
config: &MemoryConfig,
|
||||||
|
cache: &MemoryCache,
|
||||||
|
sessions: &SessionCache,
|
||||||
|
knowledge: &KnowledgeCache,
|
||||||
|
wal_applied: Option<WalMark>,
|
||||||
|
) -> Result<(), MemoryError> {
|
||||||
|
let meta = schema::CheckpointMeta {
|
||||||
|
wal_applied,
|
||||||
|
..schema::CheckpointMeta::default()
|
||||||
|
};
|
||||||
|
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> {
|
||||||
|
write_to_disk_signed(path, config, cache, sessions, knowledge, checkpoint, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`write_to_disk_with_meta`] with a signed manifest of the contents.
|
||||||
|
pub fn write_to_disk_signed(
|
||||||
|
path: &Path,
|
||||||
|
config: &MemoryConfig,
|
||||||
|
cache: &MemoryCache,
|
||||||
|
sessions: &SessionCache,
|
||||||
|
knowledge: &KnowledgeCache,
|
||||||
|
checkpoint: &schema::CheckpointMeta,
|
||||||
|
signature: Option<&crate::signing::StoredSignature>,
|
||||||
|
) -> Result<(), MemoryError> {
|
||||||
|
let bytes =
|
||||||
|
schema::build_hdf5_file_signed(config, cache, sessions, knowledge, checkpoint, signature)?;
|
||||||
|
|
||||||
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()));
|
||||||
@@ -28,9 +72,41 @@ pub fn write_to_disk(
|
|||||||
|
|
||||||
// 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");
|
||||||
std::fs::write(&tmp_path, &bytes).map_err(MemoryError::Io)?;
|
write_synced(&tmp_path, &bytes)?;
|
||||||
std::fs::rename(&tmp_path, path).map_err(MemoryError::Io)?;
|
rename_synced(&tmp_path, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `bytes` to `path` and flush them to stable storage.
|
||||||
|
pub(crate) fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), MemoryError> {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut f = std::fs::File::create(path).map_err(MemoryError::Io)?;
|
||||||
|
f.write_all(bytes).map_err(MemoryError::Io)?;
|
||||||
|
f.sync_all().map_err(MemoryError::Io)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename `from` over `to`, then sync the parent directory so the rename
|
||||||
|
/// itself survives a power loss. `from` must already be synced: without that,
|
||||||
|
/// the rename can reach disk before the data and leave an empty or partial
|
||||||
|
/// file under the final name.
|
||||||
|
///
|
||||||
|
/// This is per-checkpoint/snapshot cost only (each is already a full file
|
||||||
|
/// write). Individual WAL appends are deliberately not synced — see the
|
||||||
|
/// durability notes in the crate docs.
|
||||||
|
pub(crate) fn rename_synced(from: &Path, to: &Path) -> Result<(), MemoryError> {
|
||||||
|
std::fs::rename(from, to).map_err(MemoryError::Io)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
if let Some(dir) = to.parent() {
|
||||||
|
let dir = if dir.as_os_str().is_empty() {
|
||||||
|
Path::new(".")
|
||||||
|
} else {
|
||||||
|
dir
|
||||||
|
};
|
||||||
|
// Directory fsync is best-effort: some filesystems refuse it, and the
|
||||||
|
// rename has already happened.
|
||||||
|
if let Ok(d) = std::fs::File::open(dir) {
|
||||||
|
let _ = d.sync_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,19 +118,39 @@ pub fn write_to_disk(
|
|||||||
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> {
|
||||||
let mmap = clawhdf5_io::MmapReader::open(path).map_err(MemoryError::Io)?;
|
read_from_disk_with_mark(path).map(|(state, _mark)| state)
|
||||||
|
}
|
||||||
|
|
||||||
// Advise the OS we'll need the whole file for parsing
|
/// Everything [`read_from_disk`] returns.
|
||||||
mmap.advise_willneed(0, mmap.len());
|
pub type StoreState = (MemoryConfig, MemoryCache, SessionCache, KnowledgeCache);
|
||||||
|
|
||||||
// Parse the HDF5 file from the mmap'd bytes
|
/// [`read_from_disk`], plus the checkpoint's [`WalMark`] (if any) so the
|
||||||
let file = clawhdf5::File::from_bytes(mmap.as_bytes().to_vec())
|
/// caller can skip WAL entries this file already contains.
|
||||||
|
pub fn read_from_disk_with_mark(path: &Path) -> Result<(StoreState, Option<WalMark>), MemoryError> {
|
||||||
|
// `File::open` memory-maps the file itself (the facade's `mmap` feature is
|
||||||
|
// on by default). Mapping it here and handing over `as_bytes().to_vec()`
|
||||||
|
// did the same work and then copied the whole store — a second full copy
|
||||||
|
// of the file, live for the whole parse, on top of the mapping.
|
||||||
|
let file = clawhdf5::File::open(path)
|
||||||
.map_err(|e| MemoryError::Hdf5(format!("cannot open {}: {e}", path.display())))?;
|
.map_err(|e| MemoryError::Hdf5(format!("cannot open {}: {e}", path.display())))?;
|
||||||
|
|
||||||
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))
|
Ok(((config, cache, sessions, knowledge), wal_applied))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`read_from_disk`], plus all checkpoint bookkeeping.
|
||||||
|
pub fn read_from_disk_with_meta(
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<(StoreState, schema::CheckpointMeta), MemoryError> {
|
||||||
|
let file = clawhdf5::File::open(path)
|
||||||
|
.map_err(|e| MemoryError::Hdf5(format!("cannot open {}: {e}", path.display())))?;
|
||||||
|
let (mut config, cache, sessions, knowledge) = schema::validate_and_load(&file)?;
|
||||||
|
config.path = path.to_path_buf();
|
||||||
|
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.
|
||||||
@@ -78,7 +174,10 @@ 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::rename(&tmp_path, &dest_file).map_err(MemoryError::Io)?;
|
std::fs::File::open(&tmp_path)
|
||||||
|
.and_then(|f| f.sync_all())
|
||||||
|
.map_err(MemoryError::Io)?;
|
||||||
|
rename_synced(&tmp_path, &dest_file)?;
|
||||||
|
|
||||||
Ok(dest_file)
|
Ok(dest_file)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//! Single-writer guard for a memory store.
|
||||||
|
//!
|
||||||
|
//! `HDF5Memory` keeps the whole store in memory and rewrites the `.h5` file at
|
||||||
|
//! every checkpoint, so two handles on one store (two processes, or two opens
|
||||||
|
//! in one process) silently destroy each other's data: whoever checkpoints
|
||||||
|
//! last wins, and both append to the same WAL with independent CRC chains.
|
||||||
|
//! The lock turns that into an immediate, explicit error.
|
||||||
|
|
||||||
|
use std::fs::{File, OpenOptions, TryLockError};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::MemoryError;
|
||||||
|
|
||||||
|
const LOCK_RETRIES: u32 = 25;
|
||||||
|
const LOCK_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(10);
|
||||||
|
|
||||||
|
/// An exclusive advisory lock on `<store>.h5.lock`, held for the lifetime of
|
||||||
|
/// the owning `HDF5Memory` and released when it is dropped (or when the
|
||||||
|
/// process dies — the OS drops the lock with the file descriptor, so a crash
|
||||||
|
/// never leaves a stale lock behind; the empty lock file itself is harmless).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct StoreLock {
|
||||||
|
_file: File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreLock {
|
||||||
|
pub(crate) fn lock_path(store: &Path) -> PathBuf {
|
||||||
|
store.with_extension("h5.lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn acquire(store: &Path) -> Result<Self, MemoryError> {
|
||||||
|
let path = Self::lock_path(store);
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)?;
|
||||||
|
// A previous owner may be mid-teardown (e.g. an `AsyncHDF5Memory`
|
||||||
|
// dropped without `shutdown()`: its background task releases the
|
||||||
|
// store a moment later), so give the lock a short, bounded grace
|
||||||
|
// period before reporting a genuine second writer.
|
||||||
|
let mut attempts_left = LOCK_RETRIES;
|
||||||
|
loop {
|
||||||
|
match file.try_lock() {
|
||||||
|
Ok(()) => return Ok(Self { _file: file }),
|
||||||
|
Err(TryLockError::WouldBlock) if attempts_left > 0 => {
|
||||||
|
attempts_left -= 1;
|
||||||
|
std::thread::sleep(LOCK_RETRY_DELAY);
|
||||||
|
}
|
||||||
|
Err(TryLockError::WouldBlock) => {
|
||||||
|
return Err(MemoryError::Locked(format!(
|
||||||
|
"{} is already open in this or another process (lock file {})",
|
||||||
|
store.display(),
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(TryLockError::Error(e)) => return Err(MemoryError::Io(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_acquire_fails_until_first_is_dropped() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let store = dir.path().join("s.h5");
|
||||||
|
let first = StoreLock::acquire(&store).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
StoreLock::acquire(&store),
|
||||||
|
Err(MemoryError::Locked(_))
|
||||||
|
));
|
||||||
|
drop(first);
|
||||||
|
StoreLock::acquire(&store).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,44 @@
|
|||||||
//! `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.
|
||||||
@@ -22,7 +60,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: &[Vec<f32>],
|
vectors: &(impl VectorSet + ?Sized),
|
||||||
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);
|
||||||
@@ -30,7 +68,7 @@ pub fn cosine_similarity_batch(
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let n = vectors.len();
|
let n = vectors.count();
|
||||||
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
|
||||||
@@ -42,8 +80,9 @@ 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[i]);
|
let vec_norm = clawhdf5_accel::vector_norm(vectors.row(i));
|
||||||
let score = crate::cosine_similarity_prenorm(query, query_norm, &vectors[i], vec_norm);
|
let score =
|
||||||
|
crate::cosine_similarity_prenorm(query, query_norm, vectors.row(i), vec_norm);
|
||||||
results.push((i, score));
|
results.push((i, score));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,8 +92,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[i]);
|
let vec_norm = clawhdf5_accel::vector_norm(vectors.row(i));
|
||||||
let score = crate::cosine_similarity_prenorm(query, query_norm, &vectors[i], vec_norm);
|
let score = crate::cosine_similarity_prenorm(query, query_norm, vectors.row(i), vec_norm);
|
||||||
results.push((i, score));
|
results.push((i, score));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +107,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: &[Vec<f32>],
|
vectors: &(impl VectorSet + ?Sized),
|
||||||
norms: &[f32],
|
norms: &[f32],
|
||||||
tombstones: &[u8],
|
tombstones: &[u8],
|
||||||
) -> Vec<(usize, f32)> {
|
) -> Vec<(usize, f32)> {
|
||||||
@@ -77,7 +116,7 @@ pub fn cosine_similarity_batch_prenorm(
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let n = vectors.len();
|
let n = vectors.count();
|
||||||
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 {
|
||||||
@@ -85,7 +124,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[i], vec_norm);
|
let score = crate::cosine_similarity_prenorm(query, query_norm, vectors.row(i), vec_norm);
|
||||||
results.push((i, score));
|
results.push((i, score));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +201,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: &[Vec<f32>],
|
vectors: &(impl VectorSet + Sync + ?Sized),
|
||||||
tombstones: &[u8],
|
tombstones: &[u8],
|
||||||
k: usize,
|
k: usize,
|
||||||
) -> Vec<(usize, f32)> {
|
) -> Vec<(usize, f32)> {
|
||||||
@@ -174,24 +213,27 @@ 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.len().div_ceil(num_cores);
|
let chunk_size = vectors.count().div_ceil(num_cores);
|
||||||
if chunk_size == 0 {
|
if chunk_size == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut all_results: Vec<(usize, f32)> = vectors
|
// Chunk over index ranges: the corpus may be one flat buffer rather than
|
||||||
.par_chunks(chunk_size)
|
// a slice of rows, so there is nothing to `par_chunks` over.
|
||||||
.enumerate()
|
let n = vectors.count();
|
||||||
.flat_map(|(chunk_idx, chunk)| {
|
let mut all_results: Vec<(usize, f32)> = (0..n.div_ceil(chunk_size))
|
||||||
|
.into_par_iter()
|
||||||
|
.flat_map(|chunk_idx| {
|
||||||
let base = chunk_idx * chunk_size;
|
let base = chunk_idx * chunk_size;
|
||||||
let mut local: Vec<(usize, f32)> = Vec::with_capacity(chunk.len());
|
let end = (base + chunk_size).min(n);
|
||||||
for (j, vec) in chunk.iter().enumerate() {
|
let mut local: Vec<(usize, f32)> = Vec::with_capacity(end - base);
|
||||||
let i = base + j;
|
for i in base..end {
|
||||||
if i < tombstones.len() && tombstones[i] != 0 {
|
if i < tombstones.len() && tombstones[i] != 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let vec_norm = clawhdf5_accel::vector_norm(vec);
|
let vec_norm = clawhdf5_accel::vector_norm(vectors.row(i));
|
||||||
let score = crate::cosine_similarity_prenorm(query, query_norm, vec, vec_norm);
|
let score =
|
||||||
|
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));
|
||||||
@@ -209,7 +251,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: &[Vec<f32>],
|
vectors: &(impl VectorSet + Sync + ?Sized),
|
||||||
norms: &[f32],
|
norms: &[f32],
|
||||||
tombstones: &[u8],
|
tombstones: &[u8],
|
||||||
k: usize,
|
k: usize,
|
||||||
@@ -222,23 +264,26 @@ 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.len().div_ceil(num_cores);
|
let chunk_size = vectors.count().div_ceil(num_cores);
|
||||||
if chunk_size == 0 {
|
if chunk_size == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut all_results: Vec<(usize, f32)> = vectors
|
// Chunk over index ranges: the corpus may be one flat buffer rather than
|
||||||
.par_chunks(chunk_size)
|
// a slice of rows, so there is nothing to `par_chunks` over.
|
||||||
.enumerate()
|
let n = vectors.count();
|
||||||
.flat_map(|(chunk_idx, chunk)| {
|
let mut all_results: Vec<(usize, f32)> = (0..n.div_ceil(chunk_size))
|
||||||
|
.into_par_iter()
|
||||||
|
.flat_map(|chunk_idx| {
|
||||||
let base = chunk_idx * chunk_size;
|
let base = chunk_idx * chunk_size;
|
||||||
let mut local: Vec<(usize, f32)> = Vec::with_capacity(chunk.len());
|
let end = (base + chunk_size).min(n);
|
||||||
for (j, vec) in chunk.iter().enumerate() {
|
let mut local: Vec<(usize, f32)> = Vec::with_capacity(end - base);
|
||||||
let i = base + j;
|
for i in base..end {
|
||||||
if i < tombstones.len() && tombstones[i] != 0 {
|
if i < tombstones.len() && tombstones[i] != 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let score = crate::cosine_similarity_prenorm(query, query_norm, vec, norms[i]);
|
let score =
|
||||||
|
crate::cosine_similarity_prenorm(query, query_norm, vectors.row(i), norms[i]);
|
||||||
local.push((i, score));
|
local.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));
|
||||||
|
|||||||
@@ -28,7 +28,18 @@ const WAL_HEADER_LEN: u64 = WAL_MAGIC.len() as u64 + 1 + 4;
|
|||||||
/// its target Save) — the moved/inserted entry's stored CRC was computed
|
/// 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,
|
/// 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.
|
/// so the chain breaks at that point and replay stops there.
|
||||||
const WAL_VERSION: u8 = 3;
|
const WAL_VERSION: u8 = 4;
|
||||||
|
|
||||||
|
/// The chained-CRC format before [`WalEntryType::Update`] records existed.
|
||||||
|
/// Byte-for-byte the same framing as [`WAL_VERSION`], so it is read by the
|
||||||
|
/// same code, and `WalFile::open` upgrades it in place by rewriting the
|
||||||
|
/// header's version byte (the header is not covered by the CRC chain).
|
||||||
|
///
|
||||||
|
/// The bump exists for *older binaries*: they don't know record type 0x04,
|
||||||
|
/// would treat it as a torn tail, and would truncate it — and everything
|
||||||
|
/// after it — away. An unknown header version makes them refuse the file
|
||||||
|
/// with a clear error instead.
|
||||||
|
const WAL_VERSION_CHAINED_NO_UPDATE: u8 = 3;
|
||||||
|
|
||||||
/// The previous WAL format version: still a CRC32 per entry (so a bit-flip
|
/// 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
|
/// within one entry is caught), but not chained to the previous entry's CRC
|
||||||
@@ -67,6 +78,10 @@ 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 {
|
||||||
@@ -75,6 +90,7 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +107,8 @@ 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.
|
||||||
@@ -112,6 +130,62 @@ pub struct WalFile {
|
|||||||
/// Reset to 0 by `truncate()`/`create_fresh_wal_file`, and re-derived by
|
/// Reset to 0 by `truncate()`/`create_fresh_wal_file`, and re-derived by
|
||||||
/// scanning existing entries when `open()` attaches to a non-empty file.
|
/// scanning existing entries when `open()` attaches to a non-empty file.
|
||||||
running_crc: u32,
|
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 {
|
||||||
@@ -138,7 +212,15 @@ 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 | WAL_VERSION_CHAINED_NO_UPDATE => {
|
||||||
|
if ver[0] == WAL_VERSION_CHAINED_NO_UPDATE {
|
||||||
|
// Same framing; stamp the current version so an older
|
||||||
|
// binary refuses this file rather than truncating an
|
||||||
|
// Update record it can't parse. See the constant.
|
||||||
|
f.seek(SeekFrom::Start(4))?;
|
||||||
|
f.write_all(&[WAL_VERSION])?;
|
||||||
|
f.seek(SeekFrom::Start(5))?;
|
||||||
|
}
|
||||||
let mut count_buf = [0u8; 4];
|
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 header_count = u32::from_le_bytes(count_buf);
|
||||||
@@ -147,7 +229,8 @@ impl WalFile {
|
|||||||
// be stale from deferred group-commit sync, same
|
// be stale from deferred group-commit sync, same
|
||||||
// tolerance `read_entries` already has, so the scanned
|
// tolerance `read_entries` already has, so the scanned
|
||||||
// count is also the more accurate of the two).
|
// count is also the more accurate of the two).
|
||||||
let (entries, running_crc, verified_bytes) = read_chained_entries(&mut f, 0);
|
let (entries, running_crc, verified_bytes) =
|
||||||
|
read_chained_entries(&mut f, 0, None);
|
||||||
let entry_count = if entries.is_empty() {
|
let entry_count = if entries.is_empty() {
|
||||||
header_count
|
header_count
|
||||||
} else {
|
} else {
|
||||||
@@ -190,6 +273,7 @@ impl WalFile {
|
|||||||
entry_count,
|
entry_count,
|
||||||
pending_header_sync: 0,
|
pending_header_sync: 0,
|
||||||
running_crc,
|
running_crc,
|
||||||
|
chain_len: verified_bytes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
WAL_VERSION_CRC_UNCHAINED | WAL_VERSION_LEGACY_NO_CRC => {
|
WAL_VERSION_CRC_UNCHAINED | WAL_VERSION_LEGACY_NO_CRC => {
|
||||||
@@ -201,6 +285,7 @@ impl WalFile {
|
|||||||
entry_count: 0,
|
entry_count: 0,
|
||||||
pending_header_sync: 0,
|
pending_header_sync: 0,
|
||||||
running_crc: 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}"))),
|
||||||
@@ -213,6 +298,7 @@ impl WalFile {
|
|||||||
entry_count: 0,
|
entry_count: 0,
|
||||||
pending_header_sync: 0,
|
pending_header_sync: 0,
|
||||||
running_crc: 0,
|
running_crc: 0,
|
||||||
|
chain_len: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,8 +322,20 @@ impl WalFile {
|
|||||||
4 + entry.session_id.len() +
|
4 + entry.session_id.len() +
|
||||||
4 + entry.tags.len(),
|
4 + entry.tags.len(),
|
||||||
);
|
);
|
||||||
buf.push(WalEntryType::Save as u8);
|
match entry.update_index {
|
||||||
buf.extend_from_slice(&entry.timestamp.to_le_bytes());
|
Some(index) => {
|
||||||
|
let index = u32::try_from(index).map_err(|_| {
|
||||||
|
MemoryError::Schema(format!("WAL update index {index} exceeds u32"))
|
||||||
|
})?;
|
||||||
|
buf.push(WalEntryType::Update as u8);
|
||||||
|
buf.extend_from_slice(&entry.timestamp.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&index.to_le_bytes());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
buf.push(WalEntryType::Save as u8);
|
||||||
|
buf.extend_from_slice(&entry.timestamp.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
serialize_str(&mut buf, &entry.chunk);
|
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 {
|
||||||
@@ -258,6 +356,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.running_crc = crc;
|
||||||
self.entry_count += 1;
|
self.entry_count += 1;
|
||||||
@@ -282,6 +381,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.running_crc = crc;
|
||||||
self.entry_count += 1;
|
self.entry_count += 1;
|
||||||
@@ -311,7 +411,7 @@ impl WalFile {
|
|||||||
/// legacy-no-CRC file returns a typed error instead of silently
|
/// legacy-no-CRC file returns a typed error instead of silently
|
||||||
/// downgrading to the unverified parser.
|
/// 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)
|
Self::read_entries_impl(path, false, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like [`WalFile::read_entries`], but also accepts
|
/// Like [`WalFile::read_entries`], but also accepts
|
||||||
@@ -320,13 +420,23 @@ impl WalFile {
|
|||||||
/// legitimate caller is `HDF5Memory::open`'s one-time migration of a
|
/// legitimate caller is `HDF5Memory::open`'s one-time migration of a
|
||||||
/// pre-CRC WAL file, which immediately recreates it in the current
|
/// pre-CRC WAL file, which immediately recreates it in the current
|
||||||
/// format afterward. Do not use this for anything else.
|
/// format afterward. Do not use this for anything else.
|
||||||
pub(crate) fn read_entries_for_migration(path: &Path) -> Result<Vec<WalEntry>, MemoryError> {
|
///
|
||||||
Self::read_entries_impl(path, true)
|
/// `applied` is the checkpoint mark read from the `.h5` file, if any: if
|
||||||
|
/// the WAL's chain passes through it (same byte length, same chained
|
||||||
|
/// CRC), everything up to that point is already in the `.h5` and is
|
||||||
|
/// dropped. If it never does — the normal case, because the WAL was
|
||||||
|
/// truncated after the checkpoint — every entry is returned.
|
||||||
|
pub(crate) fn read_entries_for_migration(
|
||||||
|
path: &Path,
|
||||||
|
applied: Option<WalMark>,
|
||||||
|
) -> Result<Vec<WalEntry>, MemoryError> {
|
||||||
|
Self::read_entries_impl(path, true, applied)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_entries_impl(
|
fn read_entries_impl(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
allow_legacy_no_crc: bool,
|
allow_legacy_no_crc: bool,
|
||||||
|
applied: Option<WalMark>,
|
||||||
) -> Result<Vec<WalEntry>, MemoryError> {
|
) -> Result<Vec<WalEntry>, MemoryError> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -342,8 +452,9 @@ impl WalFile {
|
|||||||
let entry_count_hint = u32::from_le_bytes([header[5], header[6], header[7], header[8]]);
|
let entry_count_hint = u32::from_le_bytes([header[5], header[6], header[7], header[8]]);
|
||||||
|
|
||||||
match header[4] {
|
match header[4] {
|
||||||
WAL_VERSION => {
|
WAL_VERSION | WAL_VERSION_CHAINED_NO_UPDATE => {
|
||||||
let (entries, _final_crc, _verified_bytes) = read_chained_entries(&mut f, 0);
|
let (entries, _final_crc, _verified_bytes) =
|
||||||
|
read_chained_entries(&mut f, 0, applied);
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
WAL_VERSION_CRC_UNCHAINED => {
|
WAL_VERSION_CRC_UNCHAINED => {
|
||||||
@@ -406,9 +517,19 @@ impl WalFile {
|
|||||||
self.entry_count = 0;
|
self.entry_count = 0;
|
||||||
self.pending_header_sync = 0;
|
self.pending_header_sync = 0;
|
||||||
self.running_crc = 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
|
||||||
@@ -448,6 +569,28 @@ 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);
|
||||||
@@ -524,7 +667,16 @@ fn chained_crc(entry_bytes: &[u8], prev_crc: u32) -> u32 {
|
|||||||
/// The byte count is what lets `open()` position an append at the end of the
|
/// 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
|
/// VERIFIED prefix rather than at end-of-file. Appending past a torn tail
|
||||||
/// writes entries that replay can never reach — see `open`.
|
/// writes entries that replay can never reach — see `open`.
|
||||||
fn read_chained_entries<R: Read>(f: &mut R, start_crc: u32) -> (Vec<WalEntry>, u32, u64) {
|
///
|
||||||
|
/// `applied`, when given, is a checkpoint mark: once the chain reaches exactly
|
||||||
|
/// that position, the entries collected so far are discarded (they are
|
||||||
|
/// already in the `.h5` file). A zero-length mark matches nothing.
|
||||||
|
fn read_chained_entries<R: Read>(
|
||||||
|
f: &mut R,
|
||||||
|
start_crc: u32,
|
||||||
|
applied: Option<WalMark>,
|
||||||
|
) -> (Vec<WalEntry>, u32, u64) {
|
||||||
|
let applied = applied.filter(|m| m.len > 0);
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
let mut running_crc = start_crc;
|
let mut running_crc = start_crc;
|
||||||
let mut verified_bytes: u64 = 0;
|
let mut verified_bytes: u64 = 0;
|
||||||
@@ -554,6 +706,14 @@ fn read_chained_entries<R: Read>(f: &mut R, start_crc: u32) -> (Vec<WalEntry>, u
|
|||||||
if let Some(entry) = entry_opt {
|
if let Some(entry) = entry_opt {
|
||||||
entries.push(entry);
|
entries.push(entry);
|
||||||
}
|
}
|
||||||
|
if applied
|
||||||
|
== Some(WalMark {
|
||||||
|
len: verified_bytes,
|
||||||
|
crc: running_crc,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
entries.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
(entries, running_crc, verified_bytes)
|
(entries, running_crc, verified_bytes)
|
||||||
}
|
}
|
||||||
@@ -615,7 +775,14 @@ fn read_one_entry<R: Read>(r: &mut R) -> Result<Option<WalEntry>, ()> {
|
|||||||
let timestamp = f64::from_le_bytes(ts_buf);
|
let timestamp = f64::from_le_bytes(ts_buf);
|
||||||
|
|
||||||
match entry_type {
|
match entry_type {
|
||||||
WalEntryType::Save => {
|
WalEntryType::Save | WalEntryType::Update => {
|
||||||
|
let update_index = if entry_type == WalEntryType::Update {
|
||||||
|
let mut idx_buf = [0u8; 4];
|
||||||
|
r.read_exact(&mut idx_buf).map_err(|_| ())?;
|
||||||
|
Some(u32::from_le_bytes(idx_buf) as usize)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let chunk = read_len_prefixed_str(r).map_err(|_| ())?;
|
let 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(|_| ())?;
|
||||||
@@ -630,6 +797,7 @@ 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 => {
|
||||||
@@ -645,6 +813,7 @@ 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),
|
||||||
@@ -668,6 +837,7 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,7 +956,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.14159, f32::MAX, f32::MIN_POSITIVE];
|
let embedding = vec![0.1, -0.2, 3.4567, f32::MAX, f32::MIN_POSITIVE];
|
||||||
{
|
{
|
||||||
let mut wal = WalFile::open(&wal_path).unwrap();
|
let mut wal = WalFile::open(&wal_path).unwrap();
|
||||||
let entry = WalEntry {
|
let entry = WalEntry {
|
||||||
@@ -798,6 +968,7 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -932,6 +1103,148 @@ 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,
|
||||||
@@ -1228,8 +1541,7 @@ mod tests {
|
|||||||
drop(wal); // simulate a restart without ever truncating the WAL
|
drop(wal); // simulate a restart without ever truncating the WAL
|
||||||
|
|
||||||
let mut wal2 = WalFile::open(&wal_path).unwrap();
|
let mut wal2 = WalFile::open(&wal_path).unwrap();
|
||||||
wal2.append_save(&make_wal_entry("second", &[2.0]))
|
wal2.append_save(&make_wal_entry("second", &[2.0])).unwrap();
|
||||||
.unwrap();
|
|
||||||
drop(wal2);
|
drop(wal2);
|
||||||
|
|
||||||
let entries = WalFile::read_entries(&wal_path).unwrap();
|
let entries = WalFile::read_entries(&wal_path).unwrap();
|
||||||
@@ -1270,7 +1582,7 @@ mod tests {
|
|||||||
std::fs::write(&wal_path, build_legacy_v1_wal_bytes()).unwrap();
|
std::fs::write(&wal_path, build_legacy_v1_wal_bytes()).unwrap();
|
||||||
|
|
||||||
// Only the migration-only reader may read a legacy no-CRC file.
|
// Only the migration-only reader may read a legacy no-CRC file.
|
||||||
let entries = WalFile::read_entries_for_migration(&wal_path).unwrap();
|
let entries = WalFile::read_entries_for_migration(&wal_path, None).unwrap();
|
||||||
assert_eq!(entries.len(), 1);
|
assert_eq!(entries.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]);
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//! Crash-recovery matrix for `HDF5Memory`.
|
||||||
|
//!
|
||||||
|
//! A process crash leaves whatever reached the OS on disk. These tests build
|
||||||
|
//! the on-disk images such a crash can leave behind — after every operation,
|
||||||
|
//! inside the checkpoint window (new `.h5` in place, WAL not yet truncated),
|
||||||
|
//! and with the WAL torn at every possible length — then reopen each image
|
||||||
|
//! and check the recovered store against a model of what was acknowledged.
|
||||||
|
//!
|
||||||
|
//! Invariants:
|
||||||
|
//! * never a duplicated or invented record;
|
||||||
|
//! * an image taken between operations recovers *exactly* the acknowledged
|
||||||
|
//! state;
|
||||||
|
//! * a torn WAL recovers the last checkpoint plus a prefix of the operations
|
||||||
|
//! logged since.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
struct Rng(u64);
|
||||||
|
|
||||||
|
impl Rng {
|
||||||
|
fn next(&mut self) -> u64 {
|
||||||
|
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||||
|
let mut z = self.0;
|
||||||
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||||
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||||
|
z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
fn below(&mut self, n: usize) -> usize {
|
||||||
|
(self.next() % n.max(1) as u64) as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(chunk: &str, tags: &str) -> MemoryEntry {
|
||||||
|
MemoryEntry {
|
||||||
|
chunk: chunk.to_string(),
|
||||||
|
embedding: vec![1.0, 0.0, 0.0, 0.0],
|
||||||
|
source_channel: "test".into(),
|
||||||
|
timestamp: 1.0,
|
||||||
|
session_id: "s".into(),
|
||||||
|
tags: tags.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wal_path(h5: &Path) -> PathBuf {
|
||||||
|
h5.with_extension("h5.wal")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy the store (`.h5` + WAL) into a fresh directory, as a crash image.
|
||||||
|
fn image(h5: &Path, into: &TempDir, name: &str) -> PathBuf {
|
||||||
|
let dest = into.path().join(format!("{name}.h5"));
|
||||||
|
std::fs::copy(h5, &dest).unwrap();
|
||||||
|
if wal_path(h5).exists() {
|
||||||
|
std::fs::copy(wal_path(h5), wal_path(&dest)).unwrap();
|
||||||
|
}
|
||||||
|
dest
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recovered(h5: &Path) -> Vec<String> {
|
||||||
|
// Read-only: the image must not be modified, and no lock is needed.
|
||||||
|
HDF5Memory::open_read_only(h5).unwrap().cache.chunks.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply one random operation to the store and to the model.
|
||||||
|
fn step(mem: &mut HDF5Memory, model: &mut Vec<String>, rng: &mut Rng, n: usize) {
|
||||||
|
match rng.below(6) {
|
||||||
|
0 => mem.flush_wal().unwrap(),
|
||||||
|
1 if !model.is_empty() => {
|
||||||
|
// Update an existing record in place, addressed by its tag.
|
||||||
|
let idx = rng.below(model.len());
|
||||||
|
let chunk = format!("u{n}");
|
||||||
|
assert_eq!(
|
||||||
|
mem.save_or_update(entry(&chunk, &format!("tag{idx}")))
|
||||||
|
.unwrap(),
|
||||||
|
idx
|
||||||
|
);
|
||||||
|
model[idx] = chunk;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let chunk = format!("c{n}");
|
||||||
|
mem.save(entry(&chunk, &format!("tag{}", model.len())))
|
||||||
|
.unwrap();
|
||||||
|
model.push(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_after_every_operation_recovers_the_acknowledged_state() {
|
||||||
|
for seed in 0..40u64 {
|
||||||
|
let mut rng = Rng(seed);
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let images = TempDir::new().unwrap();
|
||||||
|
let mut config = MemoryConfig::new(dir.path().join("store.h5"), "agent", 4);
|
||||||
|
config.wal_enabled = true;
|
||||||
|
config.wal_max_entries = 1 + rng.below(6); // force frequent checkpoints
|
||||||
|
let h5 = config.path.clone();
|
||||||
|
let mut mem = HDF5Memory::create(config).unwrap();
|
||||||
|
let mut model = Vec::new();
|
||||||
|
|
||||||
|
for n in 0..30 {
|
||||||
|
step(&mut mem, &mut model, &mut rng, n);
|
||||||
|
let img = image(&h5, &images, &format!("s{seed}-{n}"));
|
||||||
|
assert_eq!(recovered(&img), model, "seed {seed}, after op {n}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crash_inside_the_checkpoint_window_never_duplicates() {
|
||||||
|
for seed in 0..40u64 {
|
||||||
|
let mut rng = Rng(seed ^ 0xABCD);
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let images = TempDir::new().unwrap();
|
||||||
|
let mut config = MemoryConfig::new(dir.path().join("store.h5"), "agent", 4);
|
||||||
|
config.wal_enabled = true;
|
||||||
|
config.wal_max_entries = 1000; // checkpoints only when we ask
|
||||||
|
let h5 = config.path.clone();
|
||||||
|
let mut mem = HDF5Memory::create(config).unwrap();
|
||||||
|
let mut model = Vec::new();
|
||||||
|
|
||||||
|
for round in 0..4 {
|
||||||
|
for n in 0..(1 + rng.below(6)) {
|
||||||
|
step(&mut mem, &mut model, &mut rng, round * 100 + n);
|
||||||
|
}
|
||||||
|
// The WAL as it is just before the checkpoint...
|
||||||
|
let stale_wal = images.path().join(format!("stale-{seed}-{round}.wal"));
|
||||||
|
if wal_path(&h5).exists() {
|
||||||
|
std::fs::copy(wal_path(&h5), &stale_wal).unwrap();
|
||||||
|
}
|
||||||
|
mem.flush_wal().unwrap();
|
||||||
|
// ...put back next to the NEW .h5: the crash-in-the-window image.
|
||||||
|
let img = image(&h5, &images, &format!("w{seed}-{round}"));
|
||||||
|
if stale_wal.exists() {
|
||||||
|
std::fs::copy(&stale_wal, wal_path(&img)).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(recovered(&img), model, "seed {seed}, round {round}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn torn_wal_recovers_checkpoint_plus_a_prefix() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let images = TempDir::new().unwrap();
|
||||||
|
let mut config = MemoryConfig::new(dir.path().join("store.h5"), "agent", 4);
|
||||||
|
config.wal_enabled = true;
|
||||||
|
config.wal_max_entries = 1000;
|
||||||
|
let h5 = config.path.clone();
|
||||||
|
let mut mem = HDF5Memory::create(config).unwrap();
|
||||||
|
|
||||||
|
for name in ["a", "b"] {
|
||||||
|
mem.save(entry(name, name)).unwrap();
|
||||||
|
}
|
||||||
|
mem.flush_wal().unwrap();
|
||||||
|
let checkpointed = vec!["a".to_string(), "b".to_string()];
|
||||||
|
|
||||||
|
// States the store passes through as each later op is logged.
|
||||||
|
let mut states = vec![checkpointed.clone()];
|
||||||
|
let mut model = checkpointed.clone();
|
||||||
|
mem.save(entry("c", "c")).unwrap();
|
||||||
|
model.push("c".into());
|
||||||
|
states.push(model.clone());
|
||||||
|
mem.save_or_update(entry("a2", "a")).unwrap();
|
||||||
|
model[0] = "a2".into();
|
||||||
|
states.push(model.clone());
|
||||||
|
mem.save(entry("d", "d")).unwrap();
|
||||||
|
model.push("d".into());
|
||||||
|
states.push(model.clone());
|
||||||
|
|
||||||
|
let full_wal = std::fs::read(wal_path(&h5)).unwrap();
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
for len in 0..=full_wal.len() {
|
||||||
|
let img = image(&h5, &images, &format!("t{len}"));
|
||||||
|
std::fs::write(wal_path(&img), &full_wal[..len]).unwrap();
|
||||||
|
let got = recovered(&img);
|
||||||
|
let which = states
|
||||||
|
.iter()
|
||||||
|
.position(|s| *s == got)
|
||||||
|
.unwrap_or_else(|| panic!("WAL torn at {len} bytes recovered {got:?}"));
|
||||||
|
seen.insert(which);
|
||||||
|
}
|
||||||
|
// Every intermediate state is reachable, and the full WAL gives the last.
|
||||||
|
assert_eq!(seen.into_iter().collect::<Vec<_>>(), [0, 1, 2, 3]);
|
||||||
|
}
|
||||||
@@ -196,7 +196,7 @@ fn test_migration_round_trip() {
|
|||||||
mem.add_relation(e1, e2, "discusses", 0.8).unwrap();
|
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(&path).unwrap();
|
let reopened = HDF5Memory::open_read_only(&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(&path).unwrap();
|
let reopened = HDF5Memory::open_read_only(&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(&path).unwrap();
|
let reopened = HDF5Memory::open_read_only(&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(&path).unwrap();
|
let orig_mem = HDF5Memory::open_read_only(&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(&path).unwrap();
|
let reopened = HDF5Memory::open_read_only(&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(&path).unwrap();
|
let reopened = HDF5Memory::open_read_only(&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(&path).unwrap();
|
let reopened = HDF5Memory::open_read_only(&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(&path).unwrap();
|
let reopened = HDF5Memory::open_read_only(&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(&vec![0.0, 0.0], &vectors, &tombstones, 3);
|
let results = gpu.search_l2(&[0.0, 0.0], &vectors, &tombstones, 3);
|
||||||
|
|
||||||
assert_eq!(results.len(), 3);
|
assert_eq!(results.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.len() > 0);
|
assert!(!mmap.is_empty());
|
||||||
// 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());
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,300 @@
|
|||||||
|
//! `MemoryConfig::float16`: embeddings stored as IEEE half precision.
|
||||||
|
//!
|
||||||
|
//! The setting used to be recorded in `/meta` and otherwise ignored — the
|
||||||
|
//! embeddings dataset was always `f32`. These tests pin what it now does: the
|
||||||
|
//! dataset is `float16`, the in-memory cache holds exactly the values the file
|
||||||
|
//! holds (so search results survive a reopen bit for bit), and a value half
|
||||||
|
//! precision cannot represent is refused rather than stored as infinity.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry, MemoryError};
|
||||||
|
use clawhdf5_format::float16::round_to_f16;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
const DIM: usize = 64;
|
||||||
|
|
||||||
|
/// Deterministic, embedding-like unit vectors.
|
||||||
|
fn embedding(seed: u64) -> Vec<f32> {
|
||||||
|
let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
|
||||||
|
let v: Vec<f32> = (0..DIM)
|
||||||
|
.map(|_| {
|
||||||
|
x ^= x << 13;
|
||||||
|
x ^= x >> 7;
|
||||||
|
x ^= x << 17;
|
||||||
|
(x >> 40) as f32 / (1u64 << 24) as f32 - 0.5
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let norm = v.iter().map(|a| a * a).sum::<f32>().sqrt();
|
||||||
|
v.iter().map(|a| a / norm).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(i: u64) -> MemoryEntry {
|
||||||
|
MemoryEntry {
|
||||||
|
chunk: format!("memory number {i} about topic {}", i % 7),
|
||||||
|
embedding: embedding(i),
|
||||||
|
source_channel: "test".into(),
|
||||||
|
timestamp: i as f64,
|
||||||
|
session_id: "s".into(),
|
||||||
|
tags: format!("t{i}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config(dir: &TempDir, name: &str, float16: bool) -> MemoryConfig {
|
||||||
|
let mut c = MemoryConfig::new(dir.path().join(name), "agent", DIM);
|
||||||
|
c.float16 = float16;
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
fn embeddings_dtype_and_values(path: &Path) -> (String, Vec<f32>) {
|
||||||
|
let file = clawhdf5::File::open(path).unwrap();
|
||||||
|
let ds = file.dataset("memory/embeddings").unwrap();
|
||||||
|
(format!("{:?}", ds.dtype().unwrap()), ds.read_f32().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_bits(m: &mut HDF5Memory, q: u64) -> Vec<(usize, u32)> {
|
||||||
|
m.hybrid_search(&embedding(q), "memory topic 3", 0.4, 0.6, 10)
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r.index, r.score.to_bits()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn float16_store_writes_half_precision_and_reopens_identically() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
// Two identical stores. Search is not read-only (it boosts the Hebbian
|
||||||
|
// activation of what it returns, and checkpoints persist that), so each
|
||||||
|
// is queried exactly once: one live, one after a checkpoint and reopen.
|
||||||
|
let live_cfg = config(&dir, "live.h5", true);
|
||||||
|
let cfg = config(&dir, "f16.h5", true);
|
||||||
|
let path: PathBuf = cfg.path.clone();
|
||||||
|
|
||||||
|
let mut live = HDF5Memory::create(live_cfg).unwrap();
|
||||||
|
live.save_batch((0..200).map(entry).collect()).unwrap();
|
||||||
|
let mut m = HDF5Memory::create(cfg).unwrap();
|
||||||
|
m.save_batch((0..200).map(entry).collect()).unwrap();
|
||||||
|
drop(m);
|
||||||
|
|
||||||
|
// On disk: a genuine float16 dataset holding the rounded inputs.
|
||||||
|
let (dtype, values) = embeddings_dtype_and_values(&path);
|
||||||
|
assert_eq!(dtype, "Other(\"float16\")");
|
||||||
|
let expected: Vec<u32> = (0..200)
|
||||||
|
.flat_map(|i| embedding(i).into_iter().map(|v| round_to_f16(v).to_bits()))
|
||||||
|
.collect();
|
||||||
|
let got: Vec<u32> = values.iter().map(|v| v.to_bits()).collect();
|
||||||
|
assert_eq!(got, expected);
|
||||||
|
|
||||||
|
// Reopened, the store answers exactly as the live one does: the cache
|
||||||
|
// held the half-rounded values before the checkpoint.
|
||||||
|
let mut reopened = HDF5Memory::open(&path).unwrap();
|
||||||
|
for q in 0..5 {
|
||||||
|
assert_eq!(
|
||||||
|
search_bits(&mut live, 1000 + q),
|
||||||
|
search_bits(&mut reopened, 1000 + q),
|
||||||
|
"query {q}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn float16_halves_the_embeddings_on_disk() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let mut sizes = Vec::new();
|
||||||
|
for float16 in [false, true] {
|
||||||
|
let cfg = config(&dir, &format!("s{float16}.h5"), float16);
|
||||||
|
let path = cfg.path.clone();
|
||||||
|
let mut m = HDF5Memory::create(cfg).unwrap();
|
||||||
|
m.save_batch((0..2000).map(entry).collect()).unwrap();
|
||||||
|
drop(m);
|
||||||
|
sizes.push(std::fs::metadata(&path).unwrap().len());
|
||||||
|
}
|
||||||
|
let embedding_bytes_f32 = (2000 * DIM * 4) as u64;
|
||||||
|
let saved = sizes[0] - sizes[1];
|
||||||
|
// Half of the f32 embeddings, give or take metadata and alignment.
|
||||||
|
assert!(
|
||||||
|
saved.abs_diff(embedding_bytes_f32 / 2) < 16 * 1024,
|
||||||
|
"f32 {} B, f16 {} B, saved {saved} B, expected ~{} B",
|
||||||
|
sizes[0],
|
||||||
|
sizes[1],
|
||||||
|
embedding_bytes_f32 / 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f32_store_is_unchanged() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let cfg = config(&dir, "f32.h5", false);
|
||||||
|
let path = cfg.path.clone();
|
||||||
|
let mut m = HDF5Memory::create(cfg).unwrap();
|
||||||
|
m.save_batch((0..50).map(entry).collect()).unwrap();
|
||||||
|
drop(m);
|
||||||
|
let (dtype, values) = embeddings_dtype_and_values(&path);
|
||||||
|
assert_eq!(dtype, "F32");
|
||||||
|
let expected: Vec<f32> = (0..50).flat_map(embedding).collect();
|
||||||
|
assert_eq!(values, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_range_values_are_refused_not_stored_as_infinity() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let mut cfg = config(&dir, "range.h5", true);
|
||||||
|
cfg.wal_enabled = true;
|
||||||
|
let path = cfg.path.clone();
|
||||||
|
let mut m = HDF5Memory::create(cfg).unwrap();
|
||||||
|
m.save(entry(1)).unwrap();
|
||||||
|
|
||||||
|
let mut bad = entry(2);
|
||||||
|
bad.embedding[5] = 70_000.0;
|
||||||
|
match m.save(bad.clone()) {
|
||||||
|
Err(MemoryError::InvalidEntry(msg)) => assert!(msg.contains("embedding[5]"), "{msg}"),
|
||||||
|
other => panic!("expected InvalidEntry, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
m.save_or_update(bad.clone()),
|
||||||
|
Err(MemoryError::InvalidEntry(_))
|
||||||
|
));
|
||||||
|
// A batch is all or nothing.
|
||||||
|
assert!(matches!(
|
||||||
|
m.save_batch(vec![entry(3), bad.clone(), entry(4)]),
|
||||||
|
Err(MemoryError::InvalidEntry(_))
|
||||||
|
));
|
||||||
|
assert_eq!(m.count(), 1);
|
||||||
|
|
||||||
|
// The largest finite half, and values that round down to it, are fine.
|
||||||
|
let mut edge = entry(5);
|
||||||
|
edge.embedding[0] = 65504.0;
|
||||||
|
edge.embedding[1] = -65519.0;
|
||||||
|
m.save(edge).unwrap();
|
||||||
|
assert_eq!(m.count(), 2);
|
||||||
|
drop(m);
|
||||||
|
|
||||||
|
// Nothing rejected reached the WAL or the file.
|
||||||
|
let m = HDF5Memory::open(&path).unwrap();
|
||||||
|
assert_eq!(m.count(), 2);
|
||||||
|
|
||||||
|
// An f32 store takes the same value as it always did.
|
||||||
|
let mut m32 = HDF5Memory::create(config(&dir, "range32.h5", false)).unwrap();
|
||||||
|
m32.save(bad).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wal_replay_rounds_like_a_live_save() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let mut cfg = config(&dir, "wal.h5", true);
|
||||||
|
cfg.wal_enabled = true;
|
||||||
|
cfg.wal_max_entries = 10_000; // keep everything in the WAL
|
||||||
|
let path = cfg.path.clone();
|
||||||
|
let mut m = HDF5Memory::create(cfg).unwrap();
|
||||||
|
for i in 0..30 {
|
||||||
|
m.save(entry(i)).unwrap();
|
||||||
|
}
|
||||||
|
let live = search_bits(&mut m, 77);
|
||||||
|
|
||||||
|
// Crash image: the .h5 is still the empty checkpoint; everything is in
|
||||||
|
// the WAL, which holds the caller's f32 values.
|
||||||
|
let crash = TempDir::new().unwrap();
|
||||||
|
let image = crash.path().join("image.h5");
|
||||||
|
std::fs::copy(&path, &image).unwrap();
|
||||||
|
std::fs::copy(
|
||||||
|
path.with_extension("h5.wal"),
|
||||||
|
image.with_extension("h5.wal"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
drop(m);
|
||||||
|
|
||||||
|
let mut recovered = HDF5Memory::open(&image).unwrap();
|
||||||
|
assert_eq!(recovered.count(), 30);
|
||||||
|
assert_eq!(search_bits(&mut recovered, 77), live);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_stores_default_to_float16() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("default.h5");
|
||||||
|
let mut m = HDF5Memory::create(MemoryConfig::new(path.clone(), "agent", DIM)).unwrap();
|
||||||
|
assert!(m.config().float16);
|
||||||
|
m.save_batch((0..10).map(entry).collect()).unwrap();
|
||||||
|
drop(m);
|
||||||
|
assert_eq!(embeddings_dtype_and_values(&path).0, "Other(\"float16\")");
|
||||||
|
assert!(HDF5Memory::open(&path).unwrap().config().float16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_existing_f32_store_stays_f32() {
|
||||||
|
// Written by the v2.5.0 CLI, with `float16 = 0` in /meta (every agent
|
||||||
|
// store has recorded it). Flipping the default for new stores must not
|
||||||
|
// reach back and round an existing store's embeddings.
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("legacy.h5");
|
||||||
|
std::fs::copy(
|
||||||
|
concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/tests/fixtures/store_v2_5_0.h5"
|
||||||
|
),
|
||||||
|
&path,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let before = embeddings_dtype_and_values(&path);
|
||||||
|
assert_eq!(before.0, "F32");
|
||||||
|
|
||||||
|
let mut m = HDF5Memory::open(&path).unwrap();
|
||||||
|
assert!(!m.config().float16, "an old store must reopen as f32");
|
||||||
|
let dim = m.config().embedding_dim;
|
||||||
|
let odd: Vec<f32> = (0..dim).map(|i| 0.1 + i as f32 * 1e-4).collect();
|
||||||
|
m.save_batch(vec![MemoryEntry {
|
||||||
|
chunk: "added after the upgrade".into(),
|
||||||
|
embedding: odd.clone(),
|
||||||
|
source_channel: "test".into(),
|
||||||
|
timestamp: 1.0,
|
||||||
|
session_id: "s".into(),
|
||||||
|
tags: String::new(),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
drop(m);
|
||||||
|
|
||||||
|
// Checkpointed: still f32, the old rows untouched and the new one exact.
|
||||||
|
let (dtype, values) = embeddings_dtype_and_values(&path);
|
||||||
|
assert_eq!(dtype, "F32");
|
||||||
|
assert_eq!(&values[..before.1.len()], before.1.as_slice());
|
||||||
|
assert_eq!(&values[before.1.len()..], odd.as_slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Group::attrs` leaves out an attribute it cannot decode. A store whose
|
||||||
|
/// `float16` setting is unreadable must not open as `float16 = false` (or with
|
||||||
|
/// any other default in place of a setting it has): it is an error.
|
||||||
|
#[test]
|
||||||
|
fn unreadable_meta_attribute_fails_open_instead_of_defaulting() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("store.h5");
|
||||||
|
{
|
||||||
|
let mut m = HDF5Memory::create(config(&dir, "store.h5", true)).unwrap();
|
||||||
|
m.save(entry(1)).unwrap();
|
||||||
|
m.flush_wal().unwrap();
|
||||||
|
}
|
||||||
|
assert!(HDF5Memory::open_read_only(&path).is_ok());
|
||||||
|
|
||||||
|
// Give the `float16` attribute message an unknown version (the name is
|
||||||
|
// at +8 in a version-1 message and +9 in a version-3 one).
|
||||||
|
let mut bytes = std::fs::read(&path).unwrap();
|
||||||
|
let name = b"float16\0";
|
||||||
|
let mut hit = false;
|
||||||
|
let positions: Vec<usize> = (9..bytes.len() - name.len())
|
||||||
|
.filter(|&p| &bytes[p..p + name.len()] == name)
|
||||||
|
.collect();
|
||||||
|
for pos in positions {
|
||||||
|
for (back, version) in [(8, 1u8), (9, 3u8)] {
|
||||||
|
if bytes[pos - back] == version {
|
||||||
|
bytes[pos - back] = 0x7f;
|
||||||
|
hit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(hit, "float16 attribute message not found");
|
||||||
|
std::fs::write(&path, &bytes).unwrap();
|
||||||
|
|
||||||
|
match HDF5Memory::open_read_only(&path) {
|
||||||
|
Err(MemoryError::Schema(msg)) => assert!(msg.contains("/meta"), "{msg}"),
|
||||||
|
Err(e) => panic!("unexpected error: {e}"),
|
||||||
|
Ok(_) => panic!("store opened with an unreadable float16 setting"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! An agent store is a standard HDF5 file: h5py can open it and read every
|
||||||
|
//! dataset.
|
||||||
|
//!
|
||||||
|
//! It could not: the float datatype's sign-bit position was hard-coded for
|
||||||
|
//! f64, so every f32 dataset (embeddings, norms, activation weights) made
|
||||||
|
//! libhdf5 refuse the file with "sign bit position out of bounds".
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
||||||
|
|
||||||
|
fn python() -> String {
|
||||||
|
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn h5py_available() -> bool {
|
||||||
|
Command::new(python())
|
||||||
|
.args(["-c", "import h5py"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn h5py_reads_every_dataset_of_an_agent_store() {
|
||||||
|
if !h5py_available() {
|
||||||
|
assert!(
|
||||||
|
std::env::var("CLAWHDF5_REQUIRE_INTEROP").as_deref() != Ok("1"),
|
||||||
|
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
|
||||||
|
);
|
||||||
|
eprintln!("SKIP: python3 with h5py not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
for float16 in [false, true] {
|
||||||
|
let path = dir.path().join(format!("store_{float16}.h5"));
|
||||||
|
let mut cfg = MemoryConfig::new(path.clone(), "agent", 8);
|
||||||
|
cfg.float16 = float16;
|
||||||
|
let mut m = HDF5Memory::create(cfg).unwrap();
|
||||||
|
// save_batch checkpoints, so the records are in the .h5, not the WAL.
|
||||||
|
m.save_batch(
|
||||||
|
(0..20)
|
||||||
|
.map(|i| MemoryEntry {
|
||||||
|
chunk: format!("memory {i}"),
|
||||||
|
embedding: (0..8).map(|j| ((i * 8 + j) as f32).sin()).collect(),
|
||||||
|
source_channel: "test".into(),
|
||||||
|
timestamp: i as f64,
|
||||||
|
session_id: "s".into(),
|
||||||
|
tags: String::new(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
drop(m);
|
||||||
|
|
||||||
|
// Exact expected values, as bits: numpy's sin need not match Rust's
|
||||||
|
// to the last place.
|
||||||
|
let bits = (0..160)
|
||||||
|
.map(|k| (k as f32).sin().to_bits().to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
import h5py, numpy as np
|
||||||
|
want = np.float16 if {py_bool} else np.float32
|
||||||
|
with h5py.File("{path}", "r") as f:
|
||||||
|
names = []
|
||||||
|
f.visititems(lambda n, o: names.append(n) if isinstance(o, h5py.Dataset) else None)
|
||||||
|
for n in names:
|
||||||
|
f[n][()] # every dataset must decode
|
||||||
|
e = f["memory/embeddings"]
|
||||||
|
assert e.dtype == want, e.dtype
|
||||||
|
assert e.shape == (20, 8), e.shape
|
||||||
|
ref = np.array([{bits}], dtype=np.uint32).view(np.float32).astype(want).reshape(20, 8)
|
||||||
|
assert (e[()] == ref).all()
|
||||||
|
assert f["memory/norms"].dtype == np.float32
|
||||||
|
print(len(names))
|
||||||
|
"#,
|
||||||
|
py_bool = if float16 { "True" } else { "False" },
|
||||||
|
path = path.display()
|
||||||
|
);
|
||||||
|
let out = Command::new(python())
|
||||||
|
.args(["-c", &script])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"float16={float16}: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let n: usize = String::from_utf8_lossy(&out.stdout).trim().parse().unwrap();
|
||||||
|
assert!(n >= 10, "only {n} datasets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edit_made_with_h5py_breaks_the_signature_and_names_the_record() {
|
||||||
|
if !h5py_available() {
|
||||||
|
assert!(
|
||||||
|
std::env::var("CLAWHDF5_REQUIRE_INTEROP").as_deref() != Ok("1"),
|
||||||
|
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
|
||||||
|
);
|
||||||
|
eprintln!("SKIP: python3 with h5py not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
use clawhdf5_agent::signing::SigningKey;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("signed.h5");
|
||||||
|
let key = SigningKey::from_bytes(&[42; 32]);
|
||||||
|
let mut m = HDF5Memory::create(MemoryConfig::new(path.clone(), "agent", 8)).unwrap();
|
||||||
|
m.set_signing_key(key.clone());
|
||||||
|
m.save_batch(
|
||||||
|
(0..10)
|
||||||
|
.map(|i| MemoryEntry {
|
||||||
|
chunk: format!("memory {i}"),
|
||||||
|
embedding: (0..8).map(|j| ((i * 8 + j) as f32).cos()).collect(),
|
||||||
|
source_channel: "test".into(),
|
||||||
|
timestamp: i as f64,
|
||||||
|
session_id: "s".into(),
|
||||||
|
tags: String::new(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
drop(m);
|
||||||
|
assert!(
|
||||||
|
HDF5Memory::verify(&path, &key.verifying_key())
|
||||||
|
.unwrap()
|
||||||
|
.is_valid()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Someone edits one timestamp in place with h5py.
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
import h5py
|
||||||
|
with h5py.File("{}", "r+") as f:
|
||||||
|
ts = f["memory/timestamps"]
|
||||||
|
ts[3] = 12345.0
|
||||||
|
"#,
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
let out = Command::new(python())
|
||||||
|
.args(["-c", &script])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let r = HDF5Memory::verify(&path, &key.verifying_key()).unwrap();
|
||||||
|
assert!(r.signature_valid && !r.is_valid(), "{r:?}");
|
||||||
|
assert_eq!(r.changed_records, vec![3]);
|
||||||
|
}
|
||||||
@@ -165,3 +165,182 @@ fn save_batch_then_search_is_consistent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quantized_index_matches_the_f32_index_after_re_scoring() {
|
||||||
|
// A quantised index holds approximate vectors, but the store still has the
|
||||||
|
// exact ones, so the query path re-scores the candidate pool before
|
||||||
|
// fusion. The results a caller sees should therefore be the same.
|
||||||
|
let dim = 64;
|
||||||
|
let n = 400;
|
||||||
|
let mut seed = 0x5EED_1234_5678_9ABC;
|
||||||
|
let vectors: Vec<Vec<f32>> = (0..n).map(|_| make_vector(&mut seed, dim)).collect();
|
||||||
|
let queries: Vec<Vec<f32>> = (0..20).map(|_| make_vector(&mut seed, dim)).collect();
|
||||||
|
|
||||||
|
let build = |dir: &TempDir, quantized: bool| {
|
||||||
|
let mut config = MemoryConfig::new(dir.path().join("mem.h5"), "agent", dim);
|
||||||
|
config.quantized_index = quantized;
|
||||||
|
let mut mem = HDF5Memory::create(config).unwrap();
|
||||||
|
for (i, v) in vectors.iter().enumerate() {
|
||||||
|
mem.save(entry(&format!("chunk {i}"), v.clone(), &format!("k{i}")))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
mem
|
||||||
|
};
|
||||||
|
|
||||||
|
let exact_dir = TempDir::new().unwrap();
|
||||||
|
let quant_dir = TempDir::new().unwrap();
|
||||||
|
let mut exact = build(&exact_dir, false);
|
||||||
|
let mut quantized = build(&quant_dir, true);
|
||||||
|
|
||||||
|
let k = 10;
|
||||||
|
let mut agree = 0;
|
||||||
|
for q in &queries {
|
||||||
|
let want: Vec<usize> = exact
|
||||||
|
.hybrid_search(q, "", 1.0, 0.0, k)
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.index)
|
||||||
|
.collect();
|
||||||
|
agree += quantized
|
||||||
|
.hybrid_search(q, "", 1.0, 0.0, k)
|
||||||
|
.iter()
|
||||||
|
.filter(|r| want.contains(&r.index))
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
let overlap = agree as f64 / (k * queries.len()) as f64;
|
||||||
|
assert!(
|
||||||
|
overlap >= 0.95,
|
||||||
|
"quantised store should match the f32 one: {overlap}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quantized_index_setting_survives_a_reopen() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("mem.h5");
|
||||||
|
let mut config = MemoryConfig::new(path.clone(), "agent", 8);
|
||||||
|
config.quantized_index = true;
|
||||||
|
let mut mem = HDF5Memory::create(config).unwrap();
|
||||||
|
let mut seed = 7;
|
||||||
|
for i in 0..30 {
|
||||||
|
mem.save(entry(&format!("c{i}"), make_vector(&mut seed, 8), "t"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
mem.flush_wal().unwrap();
|
||||||
|
drop(mem);
|
||||||
|
|
||||||
|
// Reopening must not silently quadruple the index's memory, so the flag
|
||||||
|
// is part of the stored config rather than a per-session choice.
|
||||||
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
|
assert!(reopened.config().quantized_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hnsw_parameters_are_configurable_and_persisted() {
|
||||||
|
// The graph degree and both candidate-list sizes used to be constants, so
|
||||||
|
// a deployment could not trade recall against memory or speed at all.
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("mem.h5");
|
||||||
|
let mut config = MemoryConfig::new(path.clone(), "agent", 16);
|
||||||
|
config.hnsw_m = 8;
|
||||||
|
config.hnsw_ef_construction = 32;
|
||||||
|
config.hnsw_ef_search = 128;
|
||||||
|
let mut mem = HDF5Memory::create(config).unwrap();
|
||||||
|
|
||||||
|
let mut seed = 99;
|
||||||
|
let vectors: Vec<Vec<f32>> = (0..300).map(|_| make_vector(&mut seed, 16)).collect();
|
||||||
|
for (i, v) in vectors.iter().enumerate() {
|
||||||
|
mem.save(entry(&format!("c{i}"), v.clone(), "t")).unwrap();
|
||||||
|
}
|
||||||
|
// Still correct with a smaller graph: an exact match must rank first.
|
||||||
|
let top = mem.hybrid_search(&vectors[42], "", 1.0, 0.0, 1);
|
||||||
|
assert_eq!(top[0].index, 42);
|
||||||
|
|
||||||
|
mem.flush_wal().unwrap();
|
||||||
|
drop(mem);
|
||||||
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
|
assert_eq!(reopened.config().hnsw_m, 8);
|
||||||
|
assert_eq!(reopened.config().hnsw_ef_construction, 32);
|
||||||
|
assert_eq!(reopened.config().hnsw_ef_search, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn degenerate_hnsw_parameters_do_not_panic() {
|
||||||
|
// `clawhdf5-ann` asserts m >= 2, so a zero from a config file — or from a
|
||||||
|
// caller who assumed 0 meant "default" — would abort the process inside
|
||||||
|
// the index builder. The store clamps instead.
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let mut config = MemoryConfig::new(dir.path().join("mem.h5"), "agent", 8);
|
||||||
|
config.hnsw_m = 0;
|
||||||
|
config.hnsw_ef_construction = 0;
|
||||||
|
config.hnsw_ef_search = 1;
|
||||||
|
let mut mem = HDF5Memory::create(config).unwrap();
|
||||||
|
|
||||||
|
let mut seed = 5;
|
||||||
|
let vectors: Vec<Vec<f32>> = (0..50).map(|_| make_vector(&mut seed, 8)).collect();
|
||||||
|
for (i, v) in vectors.iter().enumerate() {
|
||||||
|
mem.save(entry(&format!("c{i}"), v.clone(), "t")).unwrap();
|
||||||
|
}
|
||||||
|
let results = mem.hybrid_search(&vectors[7], "", 1.0, 0.0, 5);
|
||||||
|
assert_eq!(results[0].index, 7, "exact match should still rank first");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_stores_default_to_the_quantized_index() {
|
||||||
|
// int8 is the default because it is smaller and, with an exact re-score,
|
||||||
|
// faster at equal recall on every platform measured (see BENCHMARKS.md).
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let config = MemoryConfig::new(dir.path().join("mem.h5"), "agent", 8);
|
||||||
|
assert!(config.quantized_index);
|
||||||
|
|
||||||
|
let path = config.path.clone();
|
||||||
|
let mut mem = HDF5Memory::create(config).unwrap();
|
||||||
|
let mut seed = 3;
|
||||||
|
let vectors: Vec<Vec<f32>> = (0..40).map(|_| make_vector(&mut seed, 8)).collect();
|
||||||
|
for (i, v) in vectors.iter().enumerate() {
|
||||||
|
mem.save(entry(&format!("c{i}"), v.clone(), "t")).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
mem.hybrid_search(&vectors[11], "", 1.0, 0.0, 1)[0].index,
|
||||||
|
11
|
||||||
|
);
|
||||||
|
mem.flush_wal().unwrap();
|
||||||
|
drop(mem);
|
||||||
|
assert!(HDF5Memory::open(&path).unwrap().config().quantized_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_store_written_before_the_setting_existed_stays_f32() {
|
||||||
|
// `store_v2_5_0.h5` was written by the v2.5.0 CLI, before
|
||||||
|
// `quantized_index` or the HNSW parameters were persisted, so it carries
|
||||||
|
// none of them. Flipping the default for new stores must not reach back
|
||||||
|
// and change how an existing store's index is held.
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("legacy.h5");
|
||||||
|
std::fs::copy(
|
||||||
|
concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/tests/fixtures/store_v2_5_0.h5"
|
||||||
|
),
|
||||||
|
&path,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
assert!(
|
||||||
|
!bytes.windows(15).any(|w| w == b"quantized_index"),
|
||||||
|
"the fixture must predate the setting, or it tests nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut mem = HDF5Memory::open(&path).unwrap();
|
||||||
|
assert!(
|
||||||
|
!mem.config().quantized_index,
|
||||||
|
"an old store must reopen with an f32 index"
|
||||||
|
);
|
||||||
|
assert_eq!(mem.config().hnsw_m, 16);
|
||||||
|
assert_eq!(mem.config().hnsw_ef_construction, 64);
|
||||||
|
assert_eq!(mem.count(), 6);
|
||||||
|
// And it still searches: entry 3's own embedding finds it first.
|
||||||
|
let hit = mem.hybrid_search(&[3.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], "", 1.0, 0.0, 1);
|
||||||
|
assert_eq!(hit[0].index, 3);
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,10 +137,10 @@ 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()
|
||||||
if *top_idx == target_indices[qi] {
|
&& *top_idx == target_indices[qi]
|
||||||
hits += 1;
|
{
|
||||||
}
|
hits += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
//! `HDF5Memory::search` with `SearchOptions`: source filtering, re-ranking and
|
||||||
|
//! confidence rejection in the store's own search path.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use clawhdf5_agent::confidence::ConfidenceConfig;
|
||||||
|
use clawhdf5_agent::reranker::ReRankConfig;
|
||||||
|
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry, SearchOptions, hybrid};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
const DIM: usize = 32;
|
||||||
|
const N: usize = 3000;
|
||||||
|
const CLUSTERS: usize = 20;
|
||||||
|
|
||||||
|
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 unit(&mut self) -> f32 {
|
||||||
|
(self.next() >> 40) as f32 / (1u64 << 24) as f32 - 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(v: &mut [f32]) {
|
||||||
|
let n = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
v.iter_mut().for_each(|x| *x /= n);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Data {
|
||||||
|
vectors: Vec<Vec<f32>>,
|
||||||
|
cluster: Vec<usize>,
|
||||||
|
centres: Vec<Vec<f32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data() -> Data {
|
||||||
|
let mut rng = Rng(42);
|
||||||
|
let centres: Vec<Vec<f32>> = (0..CLUSTERS)
|
||||||
|
.map(|_| {
|
||||||
|
let mut c: Vec<f32> = (0..DIM).map(|_| rng.unit()).collect();
|
||||||
|
normalize(&mut c);
|
||||||
|
c
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut vectors = Vec::new();
|
||||||
|
let mut cluster = Vec::new();
|
||||||
|
for i in 0..N {
|
||||||
|
let c = i % CLUSTERS;
|
||||||
|
let mut v: Vec<f32> = centres[c].iter().map(|x| x + rng.unit() * 0.3).collect();
|
||||||
|
normalize(&mut v);
|
||||||
|
vectors.push(v);
|
||||||
|
cluster.push(c);
|
||||||
|
}
|
||||||
|
Data {
|
||||||
|
vectors,
|
||||||
|
cluster,
|
||||||
|
centres,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Channel of record `i` for a filter keeping `percent`% of the store at
|
||||||
|
/// random (independent of the vectors).
|
||||||
|
fn random_channel(i: usize, rng_seed: u64, percent: u64) -> String {
|
||||||
|
let mut r = Rng(rng_seed ^ (i as u64 * 7919));
|
||||||
|
if r.next() % 100 < percent {
|
||||||
|
"keep".into()
|
||||||
|
} else {
|
||||||
|
"other".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(data: &Data, channel: impl Fn(usize) -> String) -> (TempDir, HDF5Memory) {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let mut cfg = MemoryConfig::new(dir.path().join("s.h5"), "agent", DIM);
|
||||||
|
cfg.hebbian_boost = 0.0; // every query sees the same store
|
||||||
|
let mut m = HDF5Memory::create(cfg).unwrap();
|
||||||
|
let entries = data
|
||||||
|
.vectors
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, v)| MemoryEntry {
|
||||||
|
chunk: format!("record {i} cluster {}", data.cluster[i]),
|
||||||
|
embedding: v.clone(),
|
||||||
|
source_channel: channel(i),
|
||||||
|
timestamp: i as f64,
|
||||||
|
session_id: "s".into(),
|
||||||
|
tags: format!("t{i}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
m.save_batch(entries).unwrap();
|
||||||
|
(dir, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact top-k by cosine among the records `allowed` keeps.
|
||||||
|
fn exact_top(data: &Data, q: &[f32], k: usize, allowed: impl Fn(usize) -> bool) -> Vec<usize> {
|
||||||
|
let mut s: Vec<(usize, f32)> = (0..N)
|
||||||
|
.filter(|&i| allowed(i))
|
||||||
|
.map(|i| (i, data.vectors[i].iter().zip(q).map(|(a, b)| a * b).sum()))
|
||||||
|
.collect();
|
||||||
|
s.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||||
|
s.into_iter().take(k).map(|(i, _)| i).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query(data: &Data, i: usize) -> Vec<f32> {
|
||||||
|
let mut rng = Rng(1000 + i as u64);
|
||||||
|
let mut q: Vec<f32> = data.centres[i % CLUSTERS]
|
||||||
|
.iter()
|
||||||
|
.map(|x| x + rng.unit() * 0.3)
|
||||||
|
.collect();
|
||||||
|
normalize(&mut q);
|
||||||
|
q
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vector_only(k: usize) -> SearchOptions {
|
||||||
|
SearchOptions::new(k).with_fusion(hybrid::Fusion::Weighted {
|
||||||
|
vector: 1.0,
|
||||||
|
keyword: 0.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_filter_returns_only_allowed_records_and_a_full_page() {
|
||||||
|
let d = data();
|
||||||
|
// At N = 3000 and k = 10 the index serves a filter only when that is
|
||||||
|
// cheaper than scanning the allowed records: pool = 80 * N / allowed
|
||||||
|
// candidates at ~M = 16 distances each, against `allowed` distances. So
|
||||||
|
// 90% goes through the index, 50% and 1% to the exact scan.
|
||||||
|
for percent in [90, 50, 1] {
|
||||||
|
let (_dir, mut m) = build(&d, |i| random_channel(i, 5, percent));
|
||||||
|
let allowed = |i: usize| random_channel(i, 5, percent) == "keep";
|
||||||
|
let mut hits = 0;
|
||||||
|
for qi in 0..40 {
|
||||||
|
let q = query(&d, qi);
|
||||||
|
let got = m.search(&q, "", &vector_only(10).with_sources(["keep"]));
|
||||||
|
assert_eq!(got.len(), 10, "{percent}%: short page");
|
||||||
|
assert!(got.iter().all(|r| r.source_channel == "keep"));
|
||||||
|
let want: HashSet<usize> = exact_top(&d, &q, 10, allowed).into_iter().collect();
|
||||||
|
hits += got.iter().filter(|r| want.contains(&r.index)).count();
|
||||||
|
}
|
||||||
|
let recall = hits as f64 / 400.0;
|
||||||
|
let floor = if percent == 90 { 0.95 } else { 1.0 };
|
||||||
|
assert!(recall >= floor, "{percent}%: recall@10 {recall}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_away_from_the_query_falls_back_to_an_exact_scan() {
|
||||||
|
// Channel = cluster, and the filter keeps two clusters (10% of the
|
||||||
|
// store) that are not the query's: the index's neighbourhood of the
|
||||||
|
// query holds none of them. The search must still return the exact
|
||||||
|
// top 10 among the allowed records, not a short or empty page.
|
||||||
|
let d = data();
|
||||||
|
let (_dir, mut m) = build(&d, |i| format!("c{}", d.cluster[i]));
|
||||||
|
for qi in 0..20 {
|
||||||
|
let q = query(&d, qi);
|
||||||
|
let a = format!("c{}", (qi + 7) % CLUSTERS);
|
||||||
|
let b = format!("c{}", (qi + 13) % CLUSTERS);
|
||||||
|
let got: Vec<usize> = m
|
||||||
|
.search(
|
||||||
|
&q,
|
||||||
|
"",
|
||||||
|
&vector_only(10).with_sources([a.clone(), b.clone()]),
|
||||||
|
)
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.index)
|
||||||
|
.collect();
|
||||||
|
let want = exact_top(&d, &q, 10, |i| {
|
||||||
|
let c = format!("c{}", d.cluster[i]);
|
||||||
|
c == a || c == b
|
||||||
|
});
|
||||||
|
assert_eq!(got, want, "query {qi}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_edge_cases() {
|
||||||
|
let d = data();
|
||||||
|
let (_dir, mut m) = build(&d, |i| random_channel(i, 9, 50));
|
||||||
|
let q = query(&d, 0);
|
||||||
|
assert!(
|
||||||
|
m.search(
|
||||||
|
&q,
|
||||||
|
"cluster",
|
||||||
|
&SearchOptions::new(10).with_sources(Vec::<String>::new())
|
||||||
|
)
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
m.search(
|
||||||
|
&q,
|
||||||
|
"cluster",
|
||||||
|
&SearchOptions::new(10).with_sources(["nope"])
|
||||||
|
)
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
// Keyword matches from other channels are filtered too.
|
||||||
|
let got = m.search(
|
||||||
|
&q,
|
||||||
|
"record cluster",
|
||||||
|
&SearchOptions::new(50).with_sources(["keep"]),
|
||||||
|
);
|
||||||
|
assert_eq!(got.len(), 50);
|
||||||
|
assert!(got.iter().all(|r| r.source_channel == "keep"));
|
||||||
|
// Deleted records never come back, filtered or not.
|
||||||
|
let first = got[0].index;
|
||||||
|
m.delete(first).unwrap();
|
||||||
|
let again = m.search(
|
||||||
|
&q,
|
||||||
|
"record cluster",
|
||||||
|
&SearchOptions::new(50).with_sources(["keep"]),
|
||||||
|
);
|
||||||
|
assert!(again.iter().all(|r| r.index != first));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_options_equal_hybrid_search_with() {
|
||||||
|
// Two identical stores, so neither query sees the other's boosts.
|
||||||
|
let d = data();
|
||||||
|
let (_a, mut a) = build(&d, |i| random_channel(i, 3, 50));
|
||||||
|
let (_b, mut b) = build(&d, |i| random_channel(i, 3, 50));
|
||||||
|
for qi in 0..10 {
|
||||||
|
let q = query(&d, qi);
|
||||||
|
let x: Vec<(usize, u32)> = a
|
||||||
|
.search(&q, "record cluster 3", &SearchOptions::new(10))
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r.index, r.score.to_bits()))
|
||||||
|
.collect();
|
||||||
|
let y: Vec<(usize, u32)> = b
|
||||||
|
.hybrid_search_with(&q, "record cluster 3", hybrid::DEFAULT_FUSION, 10)
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r.index, r.score.to_bits()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn small_store(entries: &[(&str, &str, f64)]) -> (TempDir, HDF5Memory) {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let mut m = HDF5Memory::create(MemoryConfig::new(dir.path().join("r.h5"), "a", 4)).unwrap();
|
||||||
|
m.save_batch(
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|(chunk, channel, ts)| MemoryEntry {
|
||||||
|
chunk: chunk.to_string(),
|
||||||
|
embedding: vec![1.0, 0.0, 0.0, 0.0],
|
||||||
|
source_channel: channel.to_string(),
|
||||||
|
timestamp: *ts,
|
||||||
|
session_id: "s".into(),
|
||||||
|
tags: String::new(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(dir, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rerank_breaks_relevance_ties_by_recency() {
|
||||||
|
// Identical text and vectors, so retrieval ties; re-ranking must put the
|
||||||
|
// newer record first and report the combined score.
|
||||||
|
let now = 1_000_000.0;
|
||||||
|
let (_d, mut m) = small_store(&[
|
||||||
|
("user prefers dark mode", "chat", now - 30.0 * 86_400.0),
|
||||||
|
("user prefers dark mode", "chat", now - 60.0),
|
||||||
|
]);
|
||||||
|
let q = [1.0, 0.0, 0.0, 0.0];
|
||||||
|
let plain = m.search(&q, "dark mode", &SearchOptions::new(2));
|
||||||
|
assert_eq!(plain[0].index, 0, "ties break by index without re-ranking");
|
||||||
|
let reranked = m.search(
|
||||||
|
&q,
|
||||||
|
"dark mode",
|
||||||
|
&SearchOptions::new(2)
|
||||||
|
.with_rerank(ReRankConfig::default())
|
||||||
|
.at_time(now),
|
||||||
|
);
|
||||||
|
assert_eq!(reranked[0].index, 1);
|
||||||
|
assert!(reranked[0].score > reranked[1].score);
|
||||||
|
assert_ne!(reranked[0].score.to_bits(), plain[0].score.to_bits());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confidence_rejects_when_nothing_is_good_enough() {
|
||||||
|
let (_d, mut m) = small_store(&[("alpha", "chat", 0.0), ("beta", "chat", 0.0)]);
|
||||||
|
let q = [1.0, 0.0, 0.0, 0.0];
|
||||||
|
let strict = ConfidenceConfig {
|
||||||
|
min_score: 10.0,
|
||||||
|
..ConfidenceConfig::default()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
m.search(&q, "alpha", &SearchOptions::new(2).with_confidence(strict))
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
let lenient = ConfidenceConfig {
|
||||||
|
min_score: 0.0,
|
||||||
|
min_gap: f32::INFINITY,
|
||||||
|
max_results: 1,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
m.search(&q, "alpha", &SearchOptions::new(2).with_confidence(lenient))
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_returned_results_are_reinforced() {
|
||||||
|
// With re-ranking, a pool of max(3k, 10) candidates is retrieved; only
|
||||||
|
// the k returned should gain activation.
|
||||||
|
let d = data();
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("h.h5");
|
||||||
|
let mut m = HDF5Memory::create(MemoryConfig::new(path, "a", DIM)).unwrap();
|
||||||
|
m.save_batch(
|
||||||
|
(0..200)
|
||||||
|
.map(|i| MemoryEntry {
|
||||||
|
chunk: format!("record {i}"),
|
||||||
|
embedding: d.vectors[i].clone(),
|
||||||
|
source_channel: "chat".into(),
|
||||||
|
timestamp: i as f64,
|
||||||
|
session_id: "s".into(),
|
||||||
|
tags: String::new(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let q = query(&d, 0);
|
||||||
|
let got = m.search(
|
||||||
|
&q,
|
||||||
|
"record",
|
||||||
|
&SearchOptions::new(3).with_rerank(ReRankConfig::default()),
|
||||||
|
);
|
||||||
|
assert_eq!(got.len(), 3);
|
||||||
|
let returned: HashSet<usize> = got.iter().map(|r| r.index).collect();
|
||||||
|
// A second plain search reports each record's current activation.
|
||||||
|
let all = m.search(&q, "record", &SearchOptions::new(200));
|
||||||
|
for r in &all {
|
||||||
|
let boosted = r.activation > 1.0;
|
||||||
|
assert_eq!(boosted, returned.contains(&r.index), "record {}", r.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
//! Ed25519-signed checkpoints: `HDF5Memory::set_signing_key` and
|
||||||
|
//! `HDF5Memory::verify`.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use clawhdf5_agent::signing::{SigningKey, VerifyReport, VerifyingKey};
|
||||||
|
use clawhdf5_agent::storage;
|
||||||
|
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry, MemoryError, schema};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
const DIM: usize = 16;
|
||||||
|
|
||||||
|
fn key(seed: u8) -> SigningKey {
|
||||||
|
SigningKey::from_bytes(&[seed; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(i: usize, chunk: &str) -> MemoryEntry {
|
||||||
|
MemoryEntry {
|
||||||
|
chunk: chunk.to_string(),
|
||||||
|
embedding: (0..DIM)
|
||||||
|
.map(|j| ((i * DIM + j) as f32 * 0.37).sin())
|
||||||
|
.collect(),
|
||||||
|
source_channel: "chat".into(),
|
||||||
|
timestamp: 1_700_000_000.0 + i as f64,
|
||||||
|
session_id: format!("s{}", i % 3),
|
||||||
|
tags: format!("t{i}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Awkward strings on purpose: they must hash the same after a round trip.
|
||||||
|
const TEXTS: [&str; 6] = [
|
||||||
|
"plain text",
|
||||||
|
"ünïcödé — 日本語 🙂",
|
||||||
|
"",
|
||||||
|
"trailing spaces ",
|
||||||
|
"tab\tand\nnewline",
|
||||||
|
"x",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn signed_store(dir: &TempDir, float16: bool, k: &SigningKey) -> std::path::PathBuf {
|
||||||
|
let mut cfg = MemoryConfig::new(dir.path().join("s.h5"), "agent", DIM);
|
||||||
|
cfg.float16 = float16;
|
||||||
|
let path = cfg.path.clone();
|
||||||
|
let mut m = HDF5Memory::create(cfg).unwrap();
|
||||||
|
m.set_signing_key(k.clone());
|
||||||
|
let entries = (0..30).map(|i| entry(i, TEXTS[i % TEXTS.len()])).collect();
|
||||||
|
m.save_batch(entries).unwrap();
|
||||||
|
// Some graph and a deleted record, so every part of the manifest is used.
|
||||||
|
let a = m.knowledge_mut().add_entity("Alice", "person", 0);
|
||||||
|
let b = m.knowledge_mut().add_entity("Acme", "org", -1);
|
||||||
|
m.knowledge_mut().add_relation(a, b, "works_at", 0.75);
|
||||||
|
m.sessions_mut()
|
||||||
|
.add_at("s0", 0, 9, "chat", "first session", 1_700_000_000.0);
|
||||||
|
m.delete(4).unwrap();
|
||||||
|
m.flush_wal().unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify(path: &Path, k: &SigningKey) -> VerifyReport {
|
||||||
|
HDF5Memory::verify(path, &k.verifying_key()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_signed_store_verifies_through_reopen_and_checkpoint_cycles() {
|
||||||
|
for float16 in [true, false] {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let k = key(7);
|
||||||
|
let path = signed_store(&dir, float16, &k);
|
||||||
|
let r = verify(&path, &k);
|
||||||
|
assert!(r.is_valid(), "float16={float16}: {r:?}");
|
||||||
|
assert_eq!(r.public_key, Some(k.verifying_key().to_bytes()));
|
||||||
|
assert_eq!(r.record_count, 30);
|
||||||
|
assert!(r.changed_records.is_empty());
|
||||||
|
|
||||||
|
// Reopen, change nothing, checkpoint again (with the key): still valid.
|
||||||
|
for _ in 0..3 {
|
||||||
|
let mut m = HDF5Memory::open(&path).unwrap();
|
||||||
|
assert!(m.is_signed());
|
||||||
|
m.set_signing_key(k.clone());
|
||||||
|
m.flush_wal().unwrap();
|
||||||
|
drop(m);
|
||||||
|
assert!(verify(&path, &k).is_valid());
|
||||||
|
}
|
||||||
|
// And after real changes, re-signed.
|
||||||
|
let mut m = HDF5Memory::open(&path).unwrap();
|
||||||
|
m.set_signing_key(k.clone());
|
||||||
|
m.save(entry(99, "added later")).unwrap();
|
||||||
|
m.hybrid_search(&entry(1, "").embedding, "text", 0.4, 0.6, 5);
|
||||||
|
m.flush_wal().unwrap();
|
||||||
|
drop(m);
|
||||||
|
let r = verify(&path, &k);
|
||||||
|
assert!(r.is_valid(), "{r:?}");
|
||||||
|
assert_eq!(r.record_count, 31);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_signed_store_refuses_to_checkpoint_without_its_key() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let k = key(1);
|
||||||
|
let path = signed_store(&dir, true, &k);
|
||||||
|
|
||||||
|
let mut m = HDF5Memory::open(&path).unwrap();
|
||||||
|
m.save(entry(50, "pending")).unwrap();
|
||||||
|
match m.flush_wal() {
|
||||||
|
Err(MemoryError::SigningKeyRequired(msg)) => assert!(msg.contains("signed"), "{msg}"),
|
||||||
|
other => panic!("expected SigningKeyRequired, got {other:?}"),
|
||||||
|
}
|
||||||
|
// The file is untouched and still valid; the save is still in the WAL.
|
||||||
|
let r = verify(&path, &k);
|
||||||
|
assert!(r.is_valid());
|
||||||
|
assert_eq!(r.wal_entries_unsigned, 1);
|
||||||
|
|
||||||
|
// Supplying the key lets the checkpoint through, signed.
|
||||||
|
m.set_signing_key(k.clone());
|
||||||
|
m.flush_wal().unwrap();
|
||||||
|
drop(m);
|
||||||
|
let r = verify(&path, &k);
|
||||||
|
assert!(r.is_valid());
|
||||||
|
assert_eq!((r.record_count, r.wal_entries_unsigned), (31, 0));
|
||||||
|
|
||||||
|
// Removing the signature on purpose writes it unsigned.
|
||||||
|
let mut m = HDF5Memory::open(&path).unwrap();
|
||||||
|
m.remove_signature();
|
||||||
|
m.flush_wal().unwrap();
|
||||||
|
drop(m);
|
||||||
|
let r = verify(&path, &k);
|
||||||
|
assert!(!r.signed && !r.is_valid());
|
||||||
|
assert!(!HDF5Memory::open(&path).unwrap().is_signed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_wrong_key_does_not_verify_and_a_new_key_re_signs() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let (a, b) = (key(1), key(2));
|
||||||
|
let path = signed_store(&dir, true, &a);
|
||||||
|
let r = verify(&path, &b);
|
||||||
|
assert!(r.signed && !r.key_matches && !r.signature_valid && !r.is_valid());
|
||||||
|
|
||||||
|
let mut m = HDF5Memory::open(&path).unwrap();
|
||||||
|
m.set_signing_key(b.clone());
|
||||||
|
m.flush_wal().unwrap();
|
||||||
|
drop(m);
|
||||||
|
assert!(verify(&path, &b).is_valid());
|
||||||
|
assert!(!verify(&path, &a).is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite the store with changed contents but the *old* signature — what
|
||||||
|
/// someone with write access to the file, but not the key, can do.
|
||||||
|
fn tamper(path: &Path, change: impl FnOnce(&mut Tampered)) {
|
||||||
|
let file = clawhdf5::File::open(path).unwrap();
|
||||||
|
let (config, cache, sessions, knowledge) = schema::validate_and_load(&file).unwrap();
|
||||||
|
let checkpoint = schema::read_checkpoint_meta(&file);
|
||||||
|
let signature = schema::read_signature(&file).unwrap().unwrap();
|
||||||
|
drop(file);
|
||||||
|
let mut t = Tampered {
|
||||||
|
config,
|
||||||
|
cache,
|
||||||
|
sessions,
|
||||||
|
knowledge,
|
||||||
|
};
|
||||||
|
change(&mut t);
|
||||||
|
storage::write_to_disk_signed(
|
||||||
|
path,
|
||||||
|
&t.config,
|
||||||
|
&t.cache,
|
||||||
|
&t.sessions,
|
||||||
|
&t.knowledge,
|
||||||
|
&checkpoint,
|
||||||
|
Some(&signature),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Tampered {
|
||||||
|
config: MemoryConfig,
|
||||||
|
cache: clawhdf5_agent::cache::MemoryCache,
|
||||||
|
sessions: clawhdf5_agent::SessionCache,
|
||||||
|
knowledge: clawhdf5_agent::knowledge::KnowledgeCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_kind_of_edit_is_detected_and_located() {
|
||||||
|
let k = key(3);
|
||||||
|
type Edit = Box<dyn FnOnce(&mut Tampered)>;
|
||||||
|
type Case = (&'static str, Edit, fn(&VerifyReport) -> bool);
|
||||||
|
let cases: Vec<Case> = vec![
|
||||||
|
(
|
||||||
|
"record text",
|
||||||
|
Box::new(|t: &mut Tampered| t.cache.chunks[7] = "rewritten".into()),
|
||||||
|
|r| !r.records_match && r.changed_records == vec![7],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"one embedding value",
|
||||||
|
Box::new(|t: &mut Tampered| {
|
||||||
|
let mut e = t.cache.embeddings[12].to_vec();
|
||||||
|
e[3] = 0.5;
|
||||||
|
t.cache.embeddings.set(12, &e);
|
||||||
|
}),
|
||||||
|
|r| r.changed_records == vec![12],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"undelete",
|
||||||
|
Box::new(|t: &mut Tampered| t.cache.tombstones[4] = 0),
|
||||||
|
|r| r.changed_records == vec![4],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"timestamp",
|
||||||
|
Box::new(|t: &mut Tampered| t.cache.timestamps[20] += 1.0),
|
||||||
|
|r| r.changed_records == vec![20],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"record appended",
|
||||||
|
Box::new(|t: &mut Tampered| {
|
||||||
|
t.cache.push(
|
||||||
|
"new".into(),
|
||||||
|
vec![0.1; DIM],
|
||||||
|
"x".into(),
|
||||||
|
1.0,
|
||||||
|
"s".into(),
|
||||||
|
"".into(),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|r| !r.records_match && r.changed_records == vec![30] && r.record_count == 31,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"setting",
|
||||||
|
Box::new(|t: &mut Tampered| t.config.agent_id = "someone-else".into()),
|
||||||
|
|r| !r.settings_match && r.records_match,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session summary",
|
||||||
|
Box::new(|t: &mut Tampered| t.sessions.summaries[0] = "edited".into()),
|
||||||
|
|r| !r.sessions_match && r.records_match,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"graph edge",
|
||||||
|
Box::new(|t: &mut Tampered| t.knowledge.relations[0].weight = 1.0),
|
||||||
|
|r| !r.graph_match && r.records_match,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (name, edit, check) in cases {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = signed_store(&dir, true, &k);
|
||||||
|
tamper(&path, edit);
|
||||||
|
let r = verify(&path, &k);
|
||||||
|
assert!(
|
||||||
|
r.signed && r.key_matches && r.signature_valid,
|
||||||
|
"{name}: {r:?}"
|
||||||
|
);
|
||||||
|
assert!(!r.is_valid(), "{name}: edit not detected: {r:?}");
|
||||||
|
assert!(check(&r), "{name}: {r:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_forged_manifest_fails_the_signature() {
|
||||||
|
// Recomputing the hashes for tampered contents does not help without the
|
||||||
|
// key: the signature no longer matches the manifest.
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let k = key(5);
|
||||||
|
let path = signed_store(&dir, true, &k);
|
||||||
|
let file = clawhdf5::File::open(&path).unwrap();
|
||||||
|
let (config, mut cache, sessions, knowledge) = schema::validate_and_load(&file).unwrap();
|
||||||
|
let checkpoint = schema::read_checkpoint_meta(&file);
|
||||||
|
let mut sig = schema::read_signature(&file).unwrap().unwrap();
|
||||||
|
drop(file);
|
||||||
|
cache.chunks[0] = "forged".into();
|
||||||
|
// Re-sign with an attacker key, then splice the victim's public key back.
|
||||||
|
let forged = clawhdf5_agent::signing::sign(
|
||||||
|
&key(66),
|
||||||
|
&config,
|
||||||
|
&cache,
|
||||||
|
&sessions,
|
||||||
|
&knowledge,
|
||||||
|
checkpoint.wal_applied,
|
||||||
|
);
|
||||||
|
sig.manifest = forged.manifest;
|
||||||
|
sig.record_hashes = forged.record_hashes;
|
||||||
|
storage::write_to_disk_signed(
|
||||||
|
&path,
|
||||||
|
&config,
|
||||||
|
&cache,
|
||||||
|
&sessions,
|
||||||
|
&knowledge,
|
||||||
|
&checkpoint,
|
||||||
|
Some(&sig),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let r = verify(&path, &k);
|
||||||
|
assert!(
|
||||||
|
r.key_matches && !r.signature_valid && !r.is_valid(),
|
||||||
|
"{r:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unsigned_store_reports_unsigned() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let mut m = HDF5Memory::create(MemoryConfig::new(dir.path().join("u.h5"), "a", DIM)).unwrap();
|
||||||
|
m.save_batch(vec![entry(0, "hello")]).unwrap();
|
||||||
|
drop(m);
|
||||||
|
let r = HDF5Memory::verify(&dir.path().join("u.h5"), &VerifyingKey::from(&key(1))).unwrap();
|
||||||
|
assert!(!r.signed && !r.is_valid());
|
||||||
|
assert_eq!(r.record_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nul_bytes_in_text_still_verify() {
|
||||||
|
// Strings are stored null-padded; the hash must follow what a reopened
|
||||||
|
// store actually holds, or an untouched store would fail to verify.
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let k = key(9);
|
||||||
|
let mut m = HDF5Memory::create(MemoryConfig::new(dir.path().join("n.h5"), "a", DIM)).unwrap();
|
||||||
|
m.set_signing_key(k.clone());
|
||||||
|
m.save_batch(vec![
|
||||||
|
entry(0, "inner\0nul"),
|
||||||
|
entry(1, "trailing nul\0"),
|
||||||
|
entry(2, "\0leading"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
drop(m);
|
||||||
|
let r = verify(&dir.path().join("n.h5"), &k);
|
||||||
|
assert!(r.is_valid(), "{r:?}");
|
||||||
|
let m = HDF5Memory::open(&dir.path().join("n.h5")).unwrap();
|
||||||
|
eprintln!(
|
||||||
|
"reloaded: {:?}",
|
||||||
|
(0..3).map(|i| m.get_chunk(i)).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(&path).unwrap();
|
let reopened = HDF5Memory::open_read_only(&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(&path).unwrap();
|
let reopened = HDF5Memory::open_read_only(&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(&path).unwrap();
|
let reopened = HDF5Memory::open_read_only(&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(&path).unwrap();
|
let reopened = HDF5Memory::open_read_only(&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(&path).unwrap();
|
let reopened = HDF5Memory::open_read_only(&path).unwrap();
|
||||||
assert_eq!(reopened.count(), 250);
|
assert_eq!(reopened.count(), 250);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
//! Property tests for the write-ahead log.
|
||||||
|
//!
|
||||||
|
//! A deterministic generator (no external crates, reproducible from the seed
|
||||||
|
//! printed on failure) drives thousands of cases through two properties:
|
||||||
|
//!
|
||||||
|
//! 1. **Round trip** — whatever was appended is read back, in order, intact.
|
||||||
|
//! 2. **Prefix under corruption** — after *any* damage to the file (bit flips,
|
||||||
|
//! truncation, inserted or deleted bytes, duplicated or reordered regions),
|
||||||
|
//! reading never panics and yields an exact *prefix* of what was written.
|
||||||
|
//! This is the guarantee the chained CRC exists to provide: replay may stop
|
||||||
|
//! early, but it never returns a corrupted, reordered, or invented entry.
|
||||||
|
|
||||||
|
use clawhdf5_agent::wal::{WalEntry, WalEntryType, WalFile};
|
||||||
|
|
||||||
|
/// SplitMix64: tiny, well-distributed, and fully determined by its seed.
|
||||||
|
struct Rng(u64);
|
||||||
|
|
||||||
|
impl Rng {
|
||||||
|
fn next(&mut self) -> u64 {
|
||||||
|
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||||
|
let mut z = self.0;
|
||||||
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||||
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||||
|
z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn below(&mut self, n: usize) -> usize {
|
||||||
|
(self.next() % n.max(1) as u64) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string(&mut self, max_len: usize) -> String {
|
||||||
|
const ALPHABET: &[char] = &['a', 'Z', '0', ' ', '\n', '\0', 'é', '漢', '🦀', '"'];
|
||||||
|
(0..self.below(max_len + 1))
|
||||||
|
.map(|_| ALPHABET[self.below(ALPHABET.len())])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a test appended, in a form comparable with what is read back.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
enum Logged {
|
||||||
|
Save(String, Vec<u32>, String, String, String, u64),
|
||||||
|
Update(usize, String, Vec<u32>, u64),
|
||||||
|
Tombstone(usize, u64),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logged(entry: &WalEntry) -> Logged {
|
||||||
|
// Compare floats by bit pattern so NaN payloads and -0.0 count as intact.
|
||||||
|
let bits: Vec<u32> = entry.embedding.iter().map(|f| f.to_bits()).collect();
|
||||||
|
let ts = entry.timestamp.to_bits();
|
||||||
|
match entry.entry_type {
|
||||||
|
WalEntryType::Save => Logged::Save(
|
||||||
|
entry.chunk.clone(),
|
||||||
|
bits,
|
||||||
|
entry.source_channel.clone(),
|
||||||
|
entry.session_id.clone(),
|
||||||
|
entry.tags.clone(),
|
||||||
|
ts,
|
||||||
|
),
|
||||||
|
WalEntryType::Update => {
|
||||||
|
Logged::Update(entry.update_index.unwrap(), entry.chunk.clone(), bits, ts)
|
||||||
|
}
|
||||||
|
WalEntryType::Tombstone => Logged::Tombstone(entry.tombstone_index.unwrap(), ts),
|
||||||
|
WalEntryType::ActivationUpdate => unreachable!("never written by these tests"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a random mix of records; return what was written.
|
||||||
|
fn write_random_wal(path: &std::path::Path, rng: &mut Rng) -> Vec<Logged> {
|
||||||
|
let mut wal = WalFile::open(path).unwrap();
|
||||||
|
let mut written = Vec::new();
|
||||||
|
for _ in 0..rng.below(12) {
|
||||||
|
let timestamp = f64::from_bits(rng.next());
|
||||||
|
if rng.below(5) == 0 {
|
||||||
|
let index = rng.below(1000);
|
||||||
|
wal.append_tombstone(index, timestamp).unwrap();
|
||||||
|
written.push(Logged::Tombstone(index, timestamp.to_bits()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let update_index = (rng.below(4) == 0).then(|| rng.below(1000));
|
||||||
|
let entry = WalEntry {
|
||||||
|
entry_type: if update_index.is_some() {
|
||||||
|
WalEntryType::Update
|
||||||
|
} else {
|
||||||
|
WalEntryType::Save
|
||||||
|
},
|
||||||
|
timestamp,
|
||||||
|
chunk: rng.string(40),
|
||||||
|
embedding: (0..rng.below(9))
|
||||||
|
.map(|_| f32::from_bits(rng.next() as u32))
|
||||||
|
.collect(),
|
||||||
|
source_channel: rng.string(8),
|
||||||
|
session_id: rng.string(8),
|
||||||
|
tags: rng.string(8),
|
||||||
|
tombstone_index: None,
|
||||||
|
update_index,
|
||||||
|
};
|
||||||
|
wal.append_save(&entry).unwrap();
|
||||||
|
written.push(logged(&entry));
|
||||||
|
}
|
||||||
|
written
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_back(path: &std::path::Path) -> Option<Vec<Logged>> {
|
||||||
|
WalFile::read_entries(path)
|
||||||
|
.ok()
|
||||||
|
.map(|entries| entries.iter().map(logged).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn everything_appended_is_read_back_intact() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
for seed in 0..300u64 {
|
||||||
|
let path = dir.path().join(format!("rt-{seed}.wal"));
|
||||||
|
let written = write_random_wal(&path, &mut Rng(seed));
|
||||||
|
assert_eq!(read_back(&path).unwrap(), written, "seed {seed}");
|
||||||
|
// Reopening (which scans and repositions) must not disturb anything.
|
||||||
|
drop(WalFile::open(&path).unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
read_back(&path).unwrap(),
|
||||||
|
written,
|
||||||
|
"seed {seed} after reopen"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Damage `bytes` in one of several ways.
|
||||||
|
fn corrupt(bytes: &mut Vec<u8>, rng: &mut Rng) {
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match rng.below(7) {
|
||||||
|
0 => {
|
||||||
|
let i = rng.below(bytes.len());
|
||||||
|
bytes[i] ^= 1 << rng.below(8);
|
||||||
|
}
|
||||||
|
1 => bytes.truncate(rng.below(bytes.len())),
|
||||||
|
2 => {
|
||||||
|
let i = rng.below(bytes.len() + 1);
|
||||||
|
bytes.insert(i, rng.next() as u8);
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
let i = rng.below(bytes.len());
|
||||||
|
bytes.remove(i);
|
||||||
|
}
|
||||||
|
4 => {
|
||||||
|
// Duplicate a region in place (a replayed/duplicated entry).
|
||||||
|
let a = rng.below(bytes.len());
|
||||||
|
let b = a + rng.below(bytes.len() - a);
|
||||||
|
let region = bytes[a..b].to_vec();
|
||||||
|
let at = rng.below(bytes.len() + 1);
|
||||||
|
bytes.splice(at..at, region);
|
||||||
|
}
|
||||||
|
5 => {
|
||||||
|
// Swap two regions (reordered entries).
|
||||||
|
let mid = rng.below(bytes.len());
|
||||||
|
bytes.rotate_left(mid);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let i = rng.below(bytes.len());
|
||||||
|
let n = rng.below(bytes.len() - i + 1);
|
||||||
|
for b in &mut bytes[i..i + n] {
|
||||||
|
*b = rng.next() as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn any_corruption_yields_a_prefix_never_a_wrong_entry() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let mut shortened = 0u32;
|
||||||
|
for seed in 0..1500u64 {
|
||||||
|
let mut rng = Rng(seed ^ 0xC0FF_EE00);
|
||||||
|
let path = dir.path().join("c.wal");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let written = write_random_wal(&path, &mut rng);
|
||||||
|
|
||||||
|
let mut bytes = std::fs::read(&path).unwrap();
|
||||||
|
for _ in 0..=rng.below(3) {
|
||||||
|
corrupt(&mut bytes, &mut rng);
|
||||||
|
}
|
||||||
|
std::fs::write(&path, &bytes).unwrap();
|
||||||
|
|
||||||
|
// An unreadable header is a clean error; anything else is a prefix.
|
||||||
|
if let Some(read) = read_back(&path) {
|
||||||
|
assert!(
|
||||||
|
read.len() <= written.len() && read[..] == written[..read.len()],
|
||||||
|
"seed {seed}: read {read:?}\nis not a prefix of {written:?}"
|
||||||
|
);
|
||||||
|
if read.len() < written.len() {
|
||||||
|
shortened += 1;
|
||||||
|
}
|
||||||
|
// Opening for append repairs the tail; what was readable stays so,
|
||||||
|
// and a new entry lands right after it.
|
||||||
|
if let Ok(mut wal) = WalFile::open(&path) {
|
||||||
|
wal.append_tombstone(7, 1.0).unwrap();
|
||||||
|
drop(wal);
|
||||||
|
let mut expected = read.clone();
|
||||||
|
expected.push(Logged::Tombstone(7, 1.0f64.to_bits()));
|
||||||
|
assert_eq!(
|
||||||
|
read_back(&path).unwrap(),
|
||||||
|
expected,
|
||||||
|
"seed {seed} after repair"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
shortened > 100,
|
||||||
|
"corruption rarely took effect: {shortened}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-android"
|
name = "clawhdf5-android"
|
||||||
version = "2.2.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version.workspace = true
|
||||||
description = "Android JNI bridge for edgehdf5-memory HDF5 backend"
|
description = "Android JNI bridge for edgehdf5-memory HDF5 backend"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-ann"
|
name = "clawhdf5-ann"
|
||||||
version = "2.2.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version.workspace = true
|
||||||
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://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||||
@@ -10,9 +11,9 @@ keywords = ["hdf5", "ann", "hnsw", "nearest-neighbor"]
|
|||||||
categories = ["algorithms", "science"]
|
categories = ["algorithms", "science"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.2.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.7.0" }
|
||||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.2.0" }
|
clawhdf5-io = { path = "../clawhdf5-io", version = "2.7.0" }
|
||||||
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.2.0" }
|
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.7.0" }
|
||||||
rayon = { version = "1", optional = true }
|
rayon = { version = "1", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
+1116
-139
File diff suppressed because it is too large
Load Diff
@@ -5,4 +5,4 @@
|
|||||||
|
|
||||||
mod hnsw;
|
mod hnsw;
|
||||||
|
|
||||||
pub use hnsw::{DistanceMetric, HnswIndex};
|
pub use hnsw::{DistanceMetric, HnswIndex, Storage};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-bench"
|
name = "clawhdf5-bench"
|
||||||
version = "2.2.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version.workspace = true
|
||||||
description = "Benchmark harnesses for clawhdf5-agent (Track 8)"
|
description = "Benchmark harnesses for clawhdf5-agent (Track 8)"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
@@ -13,6 +14,14 @@ 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"
|
||||||
@@ -48,6 +57,9 @@ 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 }
|
||||||
|
|||||||
@@ -242,7 +242,12 @@ 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(chunk, embedding, TrustedSource::System, now + i as f64 * 0.1);
|
engine.add_trusted_memory(
|
||||||
|
chunk,
|
||||||
|
embedding,
|
||||||
|
TrustedSource::System,
|
||||||
|
now + i as f64 * 0.1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(" → Inserted {} records total", engine.records().len());
|
println!(" → Inserted {} records total", engine.records().len());
|
||||||
@@ -391,7 +396,7 @@ fn run_memory_reduction_benchmark() {
|
|||||||
println!();
|
println!();
|
||||||
println!(
|
println!(
|
||||||
"{:>8} {:>10} {:>10} {:>10} {:>12}",
|
"{:>8} {:>10} {:>10} {:>10} {:>12}",
|
||||||
"Initial", "Remaining", "Eviction%", "Signal OK?", "BM25 Speedup"
|
"Initial", "Remaining", "Eviction%", "Signal OK?", "Records ÷"
|
||||||
);
|
);
|
||||||
println!("{}", "-".repeat(58));
|
println!("{}", "-".repeat(58));
|
||||||
|
|
||||||
@@ -435,7 +440,8 @@ fn run_memory_reduction_benchmark() {
|
|||||||
// Check all signal records survived
|
// Check all signal records survived
|
||||||
let signal_survived = signal_ids.iter().all(|&id| engine.get_by_id(id).is_some());
|
let signal_survived = signal_ids.iter().all(|&id| engine.get_by_id(id).is_some());
|
||||||
|
|
||||||
// Rough speedup: BM25 scales roughly linearly with record count
|
// How many times fewer records there are. Not a measured speedup —
|
||||||
|
// Part 1 measures search latency before and after.
|
||||||
let speedup = before_count as f64 / after_count.max(1) as f64;
|
let speedup = before_count as f64 / after_count.max(1) as f64;
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
@@ -475,7 +481,7 @@ fn main() {
|
|||||||
println!(" 3. Reducing search latency proportional to record reduction");
|
println!(" 3. Reducing search latency proportional to record reduction");
|
||||||
println!();
|
println!();
|
||||||
println!(
|
println!(
|
||||||
"Cycle time scales sub-linearly: 100 records ~microseconds, 100K records ~tens of ms."
|
"Cycle time grows a little faster than linearly: 100 records ~microseconds, 100K records ~tens of ms."
|
||||||
);
|
);
|
||||||
println!("Signal records with Correction source + high access_count survive eviction.");
|
println!("Signal records with Correction source + high access_count survive eviction.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,14 @@
|
|||||||
//!
|
//!
|
||||||
//! Configuration matrix:
|
//! Configuration matrix:
|
||||||
//! - Text lengths: short (50 chars), medium (200 chars), long (1000 chars)
|
//! - Text lengths: short (50 chars), medium (200 chars), long (1000 chars)
|
||||||
//! - Embedding: 384-dim f32 (1536 bytes raw per record)
|
//! - Embedding: 384-dim, stored as float16 (the default for new stores) or
|
||||||
|
//! f32 with `--f32`; "raw" bytes are counted as f32 input either way
|
||||||
//! - WAL: enabled and disabled
|
//! - WAL: enabled and disabled
|
||||||
//!
|
//!
|
||||||
//! # Usage
|
//! # Usage
|
||||||
//! ```
|
//! ```
|
||||||
//! cargo run --release --bin footprint_bench
|
//! cargo run --release --bin footprint_bench # float16 stores
|
||||||
|
//! cargo run --release --bin footprint_bench -- --f32 # f32 stores
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
@@ -24,6 +26,9 @@ use std::time::Instant;
|
|||||||
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// `--f32`: build f32 stores instead of the library's float16 default.
|
||||||
|
static F32: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
const EMBEDDING_DIM: usize = 384;
|
const EMBEDDING_DIM: usize = 384;
|
||||||
|
|
||||||
// Raw bytes per record: 384 f32 embeddings + median text + overhead
|
// Raw bytes per record: 384 f32 embeddings + median text + overhead
|
||||||
@@ -152,6 +157,9 @@ fn measure_footprint(
|
|||||||
config.compression = compression;
|
config.compression = compression;
|
||||||
config.compression_level = if compression { 6 } else { 0 };
|
config.compression_level = if compression { 6 } else { 0 };
|
||||||
config.compact_threshold = 0.0;
|
config.compact_threshold = 0.0;
|
||||||
|
if F32.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
config.float16 = false;
|
||||||
|
}
|
||||||
|
|
||||||
let mut memory = HDF5Memory::create(config).expect("HDF5Memory::create failed");
|
let mut memory = HDF5Memory::create(config).expect("HDF5Memory::create failed");
|
||||||
|
|
||||||
@@ -241,11 +249,19 @@ fn fmt_n(n: usize) -> String {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
if std::env::args().skip(1).any(|a| a == "--f32") {
|
||||||
|
F32.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
let stored = if F32.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
"f32 (1,536 bytes per record)"
|
||||||
|
} else {
|
||||||
|
"float16 (768 bytes per record; the default for new stores)"
|
||||||
|
};
|
||||||
println!("=================================================================");
|
println!("=================================================================");
|
||||||
println!(" ClawhDF5 Memory Footprint Benchmark");
|
println!(" ClawhDF5 Memory Footprint Benchmark");
|
||||||
println!("=================================================================");
|
println!("=================================================================");
|
||||||
println!();
|
println!();
|
||||||
println!("Embedding: 384-dim f32 = 1,536 bytes raw per record");
|
println!("Embedding: 384-dim, stored as {stored}; raw input counted as f32");
|
||||||
println!("Text lengths: short=50 chars, medium=200 chars, long=1000 chars");
|
println!("Text lengths: short=50 chars, medium=200 chars, long=1000 chars");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
|
|||||||
@@ -55,44 +55,155 @@ use std::time::{Duration, Instant};
|
|||||||
#[path = "longmemeval_bench/embedder.rs"]
|
#[path = "longmemeval_bench/embedder.rs"]
|
||||||
mod embedder;
|
mod embedder;
|
||||||
|
|
||||||
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
use clawhdf5_agent::bm25::TokenFilter;
|
||||||
|
use clawhdf5_agent::hybrid::Fusion;
|
||||||
|
use clawhdf5_agent::reranker::{ReRankConfig, RerankInput, rerank};
|
||||||
|
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry, SearchResult};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
const EMBEDDING_DIM: usize = 384;
|
const EMBEDDING_DIM: usize = 384;
|
||||||
|
|
||||||
|
/// `--float16`: build every per-question store with `MemoryConfig::float16`,
|
||||||
|
/// so embeddings are rounded to half precision as they are saved — exactly
|
||||||
|
/// what such a store searches over.
|
||||||
|
static FLOAT16: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// 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,
|
||||||
vector_weight: f32,
|
/// How the two retrieval stages are combined into one ranking.
|
||||||
keyword_weight: f32,
|
fusion: Fusion,
|
||||||
|
/// How keyword tokens are normalised before indexing and querying.
|
||||||
|
tokens: TokenFilter,
|
||||||
|
/// Re-rank the retrieved candidates with recency and friends, relative to
|
||||||
|
/// the question's own date.
|
||||||
|
rerank: Option<ReRankConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mode {
|
||||||
|
const fn weighted(label: &'static str, vector: f32, keyword: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
label,
|
||||||
|
fusion: Fusion::Weighted { vector, keyword },
|
||||||
|
tokens: TokenFilter::Plain,
|
||||||
|
rerank: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(feature = "embeddings"), allow(dead_code))]
|
||||||
|
fn reranked(mut self, label: &'static str, rerank: ReRankConfig) -> Self {
|
||||||
|
self.label = label;
|
||||||
|
self.rerank = Some(rerank);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn stemmed(mut self, label: &'static str) -> Self {
|
||||||
|
self.label = label;
|
||||||
|
self.tokens = TokenFilter::Stemmed;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The only mode available without real embeddings. Passing zero vectors with
|
/// 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 {
|
const BM25_ONLY: Mode = Mode::weighted("BM25 only (vector stage inert)", 0.0, 1.0);
|
||||||
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 {
|
const VECTOR_ONLY: Mode = Mode::weighted("Vector only (MiniLM + HNSW)", 1.0, 0.0);
|
||||||
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 {
|
const HYBRID: Mode = Mode::weighted("Hybrid (0.4 vector / 0.6 BM25, tuned)", 0.4, 0.6);
|
||||||
label: "Hybrid (0.4 vector / 0.6 BM25, tuned)",
|
|
||||||
vector_weight: 0.4,
|
/// Reciprocal rank fusion, the documented alternative to the weighted sum.
|
||||||
keyword_weight: 0.6,
|
/// It ignores score magnitudes, so there is nothing to tune — which is the
|
||||||
|
/// claim being tested.
|
||||||
|
#[cfg(feature = "embeddings")]
|
||||||
|
const RRF: Mode = Mode {
|
||||||
|
label: "Hybrid (reciprocal rank fusion, k=60)",
|
||||||
|
fusion: Fusion::Rrf { k: 60.0 },
|
||||||
|
tokens: TokenFilter::Plain,
|
||||||
|
rerank: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The same two configurations with stemmed keyword tokens, so the tokenizer's
|
||||||
|
/// effect is isolated from everything else.
|
||||||
|
const BM25_STEMMED: Mode = BM25_ONLY.stemmed("BM25 only, stemmed tokens");
|
||||||
|
|
||||||
|
/// Re-ranking as it behaved before `relevance` was an input: the combined
|
||||||
|
/// score was recency + authority + activation only, so the retriever's own
|
||||||
|
/// ordering was discarded.
|
||||||
|
#[cfg(feature = "embeddings")]
|
||||||
|
fn hybrid_rerank_metadata_only() -> Mode {
|
||||||
|
HYBRID.reranked(
|
||||||
|
"Hybrid + rerank (metadata only, pre-fix)",
|
||||||
|
ReRankConfig {
|
||||||
|
relevance_weight: 0.0,
|
||||||
|
..ReRankConfig::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-ranking as it behaves now: relevance leads, recency nudges.
|
||||||
|
#[cfg(feature = "embeddings")]
|
||||||
|
fn hybrid_rerank_blended() -> Mode {
|
||||||
|
HYBRID.reranked(
|
||||||
|
"Hybrid + rerank (relevance + recency)",
|
||||||
|
ReRankConfig::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same blend at several half-lives. Decay is `2^(-age / half_life)`, so a
|
||||||
|
/// half-life far shorter than the gaps between memories sends every score to
|
||||||
|
/// zero and the signal vanishes; far longer and everything scores ~1 and it
|
||||||
|
/// vanishes the other way. The right value tracks how far apart the memories
|
||||||
|
/// actually are.
|
||||||
|
#[cfg(feature = "embeddings")]
|
||||||
|
fn hybrid_rerank_half_lives() -> Vec<Mode> {
|
||||||
|
[
|
||||||
|
("1 day", 86_400.0),
|
||||||
|
("7 days", 7.0 * 86_400.0),
|
||||||
|
("30 days", 30.0 * 86_400.0),
|
||||||
|
("90 days", 90.0 * 86_400.0),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(label, half_life)| {
|
||||||
|
HYBRID.reranked(
|
||||||
|
Box::leak(format!("Hybrid + rerank, half-life {label}").into_boxed_str()),
|
||||||
|
ReRankConfig {
|
||||||
|
temporal_half_life_secs: half_life,
|
||||||
|
..ReRankConfig::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
#[cfg(feature = "embeddings")]
|
||||||
|
const HYBRID_STEMMED: Mode = HYBRID.stemmed("Hybrid 0.4/0.6, stemmed tokens");
|
||||||
|
|
||||||
/// Every 0.1 step of vector weight, keyword weight taking the remainder.
|
/// 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
|
||||||
@@ -104,11 +215,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 {
|
Mode::weighted(
|
||||||
label: Box::leak(format!("sweep v={v:.1} / k={:.1}", 1.0 - v).into_boxed_str()),
|
Box::leak(format!("sweep v={v:.1} / k={:.1}", 1.0 - v).into_boxed_str()),
|
||||||
vector_weight: v,
|
v,
|
||||||
keyword_weight: 1.0 - v,
|
1.0 - v,
|
||||||
}
|
)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -181,6 +292,37 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -199,11 +341,21 @@ 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
|
||||||
}
|
}
|
||||||
@@ -261,6 +413,16 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,21 +436,29 @@ fn evaluate_question(
|
|||||||
let mut config = MemoryConfig::new(dir.path().join("lme.h5"), "lme-bench", EMBEDDING_DIM);
|
let mut config = MemoryConfig::new(dir.path().join("lme.h5"), "lme-bench", EMBEDDING_DIM);
|
||||||
config.wal_enabled = false;
|
config.wal_enabled = false;
|
||||||
config.compact_threshold = 0.0;
|
config.compact_threshold = 0.0;
|
||||||
|
config.float16 = FLOAT16.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
|
||||||
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");
|
||||||
for turn in session {
|
// Real session dates, not a synthetic counter: anything that decays
|
||||||
|
// with age needs true intervals, not just the right order.
|
||||||
|
let session_start = q
|
||||||
|
.haystack_dates
|
||||||
|
.get(sess_idx)
|
||||||
|
.map_or(sess_idx as f64 * 86_400.0, |d| session_time(d, sess_idx));
|
||||||
|
for (turn_idx, turn) in session.iter().enumerate() {
|
||||||
|
// Spread a session's turns over the minutes following its start.
|
||||||
|
let ts = session_start + turn_idx as f64 * 60.0;
|
||||||
entries.push(MemoryEntry {
|
entries.push(MemoryEntry {
|
||||||
chunk: turn.content.clone(),
|
chunk: turn.content.clone(),
|
||||||
embedding: embedding_for(embeddings, &turn.content),
|
embedding: embedding_for(embeddings, &turn.content),
|
||||||
@@ -302,7 +472,6 @@ fn evaluate_question(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
turn_has_answer.push(turn.has_answer);
|
turn_has_answer.push(turn.has_answer);
|
||||||
ts += 1.0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,17 +488,87 @@ 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();
|
||||||
let results = memory.hybrid_search(
|
// Re-ranking only reorders; it needs a candidate pool larger than `top_k`
|
||||||
&query_emb,
|
// to have anything to promote.
|
||||||
&q.question,
|
let pool = if mode.rerank.is_some() {
|
||||||
mode.vector_weight,
|
top_k * 4
|
||||||
mode.keyword_weight,
|
} else {
|
||||||
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;
|
||||||
@@ -384,6 +623,7 @@ fn evaluate_question(
|
|||||||
hit5_turn,
|
hit5_turn,
|
||||||
hit10_turn,
|
hit10_turn,
|
||||||
rr_turn,
|
rr_turn,
|
||||||
|
newest_gold_first,
|
||||||
latency,
|
latency,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,10 +712,7 @@ fn print_report(
|
|||||||
println!(" LongMemEval Benchmark — {}", mode.label);
|
println!(" LongMemEval Benchmark — {}", mode.label);
|
||||||
println!("=================================================================");
|
println!("=================================================================");
|
||||||
println!();
|
println!();
|
||||||
println!(
|
println!("Mode: {}", describe(mode));
|
||||||
"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");
|
||||||
@@ -538,6 +775,24 @@ 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!(
|
||||||
@@ -602,10 +857,7 @@ fn print_report(
|
|||||||
println!("```json");
|
println!("```json");
|
||||||
println!("{{");
|
println!("{{");
|
||||||
println!(" \"benchmark\": \"longmemeval\",");
|
println!(" \"benchmark\": \"longmemeval\",");
|
||||||
println!(
|
println!(" \"mode\": \"{}\",", describe(mode));
|
||||||
" \"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,");
|
||||||
@@ -654,6 +906,14 @@ 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}",
|
||||||
@@ -676,6 +936,8 @@ 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() {
|
||||||
@@ -684,6 +946,20 @@ 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,
|
||||||
|
"--float16" => {
|
||||||
|
FLOAT16.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
eprintln!("Stores use MemoryConfig::float16 (half-precision embeddings)");
|
||||||
|
}
|
||||||
|
"--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"));
|
||||||
}
|
}
|
||||||
@@ -702,6 +978,12 @@ 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\
|
||||||
|
--float16\n\
|
||||||
|
build each store with MemoryConfig::float16, to\n\
|
||||||
|
compare retrieval on half-precision embeddings.\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."
|
||||||
@@ -767,19 +1049,34 @@ 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![BM25_ONLY, VECTOR_ONLY, HYBRID]
|
vec![
|
||||||
|
BM25_ONLY,
|
||||||
|
VECTOR_ONLY,
|
||||||
|
HYBRID,
|
||||||
|
RRF,
|
||||||
|
BM25_STEMMED,
|
||||||
|
HYBRID_STEMMED,
|
||||||
|
hybrid_rerank_metadata_only(),
|
||||||
|
hybrid_rerank_blended(),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "embeddings"))]
|
#[cfg(not(feature = "embeddings"))]
|
||||||
{
|
{
|
||||||
vec![BM25_ONLY]
|
vec![BM25_ONLY, BM25_STEMMED]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if sweep {
|
if sweep {
|
||||||
eprintln!("warning: --sweep needs --embeddings; running BM25 only");
|
eprintln!("warning: --sweep needs --embeddings; running BM25 only");
|
||||||
}
|
}
|
||||||
vec![BM25_ONLY]
|
// Stemming is a property of the keyword stage, so it can be compared
|
||||||
|
// without a model.
|
||||||
|
vec![BM25_ONLY, BM25_STEMMED]
|
||||||
};
|
};
|
||||||
|
|
||||||
for (mode_idx, mode) in modes.iter().enumerate() {
|
for (mode_idx, mode) in modes.iter().enumerate() {
|
||||||
@@ -882,6 +1179,14 @@ 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);
|
||||||
@@ -893,3 +1198,30 @@ 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
//! HDF5 read-path measurement harness: full reads vs. hyperslab selections on
|
||||||
|
//! a chunked 2-D dataset, compressed and uncompressed, plus a contiguous one.
|
||||||
|
//!
|
||||||
|
//! The question it answers for every read-path change: does the cost of a
|
||||||
|
//! selection scale with the *selection*, or with the whole dataset?
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run --release -p clawhdf5-bench --bin read_harness
|
||||||
|
//! cargo run --release -p clawhdf5-bench --bin read_harness -- --large # 512 MB
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use clawhdf5::{File, FileBuilder};
|
||||||
|
use clawhdf5_format::selection::Selection;
|
||||||
|
|
||||||
|
const CHUNK: u64 = 256;
|
||||||
|
|
||||||
|
struct Layout {
|
||||||
|
name: &'static str,
|
||||||
|
chunked: bool,
|
||||||
|
deflate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYOUTS: [Layout; 3] = [
|
||||||
|
Layout {
|
||||||
|
name: "chunked + deflate",
|
||||||
|
chunked: true,
|
||||||
|
deflate: true,
|
||||||
|
},
|
||||||
|
Layout {
|
||||||
|
name: "chunked",
|
||||||
|
chunked: true,
|
||||||
|
deflate: false,
|
||||||
|
},
|
||||||
|
Layout {
|
||||||
|
name: "contiguous",
|
||||||
|
chunked: false,
|
||||||
|
deflate: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Smooth-ish, compressible data whose value encodes its position, so a read
|
||||||
|
/// can be verified exactly.
|
||||||
|
fn value(row: u64, col: u64) -> f64 {
|
||||||
|
(row * 100_003 + col) as f64 * 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_file(path: &std::path::Path, rows: u64, cols: u64) {
|
||||||
|
let data: Vec<f64> = (0..rows)
|
||||||
|
.flat_map(|r| (0..cols).map(move |c| value(r, c)))
|
||||||
|
.collect();
|
||||||
|
let mut builder = FileBuilder::new();
|
||||||
|
for (i, layout) in LAYOUTS.iter().enumerate() {
|
||||||
|
let ds = builder.create_dataset(&format!("d{i}"));
|
||||||
|
ds.with_f64_data(&data).with_shape(&[rows, cols]);
|
||||||
|
if layout.chunked {
|
||||||
|
ds.with_chunks(&[CHUNK, CHUNK]);
|
||||||
|
}
|
||||||
|
if layout.deflate {
|
||||||
|
ds.with_deflate(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.write(path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn median(mut samples: Vec<Duration>) -> Duration {
|
||||||
|
samples.sort();
|
||||||
|
samples[samples.len() / 2]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn time<T>(reps: usize, mut f: impl FnMut() -> T) -> Duration {
|
||||||
|
median(
|
||||||
|
(0..reps)
|
||||||
|
.map(|_| {
|
||||||
|
let t = Instant::now();
|
||||||
|
std::hint::black_box(f());
|
||||||
|
t.elapsed()
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slab(start: [u64; 2], count: [u64; 2]) -> Selection {
|
||||||
|
Selection::Hyperslab {
|
||||||
|
start: start.to_vec(),
|
||||||
|
stride: vec![1, 1],
|
||||||
|
count: count.to_vec(),
|
||||||
|
block: vec![1, 1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let large = std::env::args().any(|a| a == "--large");
|
||||||
|
let (rows, cols) = if large { (8192, 8192) } else { (4096, 2048) };
|
||||||
|
let total_mb = (rows * cols * 8) as f64 / (1 << 20) as f64;
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
eprintln!("warning: debug build — numbers are meaningless. Use --release.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("read_harness.h5");
|
||||||
|
write_file(&path, rows, cols);
|
||||||
|
let file_mb = std::fs::metadata(&path).unwrap().len() as f64 / (1 << 20) as f64;
|
||||||
|
|
||||||
|
println!("## Read harness");
|
||||||
|
println!(
|
||||||
|
"\n{rows} x {cols} f64 ({total_mb:.0} MB per dataset), chunks {CHUNK} x {CHUNK}, file {file_mb:.0} MB\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
// (label, selection, elements selected)
|
||||||
|
let selections: Vec<(&str, Selection, u64)> = vec![
|
||||||
|
(
|
||||||
|
"64 x 64 window (1 chunk)",
|
||||||
|
slab([300, 300], [64, 64]),
|
||||||
|
64 * 64,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"512 x 512 window (4-9 chunks)",
|
||||||
|
slab([1000, 700], [512, 512]),
|
||||||
|
512 * 512,
|
||||||
|
),
|
||||||
|
("one row", slab([rows / 2, 0], [1, cols]), cols),
|
||||||
|
("one column", slab([0, cols / 2], [rows, 1]), rows),
|
||||||
|
];
|
||||||
|
|
||||||
|
println!("| layout | read | selected | time ms | MB/s of selection | vs full read |");
|
||||||
|
println!("|---|---|---:|---:|---:|---:|");
|
||||||
|
for (i, layout) in LAYOUTS.iter().enumerate() {
|
||||||
|
// Fresh handle per layout so one dataset's cached chunks don't help
|
||||||
|
// (or evict) another's.
|
||||||
|
let file = File::open(&path).unwrap();
|
||||||
|
let ds = file.dataset(&format!("d{i}")).unwrap();
|
||||||
|
|
||||||
|
let full_cold = time(1, || ds.read_f64().unwrap());
|
||||||
|
let full = time(3, || ds.read_f64().unwrap());
|
||||||
|
println!(
|
||||||
|
"| {} | full (first) | {total_mb:.0} MB | {:.1} | {:.0} | |",
|
||||||
|
layout.name,
|
||||||
|
full_cold.as_secs_f64() * 1e3,
|
||||||
|
total_mb / full_cold.as_secs_f64()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"| {} | full (repeat) | {total_mb:.0} MB | {:.1} | {:.0} | 1.00x |",
|
||||||
|
layout.name,
|
||||||
|
full.as_secs_f64() * 1e3,
|
||||||
|
total_mb / full.as_secs_f64()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (label, selection, elements) in &selections {
|
||||||
|
// A fresh handle again: measure the selection on its own, not
|
||||||
|
// served from chunks the full read just cached.
|
||||||
|
let file = File::open(&path).unwrap();
|
||||||
|
let ds = file.dataset(&format!("d{i}")).unwrap();
|
||||||
|
let got = ds.read_f64_selection(selection).unwrap();
|
||||||
|
assert_eq!(got.len() as u64, *elements, "{label}");
|
||||||
|
if let Selection::Hyperslab { start, .. } = selection {
|
||||||
|
assert_eq!(got[0], value(start[0], start[1]), "{label}: wrong data");
|
||||||
|
}
|
||||||
|
let took = time(5, || {
|
||||||
|
let file = File::open(&path).unwrap();
|
||||||
|
let ds = file.dataset(&format!("d{i}")).unwrap();
|
||||||
|
ds.read_f64_selection(selection).unwrap()
|
||||||
|
});
|
||||||
|
let mb = (*elements * 8) as f64 / (1 << 20) as f64;
|
||||||
|
println!(
|
||||||
|
"| {} | {label} | {:.2} MB | {:.2} | {:.0} | {:.3}x |",
|
||||||
|
layout.name,
|
||||||
|
mb,
|
||||||
|
took.as_secs_f64() * 1e3,
|
||||||
|
mb / took.as_secs_f64(),
|
||||||
|
took.as_secs_f64() / full_cold.as_secs_f64()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-cli"
|
name = "clawhdf5-cli"
|
||||||
version = "2.2.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version.workspace = true
|
||||||
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://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||||
@@ -14,7 +15,7 @@ name = "clawhdf5"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.2.0" }
|
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.7.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 }
|
||||||
|
|||||||
+165
-17
@@ -1,15 +1,22 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
use clawhdf5_agent::signing::{self, SigningKey, VerifyingKey};
|
||||||
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
||||||
|
|
||||||
/// ClawhDF5 — HDF5-backed cognitive memory for AI agents
|
/// ClawhDF5 — HDF5-backed cognitive memory for AI agents
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "clawhdf5", version, about)]
|
#[command(name = "clawhdf5", version, about)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Path to the .h5 memory file
|
/// Path to the .h5 memory file (not needed for `keygen`)
|
||||||
#[arg(short, long, env = "CLAWHDF5_PATH")]
|
#[arg(short, long, env = "CLAWHDF5_PATH")]
|
||||||
path: PathBuf,
|
path: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// File holding an Ed25519 signing key (64 hex characters, from
|
||||||
|
/// `keygen`). Every checkpoint this command makes is then signed; a
|
||||||
|
/// signed store refuses to checkpoint without it.
|
||||||
|
#[arg(long, env = "CLAWHDF5_SIGNING_KEY", global = true)]
|
||||||
|
signing_key: Option<PathBuf>,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Commands,
|
command: Commands,
|
||||||
@@ -28,6 +35,22 @@ enum Commands {
|
|||||||
/// Enable write-ahead log
|
/// Enable write-ahead log
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
wal: bool,
|
wal: bool,
|
||||||
|
/// Hold the vector index's copy of the embeddings as f32 instead of
|
||||||
|
/// the default int8 (which uses a quarter of the memory and is faster
|
||||||
|
/// at equal recall)
|
||||||
|
#[arg(long)]
|
||||||
|
f32_index: bool,
|
||||||
|
/// Accepted for compatibility; int8 is now the default
|
||||||
|
#[arg(long, hide = true, conflicts_with = "f32_index")]
|
||||||
|
quantized_index: bool,
|
||||||
|
/// Store embeddings as full-precision f32 instead of the default
|
||||||
|
/// half precision (float16: half the bytes, about three significant
|
||||||
|
/// digits, values within ±65504)
|
||||||
|
#[arg(long)]
|
||||||
|
f32: bool,
|
||||||
|
/// Accepted for compatibility; float16 is now the default
|
||||||
|
#[arg(long, hide = true, conflicts_with = "f32")]
|
||||||
|
float16: bool,
|
||||||
},
|
},
|
||||||
/// Save a memory entry (reads JSON from stdin or --json)
|
/// Save a memory entry (reads JSON from stdin or --json)
|
||||||
Save {
|
Save {
|
||||||
@@ -75,6 +98,38 @@ enum Commands {
|
|||||||
/// Destination path
|
/// Destination path
|
||||||
dest: PathBuf,
|
dest: PathBuf,
|
||||||
},
|
},
|
||||||
|
/// Generate an Ed25519 signing key for signed checkpoints
|
||||||
|
Keygen {
|
||||||
|
/// Where to write the secret key (created new, owner-only on Unix)
|
||||||
|
#[arg(long)]
|
||||||
|
out: PathBuf,
|
||||||
|
},
|
||||||
|
/// Verify a signed store against a public key; exit status 2 if not valid
|
||||||
|
Verify {
|
||||||
|
/// The trusted public key: 64 hex characters, or a file holding them
|
||||||
|
#[arg(long)]
|
||||||
|
public_key: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_signing_key(path: &Path) -> Result<SigningKey, Box<dyn std::error::Error>> {
|
||||||
|
let text = std::fs::read_to_string(path)
|
||||||
|
.map_err(|e| format!("cannot read signing key {}: {e}", path.display()))?;
|
||||||
|
let bytes = signing::from_hex::<32>(&text)
|
||||||
|
.ok_or_else(|| format!("{} is not a 64-hex-character key", path.display()))?;
|
||||||
|
Ok(SigningKey::from_bytes(&bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open for writing, with the signing key applied if one was given.
|
||||||
|
fn open_writable(
|
||||||
|
path: &Path,
|
||||||
|
key: &Option<SigningKey>,
|
||||||
|
) -> Result<HDF5Memory, Box<dyn std::error::Error>> {
|
||||||
|
let mut mem = HDF5Memory::open(path)?;
|
||||||
|
if let Some(k) = key {
|
||||||
|
mem.set_signing_key(k.clone());
|
||||||
|
}
|
||||||
|
Ok(mem)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -87,17 +142,76 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if let Commands::Keygen { out } = &cli.command {
|
||||||
|
let key = signing::generate_key();
|
||||||
|
let mut opts = std::fs::OpenOptions::new();
|
||||||
|
opts.write(true).create_new(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
opts.mode(0o600);
|
||||||
|
}
|
||||||
|
use std::io::Write;
|
||||||
|
let mut f = opts
|
||||||
|
.open(out)
|
||||||
|
.map_err(|e| format!("cannot create {}: {e}", out.display()))?;
|
||||||
|
writeln!(f, "{}", signing::to_hex(&key.to_bytes()))?;
|
||||||
|
let j = serde_json::json!({
|
||||||
|
"status": "generated",
|
||||||
|
"secret_key_file": out.display().to_string(),
|
||||||
|
"public_key": signing::to_hex(&key.verifying_key().to_bytes()),
|
||||||
|
});
|
||||||
|
println!("{}", serde_json::to_string_pretty(&j)?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let path = cli
|
||||||
|
.path
|
||||||
|
.clone()
|
||||||
|
.ok_or("--path (or CLAWHDF5_PATH) is required")?;
|
||||||
|
let key = cli
|
||||||
|
.signing_key
|
||||||
|
.as_deref()
|
||||||
|
.map(read_signing_key)
|
||||||
|
.transpose()?;
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Create { agent_id, dim, wal } => {
|
Commands::Create {
|
||||||
let mut config = MemoryConfig::new(cli.path.clone(), &agent_id, dim);
|
agent_id,
|
||||||
|
dim,
|
||||||
|
wal,
|
||||||
|
f32_index,
|
||||||
|
quantized_index: _,
|
||||||
|
f32,
|
||||||
|
float16: _,
|
||||||
|
} => {
|
||||||
|
let mut config = MemoryConfig::new(path.clone(), &agent_id, dim);
|
||||||
config.wal_enabled = wal;
|
config.wal_enabled = wal;
|
||||||
let mem = HDF5Memory::create(config)?;
|
// As with --f32-index: only ever switch the library default off.
|
||||||
|
if f32 {
|
||||||
|
config.float16 = false;
|
||||||
|
}
|
||||||
|
let config_float16 = config.float16;
|
||||||
|
// Only ever switch *off* the library default: assigning the flag
|
||||||
|
// outright would force every CLI-created store back to f32 unless
|
||||||
|
// the caller knew to ask for int8.
|
||||||
|
if f32_index {
|
||||||
|
config.quantized_index = false;
|
||||||
|
}
|
||||||
|
let config_quantized = config.quantized_index;
|
||||||
|
let mut mem = HDF5Memory::create(config)?;
|
||||||
|
// Sign straight away, so the store is never on disk unsigned.
|
||||||
|
if let Some(k) = &key {
|
||||||
|
mem.set_signing_key(k.clone());
|
||||||
|
mem.flush_wal()?;
|
||||||
|
}
|
||||||
let j = serde_json::json!({
|
let j = serde_json::json!({
|
||||||
"status": "created",
|
"status": "created",
|
||||||
"path": cli.path.display().to_string(),
|
"path": path.display().to_string(),
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"embedding_dim": dim,
|
"embedding_dim": dim,
|
||||||
"wal_enabled": wal,
|
"wal_enabled": wal,
|
||||||
|
"quantized_index": config_quantized,
|
||||||
|
"float16": config_float16,
|
||||||
|
"signed": mem.is_signed(),
|
||||||
"count": mem.count(),
|
"count": mem.count(),
|
||||||
});
|
});
|
||||||
println!("{}", serde_json::to_string_pretty(&j)?);
|
println!("{}", serde_json::to_string_pretty(&j)?);
|
||||||
@@ -114,7 +228,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let entry: MemoryEntry = serde_json::from_str(&input)?;
|
let entry: MemoryEntry = serde_json::from_str(&input)?;
|
||||||
let mut mem = HDF5Memory::open(&cli.path)?;
|
let mut mem = open_writable(&path, &key)?;
|
||||||
let idx = mem.save(entry)?;
|
let idx = mem.save(entry)?;
|
||||||
let j = serde_json::json!({ "status": "saved", "index": idx, "count": mem.count() });
|
let j = serde_json::json!({ "status": "saved", "index": idx, "count": mem.count() });
|
||||||
println!("{}", serde_json::to_string(&j)?);
|
println!("{}", serde_json::to_string(&j)?);
|
||||||
@@ -128,7 +242,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
keyword_weight,
|
keyword_weight,
|
||||||
} => {
|
} => {
|
||||||
let emb: Vec<f32> = serde_json::from_str(&embedding)?;
|
let emb: Vec<f32> = serde_json::from_str(&embedding)?;
|
||||||
let mut mem = HDF5Memory::open(&cli.path)?;
|
let mut mem = open_writable(&path, &key)?;
|
||||||
let results = mem.hybrid_search(&emb, &query, vector_weight, keyword_weight, top_k);
|
let results = mem.hybrid_search(&emb, &query, vector_weight, keyword_weight, top_k);
|
||||||
let j: Vec<serde_json::Value> = results
|
let j: Vec<serde_json::Value> = results
|
||||||
.iter()
|
.iter()
|
||||||
@@ -146,7 +260,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Commands::Recall { index } => {
|
Commands::Recall { index } => {
|
||||||
let mem = HDF5Memory::open(&cli.path)?;
|
let mem = HDF5Memory::open_read_only(&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 });
|
||||||
@@ -160,22 +274,23 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Commands::Stats => {
|
Commands::Stats => {
|
||||||
let mem = HDF5Memory::open(&cli.path)?;
|
let mem = HDF5Memory::open_read_only(&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": path.display().to_string(),
|
||||||
"agent_id": cfg.agent_id,
|
"agent_id": cfg.agent_id,
|
||||||
"embedding_dim": cfg.embedding_dim,
|
"embedding_dim": cfg.embedding_dim,
|
||||||
"count": mem.count(),
|
"count": mem.count(),
|
||||||
"active": mem.count_active(),
|
"active": mem.count_active(),
|
||||||
"wal_enabled": cfg.wal_enabled,
|
"wal_enabled": cfg.wal_enabled,
|
||||||
"wal_pending": mem.wal_pending_count(),
|
"wal_pending": mem.wal_pending_count(),
|
||||||
|
"signed": mem.is_signed(),
|
||||||
});
|
});
|
||||||
println!("{}", serde_json::to_string_pretty(&j)?);
|
println!("{}", serde_json::to_string_pretty(&j)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
Commands::FlushWal => {
|
Commands::FlushWal => {
|
||||||
let mut mem = HDF5Memory::open(&cli.path)?;
|
let mut mem = open_writable(&path, &key)?;
|
||||||
let before = mem.wal_pending_count();
|
let before = mem.wal_pending_count();
|
||||||
mem.flush_wal()?;
|
mem.flush_wal()?;
|
||||||
let j = serde_json::json!({
|
let j = serde_json::json!({
|
||||||
@@ -187,7 +302,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Commands::AgentsMd { output } => {
|
Commands::AgentsMd { output } => {
|
||||||
let mem = HDF5Memory::open(&cli.path)?;
|
let mem = HDF5Memory::open_read_only(&path)?;
|
||||||
let md = mem.generate_agents_md();
|
let md = mem.generate_agents_md();
|
||||||
match output {
|
match output {
|
||||||
Some(p) => {
|
Some(p) => {
|
||||||
@@ -199,7 +314,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Commands::Export => {
|
Commands::Export => {
|
||||||
let mem = HDF5Memory::open(&cli.path)?;
|
let mem = HDF5Memory::open_read_only(&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 });
|
||||||
@@ -208,11 +323,44 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Commands::Keygen { .. } => unreachable!("handled before opening a store"),
|
||||||
|
|
||||||
|
Commands::Verify { public_key } => {
|
||||||
|
let text = if Path::new(&public_key).is_file() {
|
||||||
|
std::fs::read_to_string(&public_key)?
|
||||||
|
} else {
|
||||||
|
public_key
|
||||||
|
};
|
||||||
|
let bytes = signing::from_hex::<32>(&text)
|
||||||
|
.ok_or("--public-key must be 64 hex characters or a file holding them")?;
|
||||||
|
let trusted = VerifyingKey::from_bytes(&bytes)?;
|
||||||
|
let r = HDF5Memory::verify(&path, &trusted)?;
|
||||||
|
let j = serde_json::json!({
|
||||||
|
"valid": r.is_valid(),
|
||||||
|
"signed": r.signed,
|
||||||
|
"key_matches": r.key_matches,
|
||||||
|
"signature_valid": r.signature_valid,
|
||||||
|
"records_match": r.records_match,
|
||||||
|
"settings_match": r.settings_match,
|
||||||
|
"sessions_match": r.sessions_match,
|
||||||
|
"graph_match": r.graph_match,
|
||||||
|
"changed_records": r.changed_records,
|
||||||
|
"record_count": r.record_count,
|
||||||
|
"signed_record_count": r.signed_record_count,
|
||||||
|
"signed_by": r.public_key.map(|k| signing::to_hex(&k)),
|
||||||
|
"wal_entries_unsigned": r.wal_entries_unsigned,
|
||||||
|
});
|
||||||
|
println!("{}", serde_json::to_string_pretty(&j)?);
|
||||||
|
if !r.is_valid() {
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Commands::Snapshot { dest } => {
|
Commands::Snapshot { dest } => {
|
||||||
let _result = clawhdf5_agent::storage::snapshot_file(&cli.path, &dest)?;
|
let _result = clawhdf5_agent::storage::snapshot_file(&path, &dest)?;
|
||||||
let j = serde_json::json!({
|
let j = serde_json::json!({
|
||||||
"status": "snapshot_created",
|
"status": "snapshot_created",
|
||||||
"source": cli.path.display().to_string(),
|
"source": path.display().to_string(),
|
||||||
"dest": dest.display().to_string(),
|
"dest": dest.display().to_string(),
|
||||||
});
|
});
|
||||||
println!("{}", serde_json::to_string(&j)?);
|
println!("{}", serde_json::to_string(&j)?);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-derive"
|
name = "clawhdf5-derive"
|
||||||
version = "2.2.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version.workspace = true
|
||||||
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://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-filters"
|
name = "clawhdf5-filters"
|
||||||
version = "2.2.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version.workspace = true
|
||||||
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://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||||
@@ -25,8 +26,12 @@ name = "compression_bench"
|
|||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["fast-deflate"]
|
# Pure-Rust zlib-rs by default; `fast-deflate` (zlib-ng, C) overrides it.
|
||||||
|
default = ["zlib-rs"]
|
||||||
fast-deflate = ["flate2/zlib-ng"]
|
fast-deflate = ["flate2/zlib-ng"]
|
||||||
system-zlib = ["flate2/zlib-default"]
|
system-zlib = ["flate2/zlib-default"]
|
||||||
zlib-rs = ["flate2/zlib-rs"]
|
# `runtime_detection` gives zlib-rs `std`, which it needs to detect and use
|
||||||
|
# SIMD at runtime. flate2 enables it by default, but we build flate2 with
|
||||||
|
# default-features = false, and without it zlib-rs inflates 3.5x slower.
|
||||||
|
zlib-rs = ["flate2/zlib-rs", "flate2/runtime_detection"]
|
||||||
apple-compression = []
|
apple-compression = []
|
||||||
|
|||||||
@@ -8,16 +8,18 @@ Filter and compression pipeline for clawhdf5.
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- DEFLATE compression/decompression
|
- DEFLATE compression/decompression
|
||||||
- Fast deflate via zlib-ng (`fast-deflate` feature)
|
- Pure-Rust deflate via zlib-rs (default, `zlib-rs` feature)
|
||||||
|
- zlib-ng instead, if you want it (`fast-deflate` feature; C, needs cmake)
|
||||||
- Apple Compression framework support (`apple-compression` feature)
|
- Apple Compression framework support (`apple-compression` feature)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use clawhdf5_filters::{deflate_decode, deflate_encode};
|
use clawhdf5_filters::{deflate_compress, deflate_decompress};
|
||||||
|
|
||||||
let compressed = deflate_encode(&data, 6).unwrap();
|
let compressed = deflate_compress(&data, 6).unwrap();
|
||||||
let decompressed = deflate_decode(&compressed).unwrap();
|
// The second argument bounds the output: the expected decompressed size.
|
||||||
|
let decompressed = deflate_decompress(&compressed, data.len()).unwrap();
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
//! Fast deflate backends: Apple Compression Framework and zlib-ng.
|
//! Deflate backends: Apple Compression Framework, zlib-ng and zlib-rs.
|
||||||
//!
|
//!
|
||||||
//! Backend selection priority (decompression & compression):
|
//! Backend selection priority (decompression & compression):
|
||||||
//! 1. Apple Compression Framework (macOS only, `apple-compression` feature)
|
//! 1. Apple Compression Framework (macOS only, `apple-compression` feature)
|
||||||
//! 2. flate2 with zlib-ng backend (`fast-deflate` feature) or miniz_oxide (default)
|
//! 2. flate2 with zlib-ng (`fast-deflate`), else zlib-rs (`zlib-rs`, the
|
||||||
|
//! default), else miniz_oxide
|
||||||
//!
|
//!
|
||||||
//! The Apple Compression Framework uses hardware-accelerated zlib on Apple Silicon
|
//! The Apple Compression Framework uses hardware-accelerated zlib on Apple Silicon
|
||||||
//! and is typically the fastest option on macOS. zlib-ng is the fastest portable
|
//! and is typically the fastest option on macOS. zlib-rs is a pure-Rust port of
|
||||||
//! option and what C HDF5 uses internally.
|
//! zlib-ng; see `BENCHMARKS.md` for how the two compare.
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Apple Compression Framework FFI (macOS only)
|
// Apple Compression Framework FFI (macOS only)
|
||||||
@@ -243,65 +244,117 @@ mod apple {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Streaming decompression via flate2 (uses zlib-ng when fast-deflate enabled)
|
// One-shot (de)compression via flate2 (whichever backend flate2 was built with)
|
||||||
|
//
|
||||||
|
// The whole input goes to the codec in one call, into an output buffer sized
|
||||||
|
// up front. `flate2::read::ZlibDecoder` / `write::ZlibEncoder` stream through a
|
||||||
|
// 32 KiB buffer instead, which cost zlib-rs up to 3.7x against zlib-ng on a
|
||||||
|
// 1 MB chunk. clawhdf5-format's deflate filter does the same; see
|
||||||
|
// `BENCHMARKS.md`, "Deflate backend".
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Streaming decompress with pre-allocated output buffer.
|
/// Decompress into a buffer pre-sized to `output_size`, the expected
|
||||||
///
|
/// decompressed length (known for HDF5 chunks). Output longer than that is an
|
||||||
/// When the output size is known (typical for HDF5 chunks), this avoids
|
/// error, as is a stream that ends early.
|
||||||
/// dynamic reallocation by writing directly into a pre-sized buffer.
|
|
||||||
pub(crate) fn flate2_decompress_preallocated(
|
pub(crate) fn flate2_decompress_preallocated(
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
output_size: usize,
|
output_size: usize,
|
||||||
) -> Result<Vec<u8>, String> {
|
) -> Result<Vec<u8>, String> {
|
||||||
use std::io::Read;
|
inflate_bounded(data, output_size, output_size)
|
||||||
let mut decoder = flate2::read::ZlibDecoder::new(data);
|
|
||||||
let mut output = vec![0u8; output_size];
|
|
||||||
let mut total_read = 0;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match decoder.read(&mut output[total_read..]) {
|
|
||||||
Ok(0) => break,
|
|
||||||
Ok(n) => total_read += n,
|
|
||||||
Err(e) => return Err(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
output.truncate(total_read);
|
|
||||||
Ok(output)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Absolute ceiling on decompressed output when the caller has no size hint,
|
/// Absolute ceiling on decompressed output when the caller has no size hint,
|
||||||
/// preventing unbounded allocation from a hostile/corrupted zlib stream.
|
/// preventing unbounded allocation from a hostile/corrupted zlib stream.
|
||||||
const MAX_DECOMPRESS_SIZE: usize = 256 * 1024 * 1024;
|
const MAX_DECOMPRESS_SIZE: usize = 256 * 1024 * 1024;
|
||||||
|
|
||||||
/// Streaming decompress with dynamic sizing (when output size is unknown).
|
/// Decompress with no size hint, bounded by [`MAX_DECOMPRESS_SIZE`] so a
|
||||||
///
|
/// hostile zlib stream cannot force arbitrarily large allocation (a "zlib
|
||||||
/// Bounded by [`MAX_DECOMPRESS_SIZE`] since there is no chunk-size hint to
|
/// bomb").
|
||||||
/// validate against here — an unbounded `read_to_end` would let a hostile
|
|
||||||
/// zlib stream force arbitrarily large allocation (a "zlib bomb").
|
|
||||||
pub(crate) fn flate2_decompress_streaming(data: &[u8]) -> Result<Vec<u8>, String> {
|
pub(crate) fn flate2_decompress_streaming(data: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
use std::io::Read;
|
let hint = data.len().saturating_mul(4).min(1 << 20);
|
||||||
let decoder = flate2::read::ZlibDecoder::new(data);
|
inflate_bounded(data, hint, MAX_DECOMPRESS_SIZE).map_err(|e| {
|
||||||
let mut result = Vec::new();
|
if e.ends_with("exceeds size limit") {
|
||||||
decoder
|
format!(
|
||||||
.take(MAX_DECOMPRESS_SIZE as u64 + 1)
|
"decompressed output exceeds {} MiB limit",
|
||||||
.read_to_end(&mut result)
|
MAX_DECOMPRESS_SIZE / 1024 / 1024
|
||||||
.map_err(|e| e.to_string())?;
|
)
|
||||||
if result.len() > MAX_DECOMPRESS_SIZE {
|
} else {
|
||||||
return Err(format!(
|
e
|
||||||
"decompressed output exceeds {} MiB limit",
|
}
|
||||||
MAX_DECOMPRESS_SIZE / 1024 / 1024
|
})
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compress data using flate2 (zlib-ng when fast-deflate enabled, else miniz_oxide).
|
/// Inflate a zlib stream, starting from `size_hint` bytes of output and
|
||||||
|
/// failing past `limit`.
|
||||||
|
fn inflate_bounded(data: &[u8], size_hint: usize, limit: usize) -> Result<Vec<u8>, String> {
|
||||||
|
use flate2::{Decompress, FlushDecompress, Status};
|
||||||
|
|
||||||
|
// One byte of headroom past the limit distinguishes an over-size stream
|
||||||
|
// from one that legitimately ends exactly at the limit.
|
||||||
|
let max_capacity = limit.saturating_add(1);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.try_reserve_exact(size_hint.clamp(1, max_capacity))
|
||||||
|
.map_err(|e| format!("deflate: cannot allocate output: {e}"))?;
|
||||||
|
|
||||||
|
let mut inflater = Decompress::new(true);
|
||||||
|
loop {
|
||||||
|
let (in_before, out_before) = (inflater.total_in(), inflater.total_out());
|
||||||
|
let status = inflater
|
||||||
|
.decompress_vec(
|
||||||
|
&data[in_before as usize..],
|
||||||
|
&mut out,
|
||||||
|
FlushDecompress::Finish,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("deflate: {e}"))?;
|
||||||
|
if out.len() > limit {
|
||||||
|
return Err("deflate: output exceeds size limit".into());
|
||||||
|
}
|
||||||
|
match status {
|
||||||
|
Status::StreamEnd => return Ok(out),
|
||||||
|
Status::Ok | Status::BufError if out.len() == out.capacity() => {
|
||||||
|
let grow = out.capacity().min(max_capacity - out.capacity()).max(1);
|
||||||
|
out.try_reserve_exact(grow)
|
||||||
|
.map_err(|e| format!("deflate: cannot allocate output: {e}"))?;
|
||||||
|
}
|
||||||
|
Status::Ok | Status::BufError => {
|
||||||
|
if inflater.total_in() as usize >= data.len()
|
||||||
|
|| (inflater.total_in(), inflater.total_out()) == (in_before, out_before)
|
||||||
|
{
|
||||||
|
return Err("deflate: truncated stream".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compress data using flate2 (zlib-ng, zlib-rs or miniz_oxide; see module docs).
|
||||||
pub(crate) fn flate2_compress(data: &[u8], level: u32) -> Result<Vec<u8>, String> {
|
pub(crate) fn flate2_compress(data: &[u8], level: u32) -> Result<Vec<u8>, String> {
|
||||||
use std::io::Write;
|
use flate2::{Compress, Compression, FlushCompress, Status};
|
||||||
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(level));
|
|
||||||
encoder.write_all(data).map_err(|e| e.to_string())?;
|
// zlib's compressBound, plus the zlib header and trailer.
|
||||||
encoder.finish().map_err(|e| e.to_string())
|
let bound = data.len() + (data.len() >> 12) + (data.len() >> 14) + (data.len() >> 25) + 13 + 6;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.try_reserve_exact(bound)
|
||||||
|
.map_err(|e| format!("deflate: cannot allocate output: {e}"))?;
|
||||||
|
|
||||||
|
let mut deflater = Compress::new(Compression::new(level), true);
|
||||||
|
loop {
|
||||||
|
let (in_before, out_before) = (deflater.total_in(), deflater.total_out());
|
||||||
|
let status = deflater
|
||||||
|
.compress_vec(&data[in_before as usize..], &mut out, FlushCompress::Finish)
|
||||||
|
.map_err(|e| format!("deflate: {e}"))?;
|
||||||
|
match status {
|
||||||
|
Status::StreamEnd => return Ok(out),
|
||||||
|
Status::Ok | Status::BufError if out.len() == out.capacity() => out
|
||||||
|
.try_reserve(out.capacity().max(4096))
|
||||||
|
.map_err(|e| format!("deflate: cannot allocate output: {e}"))?,
|
||||||
|
Status::Ok | Status::BufError => {
|
||||||
|
if (deflater.total_in(), deflater.total_out()) == (in_before, out_before) {
|
||||||
|
return Err("deflate: encoder made no progress".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -312,7 +365,7 @@ pub(crate) fn flate2_compress(data: &[u8], level: u32) -> Result<Vec<u8>, String
|
|||||||
///
|
///
|
||||||
/// Selection order:
|
/// Selection order:
|
||||||
/// 1. Apple Compression Framework (macOS + `apple-compression` feature)
|
/// 1. Apple Compression Framework (macOS + `apple-compression` feature)
|
||||||
/// 2. flate2 (zlib-ng with `fast-deflate`, otherwise miniz_oxide)
|
/// 2. flate2 (zlib-ng with `fast-deflate`, else zlib-rs, else miniz_oxide)
|
||||||
///
|
///
|
||||||
/// When `output_hint` > 0, pre-allocates the output buffer for zero-copy
|
/// When `output_hint` > 0, pre-allocates the output buffer for zero-copy
|
||||||
/// decompression (avoids reallocation).
|
/// decompression (avoids reallocation).
|
||||||
@@ -344,7 +397,7 @@ pub fn decompress(data: &[u8], output_hint: usize) -> Result<Vec<u8>, String> {
|
|||||||
///
|
///
|
||||||
/// Selection order:
|
/// Selection order:
|
||||||
/// 1. Apple Compression Framework (macOS + `apple-compression` feature)
|
/// 1. Apple Compression Framework (macOS + `apple-compression` feature)
|
||||||
/// 2. flate2 (zlib-ng with `fast-deflate`, otherwise miniz_oxide)
|
/// 2. flate2 (zlib-ng with `fast-deflate`, else zlib-rs, else miniz_oxide)
|
||||||
pub fn compress(data: &[u8], level: u32) -> Result<Vec<u8>, String> {
|
pub fn compress(data: &[u8], level: u32) -> Result<Vec<u8>, String> {
|
||||||
#[cfg(all(target_os = "macos", feature = "apple-compression"))]
|
#[cfg(all(target_os = "macos", feature = "apple-compression"))]
|
||||||
{
|
{
|
||||||
@@ -377,9 +430,19 @@ pub fn active_backend() -> &'static str {
|
|||||||
{
|
{
|
||||||
"zlib-ng"
|
"zlib-ng"
|
||||||
}
|
}
|
||||||
|
// flate2 prefers a C zlib over zlib-rs when both are enabled.
|
||||||
|
#[cfg(all(
|
||||||
|
not(all(target_os = "macos", feature = "apple-compression")),
|
||||||
|
not(feature = "fast-deflate"),
|
||||||
|
feature = "zlib-rs"
|
||||||
|
))]
|
||||||
|
{
|
||||||
|
"zlib-rs"
|
||||||
|
}
|
||||||
#[cfg(not(any(
|
#[cfg(not(any(
|
||||||
all(target_os = "macos", feature = "apple-compression"),
|
all(target_os = "macos", feature = "apple-compression"),
|
||||||
feature = "fast-deflate"
|
feature = "fast-deflate",
|
||||||
|
feature = "zlib-rs"
|
||||||
)))]
|
)))]
|
||||||
{
|
{
|
||||||
"miniz_oxide"
|
"miniz_oxide"
|
||||||
@@ -436,7 +499,7 @@ mod tests {
|
|||||||
fn backend_name_is_set() {
|
fn backend_name_is_set() {
|
||||||
let name = active_backend();
|
let name = active_backend();
|
||||||
assert!(
|
assert!(
|
||||||
["miniz_oxide", "zlib-ng", "apple-compression"].contains(&name),
|
["miniz_oxide", "zlib-rs", "zlib-ng", "apple-compression"].contains(&name),
|
||||||
"unexpected backend: {name}"
|
"unexpected backend: {name}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
//!
|
//!
|
||||||
//! Provides deflate (zlib) decompression/compression with multiple backend options:
|
//! Provides deflate (zlib) decompression/compression with multiple backend options:
|
||||||
//!
|
//!
|
||||||
//! - **Default**: `miniz_oxide` (pure Rust, no C dependencies)
|
//! - **Default (`zlib-rs` feature)**: `zlib-rs` via flate2 (pure Rust, no C
|
||||||
//! - **`fast-deflate` feature**: `zlib-ng` via flate2 (~2-3x faster, matches C HDF5)
|
//! dependencies)
|
||||||
|
//! - **`fast-deflate` feature**: `zlib-ng` via flate2 (C, built with cmake)
|
||||||
//! - **`apple-compression` feature**: Apple Compression Framework on macOS
|
//! - **`apple-compression` feature**: Apple Compression Framework on macOS
|
||||||
//! (hardware-accelerated on Apple Silicon)
|
//! (hardware-accelerated on Apple Silicon)
|
||||||
|
//! - With none of the above: `miniz_oxide` (pure Rust, slower)
|
||||||
//!
|
//!
|
||||||
//! Backend priority: apple-compression > zlib-ng > miniz_oxide.
|
//! Backend priority: apple-compression > zlib-ng > zlib-rs > miniz_oxide.
|
||||||
|
|
||||||
pub mod fast_deflate;
|
pub mod fast_deflate;
|
||||||
|
|
||||||
@@ -115,7 +117,7 @@ mod tests {
|
|||||||
fn backend_reports_name() {
|
fn backend_reports_name() {
|
||||||
let name = deflate_backend();
|
let name = deflate_backend();
|
||||||
assert!(
|
assert!(
|
||||||
["miniz_oxide", "zlib-ng", "apple-compression"].contains(&name),
|
["miniz_oxide", "zlib-rs", "zlib-ng", "apple-compression"].contains(&name),
|
||||||
"unexpected backend: {name}"
|
"unexpected backend: {name}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-format"
|
name = "clawhdf5-format"
|
||||||
version = "2.2.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version.workspace = true
|
||||||
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://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||||
@@ -23,16 +24,20 @@ libaec-sys = { path = "../libaec-sys", version = "0.1", optional = true }
|
|||||||
pco = { version = "1.0", optional = true }
|
pco = { version = "1.0", optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
half = { workspace = true }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
criterion = { workspace = true }
|
criterion = { workspace = true }
|
||||||
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.2.0" }
|
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.7.0" }
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "bench"
|
name = "bench"
|
||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["std", "checksum", "deflate", "provenance", "fast-deflate", "system-zlib-decompress"]
|
# Deflate backend: `zlib-rs` (pure Rust) by default. `fast-deflate` selects
|
||||||
|
# zlib-ng instead (C, built with cmake); flate2 prefers a C zlib whenever one
|
||||||
|
# is enabled, so turning it on anywhere in the build overrides the default.
|
||||||
|
default = ["std", "checksum", "deflate", "provenance", "zlib-rs", "system-zlib-decompress"]
|
||||||
std = []
|
std = []
|
||||||
checksum = []
|
checksum = []
|
||||||
deflate = ["flate2"]
|
deflate = ["flate2"]
|
||||||
@@ -42,7 +47,10 @@ fast-checksum = ["crc32fast"]
|
|||||||
fast-deflate = ["flate2/zlib-ng"]
|
fast-deflate = ["flate2/zlib-ng"]
|
||||||
system-zlib = ["flate2/zlib-default"]
|
system-zlib = ["flate2/zlib-default"]
|
||||||
system-zlib-decompress = []
|
system-zlib-decompress = []
|
||||||
zlib-rs = ["flate2/zlib-rs"]
|
# `runtime_detection` gives zlib-rs `std`, which it needs to detect and use
|
||||||
|
# SIMD at runtime. flate2 enables it by default, but we build flate2 with
|
||||||
|
# default-features = false, and without it zlib-rs inflates 3.5x slower.
|
||||||
|
zlib-rs = ["flate2/zlib-rs", "flate2/runtime_detection"]
|
||||||
lz4 = ["lz4_flex"]
|
lz4 = ["lz4_flex"]
|
||||||
zstd = ["dep:zstd"]
|
zstd = ["dep:zstd"]
|
||||||
blake3_hash = ["blake3"]
|
blake3_hash = ["blake3"]
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
target/
|
target/
|
||||||
|
corpus/
|
||||||
|
artifacts/
|
||||||
|
coverage/
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,15 +1,36 @@
|
|||||||
#![no_main]
|
#![no_main]
|
||||||
|
use clawhdf5_format::btree_v2::{BTreeV2Header, collect_btree_v2_records};
|
||||||
use libfuzzer_sys::fuzz_target;
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
fuzz_target!(|data: &[u8]| {
|
fuzz_target!(|data: &[u8]| {
|
||||||
for &offset_size in &[4u8, 8] {
|
for &offset_size in &[4u8, 8] {
|
||||||
for &length_size in &[4u8, 8] {
|
for &length_size in &[4u8, 8] {
|
||||||
let _ = clawhdf5_format::btree_v2::BTreeV2Header::parse(
|
if let Ok(header) = BTreeV2Header::parse(data, 0, offset_size, length_size) {
|
||||||
data,
|
let _ = collect_btree_v2_records(data, &header, offset_size, length_size);
|
||||||
0,
|
}
|
||||||
offset_size,
|
|
||||||
length_size,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parsing a header requires a valid checksum, which random input almost
|
||||||
|
// never has, so the traversal behind it went unfuzzed — and that is where
|
||||||
|
// a node listing itself as its own child overflowed the stack. Take the
|
||||||
|
// header fields straight from the input instead and walk the rest.
|
||||||
|
let Some((fields, file)) = data.split_first_chunk::<20>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let header = BTreeV2Header {
|
||||||
|
tree_type: fields[0],
|
||||||
|
node_size: u32::from_le_bytes([fields[1], fields[2], fields[3], fields[4]]),
|
||||||
|
record_size: u16::from_le_bytes([fields[5], fields[6]]),
|
||||||
|
depth: u16::from_le_bytes([fields[7], fields[8]]),
|
||||||
|
root_node_address: u64::from(u32::from_le_bytes([
|
||||||
|
fields[9], fields[10], fields[11], fields[12],
|
||||||
|
])),
|
||||||
|
num_records_in_root: u16::from_le_bytes([fields[13], fields[14]]),
|
||||||
|
total_records: u64::from(u32::from_le_bytes([
|
||||||
|
fields[15], fields[16], fields[17], fields[18],
|
||||||
|
])),
|
||||||
|
};
|
||||||
|
let offset_size = if fields[19] & 1 == 0 { 4 } else { 8 };
|
||||||
|
let _ = collect_btree_v2_records(file, &header, offset_size, 8);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
//! HDF5 Attribute message parsing (message type 0x000C).
|
//! HDF5 Attribute message parsing (message type 0x000C).
|
||||||
|
|
||||||
#[cfg(not(feature = "std"))]
|
#[cfg(not(feature = "std"))]
|
||||||
use alloc::{string::String, vec::Vec};
|
use alloc::{borrow::Cow, string::String, vec::Vec};
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
use crate::attribute_info::AttributeInfoMessage;
|
use crate::attribute_info::AttributeInfoMessage;
|
||||||
use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records};
|
use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records};
|
||||||
@@ -48,17 +50,64 @@ 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),
|
2 => Self::parse_v2(data, length_size, file),
|
||||||
3 => Self::parse_v3(data, length_size),
|
3 => Self::parse_v3(data, length_size, file),
|
||||||
_ => 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_sized(bytes, offset_size, length_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)?;
|
||||||
@@ -94,7 +143,13 @@ impl AttributeMessage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_v2(data: &[u8], length_size: u8) -> Result<AttributeMessage, FormatError> {
|
fn parse_v2(
|
||||||
|
data: &[u8],
|
||||||
|
length_size: u8,
|
||||||
|
file: Option<(&[u8], u8)>,
|
||||||
|
) -> Result<AttributeMessage, FormatError> {
|
||||||
|
// Flags: bit 0 = datatype is shared, bit 1 = dataspace is shared.
|
||||||
|
let flags = data.get(1).copied().unwrap_or(0);
|
||||||
// version(1) + flags(1) + name_size(2) + datatype_size(2) + dataspace_size(2) = 8
|
// 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;
|
||||||
@@ -110,12 +165,26 @@ impl AttributeMessage {
|
|||||||
|
|
||||||
// Datatype (NO padding)
|
// Datatype (NO padding)
|
||||||
ensure_len(data, pos, datatype_size)?;
|
ensure_len(data, pos, datatype_size)?;
|
||||||
let (datatype, _) = Datatype::parse(&data[pos..pos + datatype_size])?;
|
let dt_bytes = Self::embedded_message(
|
||||||
|
&data[pos..pos + datatype_size],
|
||||||
|
flags & 0x01 != 0,
|
||||||
|
MessageType::Datatype,
|
||||||
|
length_size,
|
||||||
|
file,
|
||||||
|
)?;
|
||||||
|
let (datatype, _) = Datatype::parse(&dt_bytes)?;
|
||||||
pos += datatype_size;
|
pos += datatype_size;
|
||||||
|
|
||||||
// Dataspace (NO padding)
|
// Dataspace (NO padding)
|
||||||
ensure_len(data, pos, dataspace_size)?;
|
ensure_len(data, pos, dataspace_size)?;
|
||||||
let dataspace = Dataspace::parse(&data[pos..pos + dataspace_size], length_size)?;
|
let ds_bytes = Self::embedded_message(
|
||||||
|
&data[pos..pos + dataspace_size],
|
||||||
|
flags & 0x02 != 0,
|
||||||
|
MessageType::Dataspace,
|
||||||
|
length_size,
|
||||||
|
file,
|
||||||
|
)?;
|
||||||
|
let dataspace = Dataspace::parse(&ds_bytes, length_size)?;
|
||||||
pos += dataspace_size;
|
pos += dataspace_size;
|
||||||
|
|
||||||
let raw_data = compute_raw_data(data, pos, &dataspace, &datatype);
|
let raw_data = compute_raw_data(data, pos, &dataspace, &datatype);
|
||||||
@@ -128,7 +197,13 @@ impl AttributeMessage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_v3(data: &[u8], length_size: u8) -> Result<AttributeMessage, FormatError> {
|
fn parse_v3(
|
||||||
|
data: &[u8],
|
||||||
|
length_size: u8,
|
||||||
|
file: Option<(&[u8], u8)>,
|
||||||
|
) -> Result<AttributeMessage, FormatError> {
|
||||||
|
// Flags: bit 0 = datatype is shared, bit 1 = dataspace is shared.
|
||||||
|
let flags = data.get(1).copied().unwrap_or(0);
|
||||||
// version(1) + flags(1) + name_size(2) + datatype_size(2) + dataspace_size(2) + encoding(1) = 9
|
// 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;
|
||||||
@@ -145,12 +220,26 @@ impl AttributeMessage {
|
|||||||
|
|
||||||
// Datatype (NO padding)
|
// Datatype (NO padding)
|
||||||
ensure_len(data, pos, datatype_size)?;
|
ensure_len(data, pos, datatype_size)?;
|
||||||
let (datatype, _) = Datatype::parse(&data[pos..pos + datatype_size])?;
|
let dt_bytes = Self::embedded_message(
|
||||||
|
&data[pos..pos + datatype_size],
|
||||||
|
flags & 0x01 != 0,
|
||||||
|
MessageType::Datatype,
|
||||||
|
length_size,
|
||||||
|
file,
|
||||||
|
)?;
|
||||||
|
let (datatype, _) = Datatype::parse(&dt_bytes)?;
|
||||||
pos += datatype_size;
|
pos += datatype_size;
|
||||||
|
|
||||||
// Dataspace (NO padding)
|
// Dataspace (NO padding)
|
||||||
ensure_len(data, pos, dataspace_size)?;
|
ensure_len(data, pos, dataspace_size)?;
|
||||||
let dataspace = Dataspace::parse(&data[pos..pos + dataspace_size], length_size)?;
|
let ds_bytes = Self::embedded_message(
|
||||||
|
&data[pos..pos + dataspace_size],
|
||||||
|
flags & 0x02 != 0,
|
||||||
|
MessageType::Dataspace,
|
||||||
|
length_size,
|
||||||
|
file,
|
||||||
|
)?;
|
||||||
|
let dataspace = Dataspace::parse(&ds_bytes, length_size)?;
|
||||||
pos += dataspace_size;
|
pos += dataspace_size;
|
||||||
|
|
||||||
let raw_data = compute_raw_data(data, pos, &dataspace, &datatype);
|
let raw_data = compute_raw_data(data, pos, &dataspace, &datatype);
|
||||||
@@ -305,32 +394,80 @@ pub fn find_attribute<'a>(
|
|||||||
///
|
///
|
||||||
/// Use this instead of `extract_attributes` when reading files that may use dense storage
|
/// Use this instead of `extract_attributes` when reading files that may use dense storage
|
||||||
/// (e.g., objects with many attributes, typically >8).
|
/// (e.g., objects with many attributes, typically >8).
|
||||||
|
///
|
||||||
|
/// Fails if any attribute cannot be read; see [`extract_attributes_tolerant`]
|
||||||
|
/// to read the others.
|
||||||
pub fn extract_attributes_full(
|
pub fn extract_attributes_full(
|
||||||
file_data: &[u8],
|
file_data: &[u8],
|
||||||
header: &ObjectHeader,
|
header: &ObjectHeader,
|
||||||
offset_size: u8,
|
offset_size: u8,
|
||||||
length_size: u8,
|
length_size: u8,
|
||||||
|
) -> Result<Vec<AttributeMessage>, FormatError> {
|
||||||
|
extract_attributes_with(file_data, header, offset_size, length_size, &mut Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`extract_attributes_full`], but an attribute that cannot be read
|
||||||
|
/// (a corrupt or unsupported attribute message, or a heap object that cannot
|
||||||
|
/// be located) is left out and its error returned alongside the attributes
|
||||||
|
/// that could be read, instead of failing them all.
|
||||||
|
///
|
||||||
|
/// Errors in the structures that index the attributes (the Attribute Info
|
||||||
|
/// message, the dense-storage heap header or B-tree) still fail the call:
|
||||||
|
/// then it is unknown which attributes exist at all.
|
||||||
|
pub fn extract_attributes_tolerant(
|
||||||
|
file_data: &[u8],
|
||||||
|
header: &ObjectHeader,
|
||||||
|
offset_size: u8,
|
||||||
|
length_size: u8,
|
||||||
|
) -> Result<(Vec<AttributeMessage>, Vec<FormatError>), FormatError> {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let attrs = extract_attributes_with(file_data, header, offset_size, length_size, &mut |e| {
|
||||||
|
errors.push(e);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok((attrs, errors))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read every attribute; each one that fails goes to `on_error`, which
|
||||||
|
/// either stops the read (returns the error) or skips that attribute.
|
||||||
|
fn extract_attributes_with(
|
||||||
|
file_data: &[u8],
|
||||||
|
header: &ObjectHeader,
|
||||||
|
offset_size: u8,
|
||||||
|
length_size: u8,
|
||||||
|
on_error: &mut dyn FnMut(FormatError) -> Result<(), FormatError>,
|
||||||
) -> Result<Vec<AttributeMessage>, FormatError> {
|
) -> Result<Vec<AttributeMessage>, FormatError> {
|
||||||
let mut attrs = Vec::new();
|
let mut attrs = Vec::new();
|
||||||
|
|
||||||
// Collect compact attributes (inline in OH)
|
// Collect compact attributes (inline in OH)
|
||||||
for msg in &header.messages {
|
for msg in &header.messages {
|
||||||
if msg.msg_type == MessageType::Attribute {
|
if msg.msg_type == MessageType::Attribute {
|
||||||
if shared_message::is_shared(msg.flags) {
|
let attr = if shared_message::is_shared(msg.flags) {
|
||||||
// Shared attribute: resolve the reference to get actual attribute data
|
// Shared attribute: resolve the reference to get actual attribute data
|
||||||
let shared_ref = shared_message::parse_shared_ref(&msg.data, offset_size)?;
|
shared_message::parse_shared_ref_sized(&msg.data, offset_size, length_size)
|
||||||
let resolved_data = shared_message::resolve_shared_message(
|
.and_then(|shared_ref| {
|
||||||
file_data,
|
shared_message::resolve_shared_message(
|
||||||
&shared_ref,
|
file_data,
|
||||||
MessageType::Attribute,
|
&shared_ref,
|
||||||
offset_size,
|
MessageType::Attribute,
|
||||||
length_size,
|
offset_size,
|
||||||
)?;
|
length_size,
|
||||||
let attr = AttributeMessage::parse(&resolved_data, length_size)?;
|
)
|
||||||
attrs.push(attr);
|
})
|
||||||
|
.and_then(|resolved| {
|
||||||
|
AttributeMessage::parse_in_file(
|
||||||
|
&resolved,
|
||||||
|
file_data,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
)
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
let attr = AttributeMessage::parse(&msg.data, length_size)?;
|
AttributeMessage::parse_in_file(&msg.data, file_data, offset_size, length_size)
|
||||||
attrs.push(attr);
|
};
|
||||||
|
match attr {
|
||||||
|
Ok(attr) => attrs.push(attr),
|
||||||
|
Err(e) => on_error(e)?,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,9 +477,15 @@ pub fn extract_attributes_full(
|
|||||||
if let Some(info) = attr_info
|
if let Some(info) = attr_info
|
||||||
&& let Some(fh_addr) = info.fractal_heap_address
|
&& let Some(fh_addr) = info.fractal_heap_address
|
||||||
{
|
{
|
||||||
let dense_attrs =
|
extract_dense_attributes(
|
||||||
extract_dense_attributes(file_data, &info, fh_addr, offset_size, length_size)?;
|
file_data,
|
||||||
attrs.extend(dense_attrs);
|
&info,
|
||||||
|
fh_addr,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
&mut attrs,
|
||||||
|
on_error,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(attrs)
|
Ok(attrs)
|
||||||
@@ -369,7 +512,9 @@ fn extract_dense_attributes(
|
|||||||
fh_addr: u64,
|
fh_addr: u64,
|
||||||
offset_size: u8,
|
offset_size: u8,
|
||||||
length_size: u8,
|
length_size: u8,
|
||||||
) -> Result<Vec<AttributeMessage>, FormatError> {
|
attrs: &mut Vec<AttributeMessage>,
|
||||||
|
on_error: &mut dyn FnMut(FormatError) -> Result<(), 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)?;
|
||||||
|
|
||||||
@@ -383,27 +528,32 @@ fn extract_dense_attributes(
|
|||||||
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 attrs = Vec::new();
|
|
||||||
for record in &records {
|
for record in &records {
|
||||||
// Per HDF5 spec, both type 8 and type 9 records start with heap_id:
|
// Per HDF5 spec, both type 8 and type 9 records start with heap_id:
|
||||||
// Type 8: heap_id(8) + msg_flags(1) + creation_order(4) + hash(4)
|
// Type 8: heap_id(8) + msg_flags(1) + creation_order(4) + hash(4)
|
||||||
// Type 9: heap_id(8) + msg_flags(1) + creation_order(4)
|
// Type 9: heap_id(8) + msg_flags(1) + creation_order(4)
|
||||||
let id_offset = 0;
|
let id_len = fh.heap_id_length as usize;
|
||||||
|
let Some(id_bytes) = record.data.get(..id_len) else {
|
||||||
if record.data.len() < id_offset + fh.heap_id_length as usize {
|
on_error(FormatError::UnexpectedEof {
|
||||||
|
expected: id_len,
|
||||||
|
available: record.data.len(),
|
||||||
|
})?;
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
let id_bytes = &record.data[id_offset..id_offset + fh.heap_id_length as usize];
|
|
||||||
|
|
||||||
// Read attribute message from fractal heap
|
|
||||||
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 = AttributeMessage::parse(&attr_data, length_size)?;
|
let attr = fh
|
||||||
attrs.push(attr);
|
.read_managed_object(file_data, id_bytes, offset_size)
|
||||||
|
.and_then(|attr_data| {
|
||||||
|
AttributeMessage::parse_in_file(&attr_data, file_data, offset_size, length_size)
|
||||||
|
});
|
||||||
|
match attr {
|
||||||
|
Ok(attr) => attrs.push(attr),
|
||||||
|
Err(e) => on_error(e)?,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(attrs)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -472,14 +622,13 @@ mod tests {
|
|||||||
|
|
||||||
// Name padded to 8 bytes
|
// Name padded to 8 bytes
|
||||||
data.extend_from_slice(name);
|
data.extend_from_slice(name);
|
||||||
while data.len() % 8 != 0 || data.len() == 8 {
|
if data.len() % 8 != 0 || data.len() == 8 {
|
||||||
// Pad name to 8-byte boundary from start of name
|
// 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
|
||||||
@@ -749,11 +898,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.14f64.to_le_bytes());
|
data.extend_from_slice(&3.25f64.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.14]);
|
assert_eq!(vals, vec![3.25]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -172,6 +172,17 @@ fn max_records_leaf(node_size: u32, record_size: u16) -> u64 {
|
|||||||
((node_size - overhead) / record_size as u32) as u64
|
((node_size - overhead) / record_size as u32) as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deepest B-tree v2 accepted. See [`collect_btree_v2_records`].
|
||||||
|
const MAX_DEPTH: u16 = 64;
|
||||||
|
|
||||||
|
/// Take `n` records from the traversal's budget, or refuse the tree.
|
||||||
|
fn spend(budget: &mut usize, n: usize) -> Result<(), FormatError> {
|
||||||
|
*budget = budget
|
||||||
|
.checked_sub(n)
|
||||||
|
.ok_or(FormatError::NestingDepthExceeded)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Collect all records from a B-tree v2 by traversing from the root.
|
/// Collect all records from a B-tree v2 by traversing from the root.
|
||||||
pub fn collect_btree_v2_records(
|
pub fn collect_btree_v2_records(
|
||||||
file_data: &[u8],
|
file_data: &[u8],
|
||||||
@@ -182,6 +193,22 @@ pub fn collect_btree_v2_records(
|
|||||||
if header.total_records == 0 || header.num_records_in_root == 0 {
|
if header.total_records == 0 || header.num_records_in_root == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
// Recursion is one frame per level, and the depth is read from the file:
|
||||||
|
// a crafted header claiming 65 535 levels over a node that is its own
|
||||||
|
// child overflowed the stack. 64 matches the fractal heap's guard, and no
|
||||||
|
// real tree comes close — even at the minimum fan-out of two it would
|
||||||
|
// hold more than 2^64 records.
|
||||||
|
if header.depth > MAX_DEPTH {
|
||||||
|
return Err(FormatError::NestingDepthExceeded);
|
||||||
|
}
|
||||||
|
// A valid tree stores each record once, in its own bytes, so it cannot
|
||||||
|
// hold more records than the file has room for. Children are addresses,
|
||||||
|
// though, and nothing makes them distinct: levels whose children all
|
||||||
|
// point at one shared node below reach it fan-out^depth times, which is
|
||||||
|
// millions of records from a few kilobytes. Counting against what the
|
||||||
|
// file could physically contain bounds that without trusting the
|
||||||
|
// header's own `total_records`.
|
||||||
|
let mut budget = file_data.len() / usize::from(header.record_size.max(1));
|
||||||
|
|
||||||
let max_leaf_nrec = max_records_leaf(header.node_size, header.record_size);
|
let max_leaf_nrec = max_records_leaf(header.node_size, header.record_size);
|
||||||
|
|
||||||
@@ -206,6 +233,7 @@ pub fn collect_btree_v2_records(
|
|||||||
offset_size,
|
offset_size,
|
||||||
length_size,
|
length_size,
|
||||||
max_leaf_nrec,
|
max_leaf_nrec,
|
||||||
|
&mut budget,
|
||||||
&mut records,
|
&mut records,
|
||||||
)?;
|
)?;
|
||||||
Ok(records)
|
Ok(records)
|
||||||
@@ -273,6 +301,7 @@ fn collect_internal_records(
|
|||||||
offset_size: u8,
|
offset_size: u8,
|
||||||
length_size: u8,
|
length_size: u8,
|
||||||
max_leaf_nrec: u64,
|
max_leaf_nrec: u64,
|
||||||
|
budget: &mut usize,
|
||||||
out: &mut Vec<BTreeV2Record>,
|
out: &mut Vec<BTreeV2Record>,
|
||||||
) -> Result<(), FormatError> {
|
) -> Result<(), FormatError> {
|
||||||
// signature(4) + version(1) + type(1) = 6
|
// signature(4) + version(1) + type(1) = 6
|
||||||
@@ -294,39 +323,21 @@ fn collect_internal_records(
|
|||||||
let records_start = pos;
|
let records_start = pos;
|
||||||
pos += records_total;
|
pos += records_total;
|
||||||
|
|
||||||
// Compute sizes for child pointers
|
// Child pointer layout, as libhdf5 computes it (H5B2__hdr_init): the
|
||||||
// max_records at child depth - for variable-width nrec encoding
|
// child's record count is always encoded in the width needed for a
|
||||||
|
// *leaf's* maximum, and — below the first internal level — the child
|
||||||
|
// subtree's total record count in the width needed for the most records
|
||||||
|
// a subtree of that depth can hold.
|
||||||
let child_depth = depth - 1;
|
let child_depth = depth - 1;
|
||||||
let max_nrec_child = if child_depth == 0 {
|
let nrec_width = bytes_for_max_records(max_leaf_nrec);
|
||||||
max_leaf_nrec
|
|
||||||
} else {
|
|
||||||
// For internal nodes at child_depth, the true max_nrec depends on the
|
|
||||||
// node size, record size, and the recursive width of child pointer
|
|
||||||
// entries (which themselves depend on max_nrec at deeper levels).
|
|
||||||
// Computing the exact value requires iterating from the leaf level
|
|
||||||
// upward, as described in the HDF5 spec (III.A.2 "Computing the Size
|
|
||||||
// of B-tree Nodes").
|
|
||||||
//
|
|
||||||
// We use `max_leaf_nrec * 2` as a conservative upper bound. This
|
|
||||||
// over-estimates the nrec encoding width, which means we may read
|
|
||||||
// slightly more bytes per child pointer than strictly necessary, but
|
|
||||||
// never fewer. The over-read bytes are harmless because we only
|
|
||||||
// decode `num_records` entries (the actual count from the node header).
|
|
||||||
//
|
|
||||||
// Known limitation: for very deep trees (depth > 3) with small record
|
|
||||||
// sizes, the true max could exceed this estimate, causing us to
|
|
||||||
// under-allocate the nrec encoding width and misparse child pointers.
|
|
||||||
// In practice, HDF5 B-tree v2 depths rarely exceed 2-3.
|
|
||||||
max_leaf_nrec * 2
|
|
||||||
};
|
|
||||||
let nrec_width = bytes_for_max_records(max_nrec_child);
|
|
||||||
|
|
||||||
// Total records in subtree width (only if depth > 1)
|
|
||||||
let total_nrec_width = if depth > 1 {
|
let total_nrec_width = if depth > 1 {
|
||||||
// Width to hold total records in a subtree
|
bytes_for_max_records(cum_max_records(
|
||||||
// We compute max possible total records at this subtree depth
|
node_size,
|
||||||
let max_total = header_max_total_records(max_leaf_nrec, depth - 1);
|
record_size,
|
||||||
bytes_for_max_records(max_total)
|
offset_size,
|
||||||
|
max_leaf_nrec,
|
||||||
|
child_depth,
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
@@ -350,6 +361,8 @@ fn collect_internal_records(
|
|||||||
// We collect child[0] records, then record[0], then child[1], etc.
|
// We collect child[0] records, then record[0], then child[1], etc.
|
||||||
for (i, &(child_addr, child_nrec)) in children.iter().enumerate() {
|
for (i, &(child_addr, child_nrec)) in children.iter().enumerate() {
|
||||||
if child_depth == 0 {
|
if child_depth == 0 {
|
||||||
|
// Before parsing, so a refused tree is not also a large allocation.
|
||||||
|
spend(budget, usize::from(child_nrec))?;
|
||||||
let leaf_recs =
|
let leaf_recs =
|
||||||
parse_leaf_records(file_data, child_addr as usize, child_nrec, record_size)?;
|
parse_leaf_records(file_data, child_addr as usize, child_nrec, record_size)?;
|
||||||
out.extend(leaf_recs);
|
out.extend(leaf_recs);
|
||||||
@@ -364,6 +377,7 @@ fn collect_internal_records(
|
|||||||
offset_size,
|
offset_size,
|
||||||
length_size,
|
length_size,
|
||||||
max_leaf_nrec,
|
max_leaf_nrec,
|
||||||
|
budget,
|
||||||
out,
|
out,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
@@ -393,6 +407,7 @@ fn collect_internal_records(
|
|||||||
available: file_data.len(),
|
available: file_data.len(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
spend(budget, 1)?;
|
||||||
out.push(BTreeV2Record {
|
out.push(BTreeV2Record {
|
||||||
data: file_data[rec_start..rec_end].to_vec(),
|
data: file_data[rec_start..rec_end].to_vec(),
|
||||||
});
|
});
|
||||||
@@ -402,20 +417,43 @@ fn collect_internal_records(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Estimate maximum total records at a given depth (for variable-width encoding).
|
/// Most records a subtree whose root is at `depth` can hold (libhdf5's
|
||||||
fn header_max_total_records(max_leaf_nrec: u64, depth: u16) -> u64 {
|
/// `cum_max_nrec`): a leaf holds `max_leaf_nrec`; an internal node at depth
|
||||||
// Conservative: branching factor * max_leaf at each level
|
/// `d` holds `max_nrec(d)` records and `max_nrec(d) + 1` subtrees of depth
|
||||||
let mut total = max_leaf_nrec;
|
/// `d - 1`, where `max_nrec(d)` is what fits in a node once each record is
|
||||||
for _ in 0..depth {
|
/// paired with a child pointer of the width depth `d` needs.
|
||||||
total = total.saturating_mul(max_leaf_nrec.max(2));
|
fn cum_max_records(
|
||||||
|
node_size: u32,
|
||||||
|
record_size: u16,
|
||||||
|
offset_size: u8,
|
||||||
|
max_leaf_nrec: u64,
|
||||||
|
depth: u16,
|
||||||
|
) -> u64 {
|
||||||
|
// Internal node overhead: signature(4) + version(1) + type(1) + checksum(4).
|
||||||
|
const PREFIX: u64 = 10;
|
||||||
|
let nrec_width = bytes_for_max_records(max_leaf_nrec) as u64;
|
||||||
|
let mut cum = max_leaf_nrec;
|
||||||
|
let mut cum_width = 0u64;
|
||||||
|
for d in 1..=depth {
|
||||||
|
let ptr = u64::from(offset_size) + nrec_width + if d > 1 { cum_width } else { 0 };
|
||||||
|
let max_nrec = u64::from(node_size)
|
||||||
|
.saturating_sub(PREFIX)
|
||||||
|
.saturating_sub(ptr)
|
||||||
|
/ (u64::from(record_size) + ptr).max(1);
|
||||||
|
cum = max_nrec
|
||||||
|
.saturating_add(1)
|
||||||
|
.saturating_mul(cum)
|
||||||
|
.saturating_add(max_nrec);
|
||||||
|
cum_width = bytes_for_max_records(cum) as u64;
|
||||||
}
|
}
|
||||||
total
|
cum
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
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,
|
||||||
@@ -465,6 +503,130 @@ mod tests {
|
|||||||
buf
|
buf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An internal node laid out exactly as `collect_internal_records` will
|
||||||
|
/// read it at `depth`: `records` zeroed records, then `children` pointers,
|
||||||
|
/// all to `child_addr` claiming `child_nrec` records.
|
||||||
|
fn internal_node(
|
||||||
|
depth: u16,
|
||||||
|
node_size: u32,
|
||||||
|
record_size: u16,
|
||||||
|
records: usize,
|
||||||
|
children: usize,
|
||||||
|
child_addr: u64,
|
||||||
|
child_nrec: u64,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let max_leaf = max_records_leaf(node_size, record_size);
|
||||||
|
let nrec_width = bytes_for_max_records(max_leaf);
|
||||||
|
let total_width = if depth > 1 {
|
||||||
|
bytes_for_max_records(cum_max_records(
|
||||||
|
node_size,
|
||||||
|
record_size,
|
||||||
|
8,
|
||||||
|
max_leaf,
|
||||||
|
depth - 1,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let mut buf = b"BTIN".to_vec();
|
||||||
|
buf.extend_from_slice(&[0, 5]);
|
||||||
|
buf.resize(buf.len() + records * record_size as usize, 0);
|
||||||
|
for _ in 0..children {
|
||||||
|
buf.extend_from_slice(&child_addr.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&child_nrec.to_le_bytes()[..nrec_width]);
|
||||||
|
buf.resize(buf.len() + total_width, 0);
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header(depth: u16, root: u64, root_nrec: u16, total: u64) -> BTreeV2Header {
|
||||||
|
BTreeV2Header {
|
||||||
|
tree_type: 5,
|
||||||
|
node_size: 512,
|
||||||
|
record_size: 8,
|
||||||
|
depth,
|
||||||
|
root_node_address: root,
|
||||||
|
num_records_in_root: root_nrec,
|
||||||
|
total_records: total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_node_that_is_its_own_child_is_rejected_not_recursed() {
|
||||||
|
// One internal node whose two children are itself, under a header
|
||||||
|
// claiming the deepest tree a u16 allows. The layout stops depending
|
||||||
|
// on depth once the subtree-total width saturates, so every level
|
||||||
|
// parses cleanly and recursion runs ~65 000 frames deep: before the
|
||||||
|
// cap this overflowed the stack and aborted the process, from a file
|
||||||
|
// of under 100 bytes.
|
||||||
|
let mut data = internal_node(u16::MAX, 512, 8, 1, 2, 0, 1);
|
||||||
|
data.resize(4096, 0);
|
||||||
|
let result = collect_btree_v2_records(&data, &header(u16::MAX, 0, 1, 1), 8, 8);
|
||||||
|
assert!(result.is_err(), "{result:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shared_subtree_cannot_multiply_the_work() {
|
||||||
|
// A chain of distinct levels, each node's children all pointing at the
|
||||||
|
// single node below, ending in a real leaf. Every node parses and
|
||||||
|
// nothing is cyclic, yet the leaf is reached fan-out^depth times: 62
|
||||||
|
// children over 4 levels is ~15 million leaf visits from a few
|
||||||
|
// kilobytes. A valid tree cannot hold more records than the file has
|
||||||
|
// room for, so that bounds the traversal instead.
|
||||||
|
let (node_size, record_size) = (512u32, 8u16);
|
||||||
|
let fanout = 62usize;
|
||||||
|
let depth = 4u16;
|
||||||
|
let leaf = build_leaf_node(5, &[&[0u8; 8][..]]);
|
||||||
|
|
||||||
|
// Lay out root first, then each lower level, then the leaf.
|
||||||
|
let mut nodes: Vec<Vec<u8>> = Vec::new();
|
||||||
|
let mut addrs = Vec::new();
|
||||||
|
let mut at = 0u64;
|
||||||
|
let mut sizes = Vec::new();
|
||||||
|
for d in (1..=depth).rev() {
|
||||||
|
let n = internal_node(d, node_size, record_size, fanout - 1, fanout, 0, 0);
|
||||||
|
sizes.push(n.len());
|
||||||
|
}
|
||||||
|
for size in &sizes {
|
||||||
|
addrs.push(at);
|
||||||
|
at += *size as u64;
|
||||||
|
}
|
||||||
|
let leaf_addr = at;
|
||||||
|
for (i, d) in (1..=depth).rev().enumerate() {
|
||||||
|
let (child, child_nrec) = if d == 1 {
|
||||||
|
(leaf_addr, 1)
|
||||||
|
} else {
|
||||||
|
(addrs[i + 1], fanout as u64 - 1)
|
||||||
|
};
|
||||||
|
nodes.push(internal_node(
|
||||||
|
d,
|
||||||
|
node_size,
|
||||||
|
record_size,
|
||||||
|
fanout - 1,
|
||||||
|
fanout,
|
||||||
|
child,
|
||||||
|
child_nrec,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut data: Vec<u8> = nodes.concat();
|
||||||
|
data.extend_from_slice(&leaf);
|
||||||
|
data.resize(data.len() + 64, 0);
|
||||||
|
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let result =
|
||||||
|
collect_btree_v2_records(&data, &header(depth, 0, fanout as u16 - 1, u64::MAX), 8, 8);
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"expected a refusal, got {} records",
|
||||||
|
result.map_or(0, |r| r.len())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < std::time::Duration::from_secs(2),
|
||||||
|
"took {:?}",
|
||||||
|
started.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_header() {
|
fn parse_header() {
|
||||||
let data = build_btree_v2_header(5, 512, 11, 0, 0x1000, 3, 3, 8, 8);
|
let data = build_btree_v2_header(5, 512, 11, 0, 0x1000, 3, 3, 8, 8);
|
||||||
@@ -521,4 +683,18 @@ mod tests {
|
|||||||
let records = collect_btree_v2_records(&header, &hdr, 8, 8).unwrap();
|
let records = collect_btree_v2_records(&header, &hdr, 8, 8).unwrap();
|
||||||
assert!(records.is_empty());
|
assert!(records.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subtree_capacity_matches_libhdf5() {
|
||||||
|
// A link-name index (11-byte records, 512-byte nodes, 8-byte
|
||||||
|
// addresses): libhdf5's H5B2__hdr_init gives 45 records per leaf,
|
||||||
|
// then cum_max_nrec 1 149 at depth 1 and 26 449 at depth 2 — two
|
||||||
|
// bytes of subtree count in a depth-3 root's child pointers, where
|
||||||
|
// leaf_max^3 = 91 125 would need three.
|
||||||
|
let leaf = max_records_leaf(512, 11);
|
||||||
|
assert_eq!(leaf, 45);
|
||||||
|
assert_eq!(cum_max_records(512, 11, 8, leaf, 0), 45);
|
||||||
|
assert_eq!(cum_max_records(512, 11, 8, leaf, 1), 1_149);
|
||||||
|
assert_eq!(cum_max_records(512, 11, 8, leaf, 2), 26_449);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,13 +223,32 @@ pub const DEFAULT_CACHE_BYTES: usize = 16 * 1024 * 1024; // 16 MiB
|
|||||||
/// coordinate map and reduces collision chains compared to power-of-two sizes.
|
/// coordinate map and reduces collision chains compared to power-of-two sizes.
|
||||||
pub const DEFAULT_MAX_SLOTS: usize = 521;
|
pub const DEFAULT_MAX_SLOTS: usize = 521;
|
||||||
|
|
||||||
|
/// Most datasets whose chunk index a [`ChunkCache`] keeps at once.
|
||||||
|
pub const MAX_INDEXED_DATASETS: usize = 64;
|
||||||
|
|
||||||
|
/// Most chunk-index entries, summed over all datasets, a [`ChunkCache`] keeps.
|
||||||
|
/// Least-recently-used datasets' indexes are dropped past this (the dataset
|
||||||
|
/// being read is always kept), so a file with many or huge chunked datasets
|
||||||
|
/// cannot grow the cache without bound.
|
||||||
|
pub const MAX_INDEXED_CHUNKS: usize = 1 << 20;
|
||||||
|
|
||||||
|
/// The dataset key the address-less (legacy) methods use when
|
||||||
|
/// [`ChunkCache::ensure_dataset`] has not been called.
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
const UNBOUND_DATASET: u64 = u64::MAX;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// LRU entry
|
// LRU entry
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Decompressed chunks are keyed by dataset *and* coordinate: every chunked
|
||||||
|
/// dataset has a chunk at (0, 0, ...), so the coordinate alone is ambiguous.
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
type SlotKey = (u64, ChunkCoord);
|
||||||
|
|
||||||
#[cfg(feature = "std")]
|
#[cfg(feature = "std")]
|
||||||
struct CachedChunk {
|
struct CachedChunk {
|
||||||
coord: ChunkCoord,
|
key: SlotKey,
|
||||||
/// Shared so a cache hit is a refcount bump, not a copy of the whole
|
/// Shared so a cache hit is a refcount bump, not a copy of the whole
|
||||||
/// (potentially large) decompressed chunk.
|
/// (potentially large) decompressed chunk.
|
||||||
data: Arc<CacheAlignedBuffer>,
|
data: Arc<CacheAlignedBuffer>,
|
||||||
@@ -237,21 +256,48 @@ struct CachedChunk {
|
|||||||
last_access: u64,
|
last_access: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-dataset index state.
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DatasetEntry {
|
||||||
|
/// Chunk coordinate -> ChunkInfo (offset + size in file).
|
||||||
|
index: Option<Arc<HashMap<ChunkCoord, ChunkInfo>>>,
|
||||||
|
/// Pre-built chunk index for O(1) coordinate lookups.
|
||||||
|
chunk_index: Option<Arc<ChunkIndex>>,
|
||||||
|
/// Pre-computed chunk layout for fast assembly.
|
||||||
|
chunk_layout: Option<Arc<ChunkLayout>>,
|
||||||
|
/// Tick of the last use, for dropping the least recently used dataset.
|
||||||
|
last_used: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
impl DatasetEntry {
|
||||||
|
fn weight(&self) -> usize {
|
||||||
|
self.index.as_ref().map_or(0, |m| m.len())
|
||||||
|
+ self.chunk_index.as_ref().map_or(0, |c| c.num_chunks())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ChunkCache
|
// ChunkCache
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// A per-dataset chunk cache with hash-based index and LRU eviction.
|
/// A per-file chunk cache: chunk indexes per dataset, plus an LRU of
|
||||||
|
/// decompressed chunks, all keyed by dataset.
|
||||||
///
|
///
|
||||||
/// # Usage
|
/// A dataset is identified by the address of its chunk index (B-tree, fixed
|
||||||
|
/// or extensible array, ...), which is unique within a file. Every method
|
||||||
|
/// that takes an `addr` works on that dataset only, so threads reading
|
||||||
|
/// different datasets through one shared cache never see each other's
|
||||||
|
/// chunks. The address-less methods (`has_index`, `populate_index`,
|
||||||
|
/// `get_decompressed`, ...) act on the dataset last bound with
|
||||||
|
/// [`Self::ensure_dataset`]; that binding is shared state, so concurrent
|
||||||
|
/// readers must use the `*_in` / `*_for` methods instead (the chunked
|
||||||
|
/// readers in [`crate::chunked_read`] do).
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// Memory is bounded: decompressed data by `max_bytes`/`max_slots` across
|
||||||
/// let cache = ChunkCache::new();
|
/// all datasets, indexes by [`MAX_INDEXED_DATASETS`] and
|
||||||
/// // Pass &cache to read_chunked_data — it will populate the index lazily.
|
/// [`MAX_INDEXED_CHUNKS`].
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// The cache is wrapped in `Mutex` internally so it can be mutated through
|
|
||||||
/// shared references (thread-safe).
|
|
||||||
///
|
///
|
||||||
/// Only available with the `std` feature because it requires `std::sync::Mutex`.
|
/// Only available with the `std` feature because it requires `std::sync::Mutex`.
|
||||||
#[cfg(feature = "std")]
|
#[cfg(feature = "std")]
|
||||||
@@ -261,26 +307,20 @@ pub struct ChunkCache {
|
|||||||
|
|
||||||
#[cfg(feature = "std")]
|
#[cfg(feature = "std")]
|
||||||
struct CacheInner {
|
struct CacheInner {
|
||||||
/// Hash index: chunk coordinate -> ChunkInfo (offset + size in file).
|
/// Per-dataset chunk indexes, keyed by chunk-index address.
|
||||||
/// Populated once per dataset on first access.
|
datasets: HashMap<u64, DatasetEntry>,
|
||||||
index: Option<HashMap<ChunkCoord, ChunkInfo>>,
|
|
||||||
|
|
||||||
/// Address of the dataset (its chunk-index base address) that the cached
|
/// Dataset the address-less methods act on (see `ensure_dataset`).
|
||||||
/// index, chunk index, layout, and decompressed slots currently belong to.
|
current: Option<u64>,
|
||||||
/// The cache is shared per file across datasets, so every cached-read entry
|
|
||||||
/// checks this and resets the per-dataset state when the dataset changes —
|
|
||||||
/// otherwise one dataset's chunk index (with its own rank) would be reused
|
|
||||||
/// for another, corrupting reads.
|
|
||||||
index_addr: Option<u64>,
|
|
||||||
|
|
||||||
/// LRU cache of decompressed chunk data.
|
/// LRU cache of decompressed chunk data.
|
||||||
slots: Vec<CachedChunk>,
|
slots: Vec<CachedChunk>,
|
||||||
|
|
||||||
/// Coordinate -> index into `slots`, for O(1) lookup instead of a linear
|
/// Key -> index into `slots`, for O(1) lookup instead of a linear
|
||||||
/// scan. Kept in sync with `slots` on every insert/evict/clear — in
|
/// scan. Kept in sync with `slots` on every insert/evict/clear — in
|
||||||
/// particular, `slots.swap_remove(i)` moves the last element into slot
|
/// particular, `slots.swap_remove(i)` moves the last element into slot
|
||||||
/// `i`, so the moved element's index entry must be updated too.
|
/// `i`, so the moved element's index entry must be updated too.
|
||||||
slot_index: HashMap<ChunkCoord, usize>,
|
slot_index: HashMap<SlotKey, usize>,
|
||||||
|
|
||||||
/// Current total bytes of cached decompressed data.
|
/// Current total bytes of cached decompressed data.
|
||||||
current_bytes: usize,
|
current_bytes: usize,
|
||||||
@@ -294,17 +334,145 @@ struct CacheInner {
|
|||||||
/// Monotonic counter for LRU ordering.
|
/// Monotonic counter for LRU ordering.
|
||||||
tick: u64,
|
tick: u64,
|
||||||
|
|
||||||
/// Last accessed chunk coordinate (for sequential detection).
|
/// Last accessed chunk (for sequential detection).
|
||||||
last_coord: Option<ChunkCoord>,
|
last_coord: Option<SlotKey>,
|
||||||
|
|
||||||
/// Access pattern statistics.
|
/// Access pattern statistics.
|
||||||
stats: AccessStats,
|
stats: AccessStats,
|
||||||
|
}
|
||||||
|
|
||||||
/// Pre-built chunk index for O(1) coordinate lookups.
|
#[cfg(feature = "std")]
|
||||||
chunk_index: Option<ChunkIndex>,
|
impl CacheInner {
|
||||||
|
fn current(&self) -> u64 {
|
||||||
|
self.current.unwrap_or(UNBOUND_DATASET)
|
||||||
|
}
|
||||||
|
|
||||||
/// Pre-computed chunk layout for fast assembly.
|
fn touch(&mut self, addr: u64) -> &mut DatasetEntry {
|
||||||
chunk_layout: Option<ChunkLayout>,
|
self.tick += 1;
|
||||||
|
let tick = self.tick;
|
||||||
|
let entry = self.datasets.entry(addr).or_default();
|
||||||
|
entry.last_used = tick;
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(&self, addr: u64) -> Option<&DatasetEntry> {
|
||||||
|
self.datasets.get(&addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop least-recently-used datasets' indexes (never `keep`'s) until the
|
||||||
|
/// dataset and chunk-entry budgets hold.
|
||||||
|
fn trim_datasets(&mut self, keep: u64) {
|
||||||
|
loop {
|
||||||
|
let total: usize = self.datasets.values().map(DatasetEntry::weight).sum();
|
||||||
|
if self.datasets.len() <= MAX_INDEXED_DATASETS && total <= MAX_INDEXED_CHUNKS {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let victim = self
|
||||||
|
.datasets
|
||||||
|
.iter()
|
||||||
|
.filter(|(a, _)| **a != keep)
|
||||||
|
.min_by_key(|(_, e)| e.last_used)
|
||||||
|
.map(|(a, _)| *a);
|
||||||
|
match victim {
|
||||||
|
Some(a) => {
|
||||||
|
self.datasets.remove(&a);
|
||||||
|
}
|
||||||
|
None => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_decompressed(&mut self, addr: u64, coord: &[u64]) -> Option<Arc<CacheAlignedBuffer>> {
|
||||||
|
self.tick += 1;
|
||||||
|
let tick = self.tick;
|
||||||
|
|
||||||
|
// Track sequential vs random access
|
||||||
|
let is_sequential = self.last_coord.as_ref().is_some_and(|(prev_addr, prev)| {
|
||||||
|
// Sequential if exactly one dimension changed
|
||||||
|
let changes: usize = prev
|
||||||
|
.iter()
|
||||||
|
.zip(coord.iter())
|
||||||
|
.filter(|(a, b)| a != b)
|
||||||
|
.count();
|
||||||
|
*prev_addr == addr && changes <= 1
|
||||||
|
});
|
||||||
|
if is_sequential {
|
||||||
|
self.stats.sequential_count += 1;
|
||||||
|
} else if self.last_coord.is_some() {
|
||||||
|
self.stats.random_count += 1;
|
||||||
|
}
|
||||||
|
let key: SlotKey = (addr, coord.to_vec());
|
||||||
|
let found = if let Some(&idx) = self.slot_index.get(&key) {
|
||||||
|
self.slots[idx].last_access = tick;
|
||||||
|
Some(Arc::clone(&self.slots[idx].data))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
self.last_coord = Some(key);
|
||||||
|
if let Some(ref data) = found {
|
||||||
|
self.stats.hits += 1;
|
||||||
|
self.stats.bytes_read += data.len() as u64;
|
||||||
|
} else {
|
||||||
|
self.stats.misses += 1;
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_decompressed(
|
||||||
|
&mut self,
|
||||||
|
key: SlotKey,
|
||||||
|
data: Arc<CacheAlignedBuffer>,
|
||||||
|
) -> Arc<CacheAlignedBuffer> {
|
||||||
|
let data_len = data.len();
|
||||||
|
|
||||||
|
// Don't cache if single chunk exceeds budget — still return the data
|
||||||
|
// to the caller, just don't retain it.
|
||||||
|
if data_len > self.max_bytes {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already present
|
||||||
|
self.tick += 1;
|
||||||
|
let tick = self.tick;
|
||||||
|
if let Some(&idx) = self.slot_index.get(&key) {
|
||||||
|
self.slots[idx].last_access = tick;
|
||||||
|
return Arc::clone(&self.slots[idx].data); // already cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict until we have room
|
||||||
|
while self.slots.len() >= self.max_slots
|
||||||
|
|| (self.current_bytes + data_len > self.max_bytes && !self.slots.is_empty())
|
||||||
|
{
|
||||||
|
// Find LRU slot
|
||||||
|
let lru_idx = self
|
||||||
|
.slots
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.min_by_key(|(_, s)| s.last_access)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap();
|
||||||
|
let removed = self.slots.swap_remove(lru_idx);
|
||||||
|
self.slot_index.remove(&removed.key);
|
||||||
|
// swap_remove moved the former last element into `lru_idx` (unless
|
||||||
|
// it *was* the last element) — fix up that element's index entry.
|
||||||
|
if lru_idx < self.slots.len() {
|
||||||
|
let moved_key = self.slots[lru_idx].key.clone();
|
||||||
|
self.slot_index.insert(moved_key, lru_idx);
|
||||||
|
}
|
||||||
|
self.current_bytes -= removed.data.len();
|
||||||
|
self.stats.evictions += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current_bytes += data_len;
|
||||||
|
let new_idx = self.slots.len();
|
||||||
|
self.slot_index.insert(key.clone(), new_idx);
|
||||||
|
self.slots.push(CachedChunk {
|
||||||
|
key,
|
||||||
|
data: Arc::clone(&data),
|
||||||
|
last_access: tick,
|
||||||
|
});
|
||||||
|
data
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Access pattern statistics tracked by the chunk cache.
|
/// Access pattern statistics tracked by the chunk cache.
|
||||||
@@ -356,8 +524,8 @@ impl ChunkCache {
|
|||||||
pub fn with_capacity(max_bytes: usize, max_slots: usize) -> Self {
|
pub fn with_capacity(max_bytes: usize, max_slots: usize) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: std::sync::Mutex::new(CacheInner {
|
inner: std::sync::Mutex::new(CacheInner {
|
||||||
index: None,
|
datasets: HashMap::new(),
|
||||||
index_addr: None,
|
current: None,
|
||||||
slots: Vec::with_capacity(max_slots.min(64)),
|
slots: Vec::with_capacity(max_slots.min(64)),
|
||||||
slot_index: HashMap::with_capacity(max_slots.min(64)),
|
slot_index: HashMap::with_capacity(max_slots.min(64)),
|
||||||
current_bytes: 0,
|
current_bytes: 0,
|
||||||
@@ -366,335 +534,331 @@ impl ChunkCache {
|
|||||||
tick: 0,
|
tick: 0,
|
||||||
last_coord: None,
|
last_coord: None,
|
||||||
stats: AccessStats::default(),
|
stats: AccessStats::default(),
|
||||||
chunk_index: None,
|
|
||||||
chunk_layout: None,
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Index operations -----
|
fn lock(&self) -> std::sync::MutexGuard<'_, CacheInner> {
|
||||||
|
self.inner.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
/// Bind the cache to the dataset at chunk-index address `addr`.
|
/// The most decompressed bytes this cache will hold.
|
||||||
|
pub fn max_bytes(&self) -> usize {
|
||||||
|
self.lock().max_bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Dataset-keyed operations (safe to use concurrently) -----
|
||||||
|
|
||||||
|
/// The chunk list of the dataset whose chunk index is at `addr`.
|
||||||
///
|
///
|
||||||
/// The cache is shared per file across all of its datasets. If the cache
|
/// On the first call for a dataset, `build` scans its chunk index; the
|
||||||
/// currently holds state for a different dataset, all per-dataset state
|
/// result is kept (offsets truncated to `rank` for the lookup key), so
|
||||||
/// (chunk index, chunk-index map, layout, and decompressed slots) is
|
/// later calls skip the scan. `build` runs without the cache lock held;
|
||||||
/// dropped so the next access rebuilds it for this dataset. Reading the
|
/// if two threads race to build the same dataset's index, the first
|
||||||
/// same dataset again is a no-op, preserving the cache's benefit for
|
/// stored one wins and both return equivalent lists.
|
||||||
/// repeated/sequential access. Returns `true` if a reset occurred.
|
pub fn chunks_for<E>(
|
||||||
pub fn ensure_dataset(&self, addr: u64) -> bool {
|
&self,
|
||||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
addr: u64,
|
||||||
if inner.index_addr == Some(addr) {
|
rank: usize,
|
||||||
return false;
|
build: impl FnOnce() -> Result<Vec<ChunkInfo>, E>,
|
||||||
|
) -> Result<Vec<ChunkInfo>, E> {
|
||||||
|
Ok(self
|
||||||
|
.index_for(addr, rank, build)?
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_for<E>(
|
||||||
|
&self,
|
||||||
|
addr: u64,
|
||||||
|
rank: usize,
|
||||||
|
build: impl FnOnce() -> Result<Vec<ChunkInfo>, E>,
|
||||||
|
) -> Result<Arc<HashMap<ChunkCoord, ChunkInfo>>, E> {
|
||||||
|
if let Some(index) = self.lock().touch(addr).index.clone() {
|
||||||
|
return Ok(index);
|
||||||
}
|
}
|
||||||
inner.index = None;
|
let chunks = build()?;
|
||||||
inner.chunk_index = None;
|
let map: HashMap<ChunkCoord, ChunkInfo> = chunks
|
||||||
inner.chunk_layout = None;
|
.into_iter()
|
||||||
inner.slots.clear();
|
.map(|ci| (ci.offsets.iter().take(rank).copied().collect(), ci))
|
||||||
inner.slot_index.clear();
|
.collect();
|
||||||
inner.current_bytes = 0;
|
let mut inner = self.lock();
|
||||||
inner.last_coord = None;
|
let entry = inner.touch(addr);
|
||||||
inner.index_addr = Some(addr);
|
let index = Arc::clone(entry.index.get_or_insert_with(|| Arc::new(map)));
|
||||||
true
|
inner.trim_datasets(addr);
|
||||||
|
Ok(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if the chunk index has been built.
|
/// The pre-computed assembly layout of the dataset at `addr`, building
|
||||||
|
/// its chunk index (via `build`, as in [`Self::chunks_for`]) and layout on
|
||||||
|
/// first use.
|
||||||
|
pub fn chunk_layout_for<E>(
|
||||||
|
&self,
|
||||||
|
addr: u64,
|
||||||
|
rank: usize,
|
||||||
|
build: impl FnOnce() -> Result<Vec<ChunkInfo>, E>,
|
||||||
|
ds_dims: &[usize],
|
||||||
|
chunk_dims: &[usize],
|
||||||
|
elem_size: usize,
|
||||||
|
) -> Result<Arc<ChunkLayout>, E> {
|
||||||
|
let (layout, chunk_index) = {
|
||||||
|
let mut inner = self.lock();
|
||||||
|
let entry = inner.touch(addr);
|
||||||
|
(entry.chunk_layout.clone(), entry.chunk_index.clone())
|
||||||
|
};
|
||||||
|
if let Some(layout) = layout {
|
||||||
|
return Ok(layout);
|
||||||
|
}
|
||||||
|
let chunk_index = match chunk_index {
|
||||||
|
Some(ci) => ci,
|
||||||
|
None => {
|
||||||
|
let index = self.index_for(addr, rank, build)?;
|
||||||
|
let chunks: Vec<ChunkInfo> = index.values().cloned().collect();
|
||||||
|
Arc::new(ChunkIndex::build(&chunks, rank))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let layout = ChunkLayout::build(&chunk_index, ds_dims, chunk_dims, elem_size);
|
||||||
|
let mut inner = self.lock();
|
||||||
|
let entry = inner.touch(addr);
|
||||||
|
entry.chunk_index.get_or_insert(chunk_index);
|
||||||
|
let layout = Arc::clone(entry.chunk_layout.get_or_insert_with(|| Arc::new(layout)));
|
||||||
|
inner.trim_datasets(addr);
|
||||||
|
Ok(layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached decompressed chunk at `coord` of the dataset at `addr`.
|
||||||
|
///
|
||||||
|
/// O(1) lookup; the clone is an `Arc` refcount bump, not a copy of the
|
||||||
|
/// underlying decompressed data.
|
||||||
|
pub fn get_decompressed_in(&self, addr: u64, coord: &[u64]) -> Option<Arc<CacheAlignedBuffer>> {
|
||||||
|
self.lock().get_decompressed(addr, coord)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache decompressed chunk data for `coord` of the dataset at `addr`.
|
||||||
|
/// Returns the `Arc`-shared buffer now cached (or already cached).
|
||||||
|
pub fn put_decompressed_in(
|
||||||
|
&self,
|
||||||
|
addr: u64,
|
||||||
|
coord: ChunkCoord,
|
||||||
|
data: Vec<u8>,
|
||||||
|
) -> Arc<CacheAlignedBuffer> {
|
||||||
|
self.put_decompressed_aligned_in(addr, coord, CacheAlignedBuffer::from_vec(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::put_decompressed_in`] for an already-aligned buffer.
|
||||||
|
pub fn put_decompressed_aligned_in(
|
||||||
|
&self,
|
||||||
|
addr: u64,
|
||||||
|
coord: ChunkCoord,
|
||||||
|
data: CacheAlignedBuffer,
|
||||||
|
) -> Arc<CacheAlignedBuffer> {
|
||||||
|
let data = Arc::new(data);
|
||||||
|
self.lock().put_decompressed((addr, coord), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that the given chunk coordinates of the dataset at `addr` are
|
||||||
|
/// predicted to be accessed soon (bookkeeping only).
|
||||||
|
///
|
||||||
|
/// This does **not** prefetch or pre-decompress anything — it only
|
||||||
|
/// checks whether each coordinate is already in the chunk index and
|
||||||
|
/// updates access-pattern stats accordingly.
|
||||||
|
pub fn prefetch_hint_in(&self, addr: u64, next_coords: &[ChunkCoord]) {
|
||||||
|
let mut inner = self.lock();
|
||||||
|
let Some(index) = inner.entry(addr).and_then(|e| e.index.clone()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let known = next_coords
|
||||||
|
.iter()
|
||||||
|
.filter(|c| index.contains_key(*c))
|
||||||
|
.count();
|
||||||
|
inner.stats.sequential_count += known as u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Address-less operations on the bound dataset -----
|
||||||
|
|
||||||
|
/// Bind the address-less methods to the dataset at chunk-index address
|
||||||
|
/// `addr`. Returns `true` if this changed the bound dataset.
|
||||||
|
///
|
||||||
|
/// Each dataset's state is kept separately, so switching loses nothing
|
||||||
|
/// and never exposes one dataset's index or chunks to another. The
|
||||||
|
/// binding itself is shared, though: concurrent readers should use the
|
||||||
|
/// `addr`-taking methods rather than bind and then call these.
|
||||||
|
pub fn ensure_dataset(&self, addr: u64) -> bool {
|
||||||
|
let mut inner = self.lock();
|
||||||
|
let changed = inner.current != Some(addr);
|
||||||
|
inner.current = Some(addr);
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the bound dataset's chunk index has been built.
|
||||||
pub fn has_index(&self) -> bool {
|
pub fn has_index(&self) -> bool {
|
||||||
self.inner
|
let inner = self.lock();
|
||||||
.lock()
|
inner
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
.entry(inner.current())
|
||||||
.index
|
.is_some_and(|e| e.index.is_some())
|
||||||
.is_some()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the chunk index from a pre-collected list of `ChunkInfo`.
|
/// Build the bound dataset's chunk index from a pre-collected list of
|
||||||
|
/// `ChunkInfo`.
|
||||||
///
|
///
|
||||||
/// The `rank` parameter is used to truncate offsets to spatial dims only
|
/// The `rank` parameter is used to truncate offsets to spatial dims only
|
||||||
/// (B-tree v1 stores rank+1 offsets).
|
/// (B-tree v1 stores rank+1 offsets).
|
||||||
pub fn populate_index(&self, chunks: &[ChunkInfo], rank: usize) {
|
pub fn populate_index(&self, chunks: &[ChunkInfo], rank: usize) {
|
||||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let addr = self.lock().current();
|
||||||
if inner.index.is_some() {
|
let _ = self.index_for::<core::convert::Infallible>(addr, rank, || Ok(chunks.to_vec()));
|
||||||
return; // already populated
|
|
||||||
}
|
|
||||||
let mut map = HashMap::with_capacity(chunks.len());
|
|
||||||
|
|
||||||
for ci in chunks {
|
|
||||||
let coord: ChunkCoord = ci.offsets.iter().take(rank).copied().collect();
|
|
||||||
map.insert(coord, ci.clone());
|
|
||||||
}
|
|
||||||
inner.index = Some(map);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look up a chunk by its spatial coordinate in the index.
|
/// Look up a chunk by its spatial coordinate in the bound dataset's index.
|
||||||
pub fn lookup_index(&self, coord: &[u64]) -> Option<ChunkInfo> {
|
pub fn lookup_index(&self, coord: &[u64]) -> Option<ChunkInfo> {
|
||||||
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let inner = self.lock();
|
||||||
inner.index.as_ref()?.get(coord).cloned()
|
inner
|
||||||
|
.entry(inner.current())?
|
||||||
|
.index
|
||||||
|
.as_ref()?
|
||||||
|
.get(coord)
|
||||||
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return all indexed chunks as a `Vec<ChunkInfo>` (order unspecified).
|
/// Return all of the bound dataset's indexed chunks (order unspecified).
|
||||||
pub fn all_indexed_chunks(&self) -> Option<Vec<ChunkInfo>> {
|
pub fn all_indexed_chunks(&self) -> Option<Vec<ChunkInfo>> {
|
||||||
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let inner = self.lock();
|
||||||
inner.index.as_ref().map(|m| m.values().cloned().collect())
|
let index = inner.entry(inner.current())?.index.as_ref()?;
|
||||||
|
Some(index.values().cloned().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Chunk index (pre-built coordinate → ChunkInfo map) -----
|
/// Returns `true` if the bound dataset's `ChunkIndex` has been built.
|
||||||
|
|
||||||
/// Returns `true` if the chunk B-tree index has been built.
|
|
||||||
pub fn has_chunk_index(&self) -> bool {
|
pub fn has_chunk_index(&self) -> bool {
|
||||||
self.inner
|
let inner = self.lock();
|
||||||
.lock()
|
inner
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
.entry(inner.current())
|
||||||
.chunk_index
|
.is_some_and(|e| e.chunk_index.is_some())
|
||||||
.is_some()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build and store the chunk B-tree index from a pre-collected list of `ChunkInfo`.
|
/// Build and store the bound dataset's `ChunkIndex`.
|
||||||
pub fn populate_chunk_index(&self, chunks: &[ChunkInfo], rank: usize) {
|
pub fn populate_chunk_index(&self, chunks: &[ChunkInfo], rank: usize) {
|
||||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let built = Arc::new(ChunkIndex::build(chunks, rank));
|
||||||
if inner.chunk_index.is_some() {
|
let mut inner = self.lock();
|
||||||
return;
|
let addr = inner.current();
|
||||||
}
|
inner.touch(addr).chunk_index.get_or_insert(built);
|
||||||
inner.chunk_index = Some(ChunkIndex::build(chunks, rank));
|
inner.trim_datasets(addr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Chunk layout (pre-computed assembly plan) -----
|
/// Returns `true` if the bound dataset's chunk layout has been computed.
|
||||||
|
|
||||||
/// Returns `true` if the chunk layout has been computed.
|
|
||||||
pub fn has_chunk_layout(&self) -> bool {
|
pub fn has_chunk_layout(&self) -> bool {
|
||||||
self.inner
|
let inner = self.lock();
|
||||||
.lock()
|
inner
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
.entry(inner.current())
|
||||||
.chunk_layout
|
.is_some_and(|e| e.chunk_layout.is_some())
|
||||||
.is_some()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build and store the pre-computed chunk layout for fast assembly.
|
/// Build and store the bound dataset's chunk layout (needs its
|
||||||
|
/// `ChunkIndex`; does nothing without one).
|
||||||
pub fn populate_chunk_layout(&self, ds_dims: &[usize], chunk_dims: &[usize], elem_size: usize) {
|
pub fn populate_chunk_layout(&self, ds_dims: &[usize], chunk_dims: &[usize], elem_size: usize) {
|
||||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let mut inner = self.lock();
|
||||||
if inner.chunk_layout.is_some() {
|
let addr = inner.current();
|
||||||
|
let entry = inner.touch(addr);
|
||||||
|
if entry.chunk_layout.is_some() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(ref idx) = inner.chunk_index {
|
if let Some(idx) = entry.chunk_index.clone() {
|
||||||
inner.chunk_layout = Some(ChunkLayout::build(idx, ds_dims, chunk_dims, elem_size));
|
entry.chunk_layout = Some(Arc::new(ChunkLayout::build(
|
||||||
|
&idx, ds_dims, chunk_dims, elem_size,
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a function with a reference to the chunk layout.
|
/// Execute a function with a reference to the bound dataset's chunk
|
||||||
///
|
/// layout. Returns `None` if the layout hasn't been computed yet.
|
||||||
/// Returns `None` if the layout hasn't been computed yet.
|
|
||||||
pub fn with_chunk_layout<F, R>(&self, f: F) -> Option<R>
|
pub fn with_chunk_layout<F, R>(&self, f: F) -> Option<R>
|
||||||
where
|
where
|
||||||
F: FnOnce(&ChunkLayout) -> R,
|
F: FnOnce(&ChunkLayout) -> R,
|
||||||
{
|
{
|
||||||
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let layout = {
|
||||||
inner.chunk_layout.as_ref().map(f)
|
let inner = self.lock();
|
||||||
|
inner.entry(inner.current())?.chunk_layout.clone()?
|
||||||
|
};
|
||||||
|
Some(f(&layout))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Decompressed data cache (LRU) -----
|
/// Try to get cached decompressed data for a chunk of the bound dataset.
|
||||||
|
|
||||||
/// Try to get cached decompressed data for a chunk coordinate.
|
|
||||||
///
|
///
|
||||||
/// O(1) lookup. Returns an owned copy for API compatibility with callers
|
/// Returns an owned copy; prefer [`Self::get_decompressed_aligned`] when
|
||||||
/// that need a `Vec<u8>`; prefer [`Self::get_decompressed_aligned`] when
|
/// an `Arc`-shared buffer works for the caller.
|
||||||
/// an `Arc`-shared buffer works for the caller, since that avoids the
|
|
||||||
/// copy entirely.
|
|
||||||
pub fn get_decompressed(&self, coord: &[u64]) -> Option<Vec<u8>> {
|
pub fn get_decompressed(&self, coord: &[u64]) -> Option<Vec<u8>> {
|
||||||
self.get_decompressed_aligned(coord)
|
self.get_decompressed_aligned(coord)
|
||||||
.map(|arc| arc.as_slice().to_vec())
|
.map(|arc| arc.as_slice().to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to get a reference-counted clone of the aligned buffer for a chunk.
|
/// Reference-counted cached buffer for a chunk of the bound dataset.
|
||||||
///
|
|
||||||
/// O(1) index lookup; the clone is an `Arc` refcount bump, not a copy of
|
|
||||||
/// the underlying decompressed data.
|
|
||||||
pub fn get_decompressed_aligned(&self, coord: &[u64]) -> Option<Arc<CacheAlignedBuffer>> {
|
pub fn get_decompressed_aligned(&self, coord: &[u64]) -> Option<Arc<CacheAlignedBuffer>> {
|
||||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let mut inner = self.lock();
|
||||||
inner.tick += 1;
|
let addr = inner.current();
|
||||||
let tick = inner.tick;
|
inner.get_decompressed(addr, coord)
|
||||||
|
|
||||||
// Track sequential vs random access
|
|
||||||
let is_sequential = inner.last_coord.as_ref().is_some_and(|prev| {
|
|
||||||
// Sequential if exactly one dimension changed
|
|
||||||
let changes: usize = prev
|
|
||||||
.iter()
|
|
||||||
.zip(coord.iter())
|
|
||||||
.filter(|(a, b)| a != b)
|
|
||||||
.count();
|
|
||||||
changes <= 1
|
|
||||||
});
|
|
||||||
if is_sequential {
|
|
||||||
inner.stats.sequential_count += 1;
|
|
||||||
} else if inner.last_coord.is_some() {
|
|
||||||
inner.stats.random_count += 1;
|
|
||||||
}
|
|
||||||
inner.last_coord = Some(coord.to_vec());
|
|
||||||
|
|
||||||
let found = if let Some(&idx) = inner.slot_index.get(coord) {
|
|
||||||
inner.slots[idx].last_access = tick;
|
|
||||||
Some(Arc::clone(&inner.slots[idx].data))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
if let Some(ref data) = found {
|
|
||||||
inner.stats.hits += 1;
|
|
||||||
inner.stats.bytes_read += data.len() as u64;
|
|
||||||
} else {
|
|
||||||
inner.stats.misses += 1;
|
|
||||||
}
|
|
||||||
found
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert decompressed chunk data into the LRU cache.
|
/// Insert decompressed chunk data for the bound dataset into the LRU
|
||||||
///
|
/// cache, returning the `Arc`-shared buffer now cached.
|
||||||
/// The data is stored in a [`CacheAlignedBuffer`] so subsequent reads
|
|
||||||
/// return cache-line-aligned memory. Returns the `Arc`-shared buffer that
|
|
||||||
/// is now cached (or already was), so the caller can reuse it directly
|
|
||||||
/// instead of holding a separate copy of the same data.
|
|
||||||
pub fn put_decompressed(&self, coord: ChunkCoord, data: Vec<u8>) -> Arc<CacheAlignedBuffer> {
|
pub fn put_decompressed(&self, coord: ChunkCoord, data: Vec<u8>) -> Arc<CacheAlignedBuffer> {
|
||||||
let aligned = CacheAlignedBuffer::from_vec(data);
|
self.put_decompressed_aligned(coord, CacheAlignedBuffer::from_vec(data))
|
||||||
self.put_decompressed_aligned(coord, aligned)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert an already-aligned buffer into the LRU cache.
|
/// Insert an already-aligned buffer for the bound dataset.
|
||||||
///
|
|
||||||
/// Returns the `Arc`-shared buffer now held by the cache (the one just
|
|
||||||
/// inserted, or the existing cached copy if `coord` was already present).
|
|
||||||
pub fn put_decompressed_aligned(
|
pub fn put_decompressed_aligned(
|
||||||
&self,
|
&self,
|
||||||
coord: ChunkCoord,
|
coord: ChunkCoord,
|
||||||
data: CacheAlignedBuffer,
|
data: CacheAlignedBuffer,
|
||||||
) -> Arc<CacheAlignedBuffer> {
|
) -> Arc<CacheAlignedBuffer> {
|
||||||
let data = Arc::new(data);
|
let data = Arc::new(data);
|
||||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let mut inner = self.lock();
|
||||||
let data_len = data.len();
|
let addr = inner.current();
|
||||||
|
inner.put_decompressed((addr, coord), data)
|
||||||
// Don't cache if single chunk exceeds budget — still return the data
|
|
||||||
// to the caller, just don't retain it.
|
|
||||||
if data_len > inner.max_bytes {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already present
|
|
||||||
inner.tick += 1;
|
|
||||||
let tick = inner.tick;
|
|
||||||
if let Some(&idx) = inner.slot_index.get(&coord) {
|
|
||||||
inner.slots[idx].last_access = tick;
|
|
||||||
return Arc::clone(&inner.slots[idx].data); // already cached
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evict until we have room
|
|
||||||
while inner.slots.len() >= inner.max_slots
|
|
||||||
|| (inner.current_bytes + data_len > inner.max_bytes && !inner.slots.is_empty())
|
|
||||||
{
|
|
||||||
// Find LRU slot
|
|
||||||
let lru_idx = inner
|
|
||||||
.slots
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.min_by_key(|(_, s)| s.last_access)
|
|
||||||
.map(|(i, _)| i)
|
|
||||||
.unwrap();
|
|
||||||
let removed = inner.slots.swap_remove(lru_idx);
|
|
||||||
inner.slot_index.remove(&removed.coord);
|
|
||||||
// swap_remove moved the former last element into `lru_idx` (unless
|
|
||||||
// it *was* the last element) — fix up that element's index entry.
|
|
||||||
if lru_idx < inner.slots.len() {
|
|
||||||
let moved_coord = inner.slots[lru_idx].coord.clone();
|
|
||||||
inner.slot_index.insert(moved_coord, lru_idx);
|
|
||||||
}
|
|
||||||
inner.current_bytes -= removed.data.len();
|
|
||||||
inner.stats.evictions += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
inner.current_bytes += data_len;
|
|
||||||
let new_idx = inner.slots.len();
|
|
||||||
inner.slot_index.insert(coord.clone(), new_idx);
|
|
||||||
inner.slots.push(CachedChunk {
|
|
||||||
coord,
|
|
||||||
data: Arc::clone(&data),
|
|
||||||
last_access: tick,
|
|
||||||
});
|
|
||||||
data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the entire cache (index + decompressed data).
|
/// [`Self::prefetch_hint_in`] for the bound dataset.
|
||||||
|
pub fn prefetch_hint(&self, next_coords: &[ChunkCoord]) {
|
||||||
|
let addr = self.lock().current();
|
||||||
|
self.prefetch_hint_in(addr, next_coords);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Whole-cache operations -----
|
||||||
|
|
||||||
|
/// Clear the entire cache (indexes + decompressed data + stats).
|
||||||
pub fn clear(&self) {
|
pub fn clear(&self) {
|
||||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let mut inner = self.lock();
|
||||||
inner.index = None;
|
inner.datasets.clear();
|
||||||
inner.index_addr = None;
|
inner.current = None;
|
||||||
inner.slots.clear();
|
inner.slots.clear();
|
||||||
inner.slot_index.clear();
|
inner.slot_index.clear();
|
||||||
inner.current_bytes = 0;
|
inner.current_bytes = 0;
|
||||||
inner.tick = 0;
|
inner.tick = 0;
|
||||||
inner.last_coord = None;
|
inner.last_coord = None;
|
||||||
inner.stats = AccessStats::default();
|
inner.stats = AccessStats::default();
|
||||||
inner.chunk_index = None;
|
|
||||||
inner.chunk_layout = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record that the given chunk coordinates are predicted to be accessed
|
|
||||||
/// soon (bookkeeping only).
|
|
||||||
///
|
|
||||||
/// This does **not** prefetch or pre-decompress anything — it only
|
|
||||||
/// checks whether each coordinate is already in the chunk index and
|
|
||||||
/// updates access-pattern stats accordingly. Real prefetching (e.g.
|
|
||||||
/// background pre-decompression) is not implemented.
|
|
||||||
pub fn prefetch_hint(&self, next_coords: &[ChunkCoord]) {
|
|
||||||
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
if inner.index.is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
drop(inner);
|
|
||||||
// For each predicted coordinate, verify it exists in the index.
|
|
||||||
// The index is already populated, so this is a no-op for known chunks.
|
|
||||||
// The purpose is to signal intent — callers can pre-decompress if needed.
|
|
||||||
// We touch the stats to record that prefetch hints were issued.
|
|
||||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
for coord in next_coords {
|
|
||||||
let exists = inner
|
|
||||||
.index
|
|
||||||
.as_ref()
|
|
||||||
.map(|idx| idx.contains_key(coord))
|
|
||||||
.unwrap_or(false);
|
|
||||||
if exists {
|
|
||||||
inner.stats.sequential_count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the current access pattern statistics.
|
/// Return the current access pattern statistics.
|
||||||
pub fn access_stats(&self) -> AccessStats {
|
pub fn access_stats(&self) -> AccessStats {
|
||||||
self.inner
|
self.lock().stats.clone()
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
|
||||||
.stats
|
|
||||||
.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the sweep direction label in the access stats.
|
/// Update the sweep direction label in the access stats.
|
||||||
pub fn set_sweep_direction(&self, direction: &'static str) {
|
pub fn set_sweep_direction(&self, direction: &'static str) {
|
||||||
self.inner
|
self.lock().stats.sweep_direction = Some(direction);
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
|
||||||
.stats
|
|
||||||
.sweep_direction = Some(direction);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of decompressed chunks currently cached.
|
/// Number of decompressed chunks currently cached (all datasets).
|
||||||
pub fn cached_chunk_count(&self) -> usize {
|
pub fn cached_chunk_count(&self) -> usize {
|
||||||
self.inner
|
self.lock().slots.len()
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
|
||||||
.slots
|
|
||||||
.len()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total bytes of decompressed data currently cached.
|
/// Total bytes of decompressed data currently cached (all datasets).
|
||||||
pub fn cached_bytes(&self) -> usize {
|
pub fn cached_bytes(&self) -> usize {
|
||||||
self.inner
|
self.lock().current_bytes
|
||||||
.lock()
|
}
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
|
||||||
.current_bytes
|
/// Number of datasets whose chunk index is currently kept.
|
||||||
|
pub fn indexed_dataset_count(&self) -> usize {
|
||||||
|
self.lock().datasets.len()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,6 +967,92 @@ mod tests {
|
|||||||
assert_eq!(cache.cached_bytes(), 0);
|
assert_eq!(cache.cached_bytes(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn datasets_sharing_coordinates_stay_separate() {
|
||||||
|
let cache = ChunkCache::new();
|
||||||
|
let a = vec![make_chunk(vec![0, 0], 0x100, 8)];
|
||||||
|
let b = vec![make_chunk(vec![0, 0], 0x900, 8)];
|
||||||
|
let got_a = cache.chunks_for::<()>(1, 1, || Ok(a.clone())).unwrap();
|
||||||
|
let got_b = cache.chunks_for::<()>(2, 1, || Ok(b.clone())).unwrap();
|
||||||
|
assert_eq!(got_a[0].address, 0x100);
|
||||||
|
assert_eq!(got_b[0].address, 0x900);
|
||||||
|
// Built once per dataset: a second lookup doesn't call the builder.
|
||||||
|
let again = cache
|
||||||
|
.chunks_for::<()>(1, 1, || panic!("index rebuilt"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(again[0].address, 0x100);
|
||||||
|
|
||||||
|
cache.put_decompressed_in(1, vec![0], vec![1; 4]);
|
||||||
|
cache.put_decompressed_in(2, vec![0], vec![2; 4]);
|
||||||
|
assert_eq!(
|
||||||
|
cache.get_decompressed_in(1, &[0]).unwrap().as_slice(),
|
||||||
|
&[1; 4]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cache.get_decompressed_in(2, &[0]).unwrap().as_slice(),
|
||||||
|
&[2; 4]
|
||||||
|
);
|
||||||
|
assert!(cache.get_decompressed_in(3, &[0]).is_none());
|
||||||
|
assert_eq!(cache.cached_chunk_count(), 2);
|
||||||
|
|
||||||
|
// The bound-dataset methods see only the bound dataset.
|
||||||
|
cache.ensure_dataset(2);
|
||||||
|
assert_eq!(cache.lookup_index(&[0]).unwrap().address, 0x900);
|
||||||
|
assert_eq!(cache.get_decompressed(&[0]).unwrap(), vec![2; 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dataset_indexes_are_bounded() {
|
||||||
|
let cache = ChunkCache::new();
|
||||||
|
for addr in 0..(MAX_INDEXED_DATASETS as u64 + 10) {
|
||||||
|
cache
|
||||||
|
.chunks_for::<()>(addr, 1, || Ok(vec![make_chunk(vec![0], addr, 8)]))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(cache.indexed_dataset_count(), MAX_INDEXED_DATASETS);
|
||||||
|
|
||||||
|
// One huge index evicts the others but is itself kept.
|
||||||
|
let huge: Vec<ChunkInfo> = (0..MAX_INDEXED_CHUNKS as u64)
|
||||||
|
.map(|i| make_chunk(vec![i], i, 8))
|
||||||
|
.collect();
|
||||||
|
let got = cache.chunks_for::<()>(9999, 1, || Ok(huge)).unwrap();
|
||||||
|
assert_eq!(got.len(), MAX_INDEXED_CHUNKS);
|
||||||
|
assert_eq!(cache.indexed_dataset_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_readers_of_different_datasets_see_their_own_chunks() {
|
||||||
|
let cache = std::sync::Arc::new(ChunkCache::with_capacity(1 << 20, 64));
|
||||||
|
let handles: Vec<_> = (0..8u64)
|
||||||
|
.map(|t| {
|
||||||
|
let cache = std::sync::Arc::clone(&cache);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for round in 0..500u64 {
|
||||||
|
let addr = (t + round) % 16;
|
||||||
|
let coord = vec![round % 4];
|
||||||
|
let chunks = cache
|
||||||
|
.chunks_for::<()>(addr, 1, || {
|
||||||
|
Ok((0..4).map(|c| make_chunk(vec![c], addr, 8)).collect())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(chunks.iter().all(|c| c.address == addr));
|
||||||
|
let want = vec![addr as u8; 8];
|
||||||
|
let got = match cache.get_decompressed_in(addr, &coord) {
|
||||||
|
Some(hit) => hit.to_vec(),
|
||||||
|
None => cache
|
||||||
|
.put_decompressed_in(addr, coord, want.clone())
|
||||||
|
.to_vec(),
|
||||||
|
};
|
||||||
|
assert_eq!(got, want);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for h in handles {
|
||||||
|
h.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn duplicate_insert_is_noop() {
|
fn duplicate_insert_is_noop() {
|
||||||
let cache = ChunkCache::new();
|
let cache = ChunkCache::new();
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
//! Chunk-index linearisation shared by the Fixed Array and Extensible Array
|
||||||
|
//! chunk indexes (reader and writer).
|
||||||
|
//!
|
||||||
|
//! Both indexes store one element per chunk at a *linear* index, and the
|
||||||
|
//! library derives that index from the chunk's scaled coordinates
|
||||||
|
//! (`offset / chunk_dim`) using the dataset's **maximum** dimensions, not its
|
||||||
|
//! current ones (`H5D__farray_idx_get_addr` / `H5D__earray_idx_get_addr`,
|
||||||
|
//! via `layout->max_down_chunks`). A dataset whose current shape is smaller
|
||||||
|
//! than its maxshape therefore has gaps in the index, and laying it out by the
|
||||||
|
//! current shape puts every chunk after the first row in the wrong place.
|
||||||
|
//!
|
||||||
|
//! The Extensible Array adds one more step: its one unlimited dimension has no
|
||||||
|
//! finite chunk count, so the library *swizzles* the coordinates to make that
|
||||||
|
//! dimension the slowest-varying one (`H5VM_swizzle_coords`, which moves
|
||||||
|
//! `coords[unlim_dim]` to the front and shifts the dimensions before it right
|
||||||
|
//! by one) before linearising with `swizzled_max_down_chunks`. When the
|
||||||
|
//! unlimited dimension is already dimension 0 no swizzle happens.
|
||||||
|
|
||||||
|
#[cfg(not(feature = "std"))]
|
||||||
|
extern crate alloc;
|
||||||
|
|
||||||
|
#[cfg(not(feature = "std"))]
|
||||||
|
use alloc::{vec, vec::Vec};
|
||||||
|
|
||||||
|
use crate::error::FormatError;
|
||||||
|
|
||||||
|
/// How a chunk index maps linear element indexes to chunk coordinates.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct ChunkGrid {
|
||||||
|
/// Spatial chunk dimensions, in dataset order.
|
||||||
|
chunk_dims: Vec<u64>,
|
||||||
|
/// Chunks per dimension covering the *current* extent, in dataset order.
|
||||||
|
cur_chunks: Vec<u64>,
|
||||||
|
/// Dataset dimension stored at each linearisation position (slowest
|
||||||
|
/// first). The identity except for a swizzled Extensible Array.
|
||||||
|
order: Vec<usize>,
|
||||||
|
/// Linear stride of each linearisation position.
|
||||||
|
down: Vec<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChunkGrid {
|
||||||
|
/// Grid for a Fixed Array index: row-major over the chunk counts of the
|
||||||
|
/// maximum dimensions (`max_dims`, falling back to the current dimensions
|
||||||
|
/// when the dataspace records none).
|
||||||
|
pub(crate) fn fixed_array(
|
||||||
|
cur_dims: &[u64],
|
||||||
|
max_dims: Option<&[u64]>,
|
||||||
|
chunk_dims: &[u64],
|
||||||
|
) -> Result<Self, FormatError> {
|
||||||
|
Self::build(cur_dims, max_dims, chunk_dims, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grid for an Extensible Array index: like the Fixed Array, but the
|
||||||
|
/// unlimited dimension (the one whose maximum is `H5S_UNLIMITED`) is moved
|
||||||
|
/// to the slowest-varying position first.
|
||||||
|
pub(crate) fn extensible_array(
|
||||||
|
cur_dims: &[u64],
|
||||||
|
max_dims: Option<&[u64]>,
|
||||||
|
chunk_dims: &[u64],
|
||||||
|
) -> Result<Self, FormatError> {
|
||||||
|
let unlim = max_dims.and_then(|m| m.iter().position(|&d| d == u64::MAX));
|
||||||
|
Self::build(cur_dims, max_dims, chunk_dims, unlim)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(
|
||||||
|
cur_dims: &[u64],
|
||||||
|
max_dims: Option<&[u64]>,
|
||||||
|
chunk_dims: &[u64],
|
||||||
|
unlim: Option<usize>,
|
||||||
|
) -> Result<Self, FormatError> {
|
||||||
|
let rank = chunk_dims.len();
|
||||||
|
if cur_dims.len() != rank || max_dims.is_some_and(|m| m.len() != rank) {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"chunk index rank does not match the dataspace".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if chunk_dims.contains(&0) {
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"chunk dimension is zero".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let cur_chunks: Vec<u64> = cur_dims
|
||||||
|
.iter()
|
||||||
|
.zip(chunk_dims)
|
||||||
|
.map(|(&d, &c)| d.div_ceil(c))
|
||||||
|
.collect();
|
||||||
|
// Chunk counts of the maximum extent. An unlimited dimension has no
|
||||||
|
// finite count; it only ever sits in the slowest position, where its
|
||||||
|
// count never enters a stride. A (corrupt) maximum smaller than the
|
||||||
|
// current extent is widened so no allocated chunk becomes unreachable.
|
||||||
|
let max_chunks: Vec<u64> = (0..rank)
|
||||||
|
.map(|d| {
|
||||||
|
let max = max_dims.map_or(cur_dims[d], |m| m[d]);
|
||||||
|
if max == u64::MAX {
|
||||||
|
u64::MAX
|
||||||
|
} else {
|
||||||
|
max.div_ceil(chunk_dims[d]).max(cur_chunks[d])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut order: Vec<usize> = (0..rank).collect();
|
||||||
|
if let Some(u) = unlim {
|
||||||
|
order.remove(u);
|
||||||
|
order.insert(0, u);
|
||||||
|
}
|
||||||
|
let mut down = vec![1u64; rank];
|
||||||
|
for p in (0..rank.saturating_sub(1)).rev() {
|
||||||
|
let next = max_chunks[order[p + 1]];
|
||||||
|
if next == u64::MAX {
|
||||||
|
// Only reachable with more than one unlimited dimension, which
|
||||||
|
// neither index type can describe.
|
||||||
|
return Err(FormatError::ChunkedReadError(
|
||||||
|
"array chunk index with more than one unlimited dimension".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
down[p] = down[p + 1].checked_mul(next).ok_or_else(|| {
|
||||||
|
FormatError::Overflow("chunk index linear stride overflows u64".into())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
chunk_dims: chunk_dims.to_vec(),
|
||||||
|
cur_chunks,
|
||||||
|
order,
|
||||||
|
down,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dataset-space offsets of the chunk stored at linear `index`, or `None`
|
||||||
|
/// when that chunk lies outside the current extent (the index still has a
|
||||||
|
/// slot for it; the library ignores such chunks on read).
|
||||||
|
pub(crate) fn offsets(&self, index: u64) -> Option<Vec<u64>> {
|
||||||
|
let rank = self.chunk_dims.len();
|
||||||
|
let mut offsets = vec![0u64; rank];
|
||||||
|
let mut rem = index;
|
||||||
|
for p in 0..rank {
|
||||||
|
let d = self.order[p];
|
||||||
|
let scaled = rem / self.down[p];
|
||||||
|
rem %= self.down[p];
|
||||||
|
if scaled >= self.cur_chunks[d] {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
offsets[d] = scaled * self.chunk_dims[d];
|
||||||
|
}
|
||||||
|
Some(offsets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear index of the chunk with scaled coordinates `scaled`
|
||||||
|
/// (`offset / chunk_dim` per dimension, in dataset order).
|
||||||
|
pub(crate) fn linear_index(&self, scaled: &[u64]) -> u64 {
|
||||||
|
self.order
|
||||||
|
.iter()
|
||||||
|
.zip(&self.down)
|
||||||
|
.map(|(&d, &stride)| scaled[d] * stride)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_array_uses_max_dims() {
|
||||||
|
// shape (4, 6), chunks (2, 3), maxshape (20, 10): 10 x 4 chunk grid.
|
||||||
|
let g = ChunkGrid::fixed_array(&[4, 6], Some(&[20, 10]), &[2, 3]).unwrap();
|
||||||
|
assert_eq!(g.offsets(0), Some(vec![0, 0]));
|
||||||
|
assert_eq!(g.offsets(1), Some(vec![0, 3]));
|
||||||
|
assert_eq!(g.offsets(2), None); // column chunk 2 is beyond the extent
|
||||||
|
assert_eq!(g.offsets(4), Some(vec![2, 0]));
|
||||||
|
assert_eq!(g.offsets(5), Some(vec![2, 3]));
|
||||||
|
assert_eq!(g.offsets(8), None); // row chunk 2 is beyond the extent
|
||||||
|
assert_eq!(g.linear_index(&[1, 1]), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extensible_array_swizzles_unlimited_dim() {
|
||||||
|
// maxshape (10, None): dim 1 is unlimited and becomes slowest.
|
||||||
|
let g = ChunkGrid::extensible_array(&[4, 6], Some(&[10, u64::MAX]), &[2, 3]).unwrap();
|
||||||
|
// max chunks of dim 0 = 5, so index = c1 * 5 + c0.
|
||||||
|
assert_eq!(g.linear_index(&[1, 0]), 1);
|
||||||
|
assert_eq!(g.linear_index(&[0, 1]), 5);
|
||||||
|
assert_eq!(g.offsets(5), Some(vec![0, 3]));
|
||||||
|
assert_eq!(g.offsets(6), Some(vec![2, 3]));
|
||||||
|
assert_eq!(g.offsets(2), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extensible_array_unlimited_first_is_row_major() {
|
||||||
|
let g = ChunkGrid::extensible_array(&[4, 6], Some(&[u64::MAX, 30]), &[2, 3]).unwrap();
|
||||||
|
// max chunks of dim 1 = 10.
|
||||||
|
assert_eq!(g.linear_index(&[1, 1]), 11);
|
||||||
|
assert_eq!(g.offsets(11), Some(vec![2, 3]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_two_unlimited_dims_after_the_first() {
|
||||||
|
assert!(ChunkGrid::fixed_array(&[4, 6], Some(&[u64::MAX, u64::MAX]), &[2, 3]).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user