Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5889b378e9 | ||
|
|
18dc35f7e5 | ||
|
|
e17ab0ceef | ||
|
|
105cf13347 | ||
|
|
0529f72a2c | ||
|
|
a29c1b224b | ||
|
|
c0a9206703 | ||
|
|
57756e69ec | ||
|
|
6ad8ceb426 | ||
|
|
2e7e0456c1 | ||
|
|
dc0113d015 | ||
|
|
8ea455bbcb | ||
|
|
1e18ff5a86 | ||
|
|
306a35347c |
@@ -33,8 +33,14 @@ jobs:
|
|||||||
/opt/interop/bin/pip install --no-cache-dir h5py numpy netCDF4 xarray
|
/opt/interop/bin/pip install --no-cache-dir h5py numpy netCDF4 xarray
|
||||||
echo "/opt/interop/bin" >> "$GITHUB_PATH"
|
echo "/opt/interop/bin" >> "$GITHUB_PATH"
|
||||||
- name: Show interop library versions
|
- name: Show interop library versions
|
||||||
run: python3 -c "import h5py, netCDF4; print('h5py', h5py.__version__, 'HDF5', h5py.version.hdf5_version, 'netCDF4', netCDF4.__version__)"
|
run: /opt/interop/bin/python -c "import h5py, netCDF4; print('h5py', h5py.__version__, 'HDF5', h5py.version.hdf5_version, 'netCDF4', netCDF4.__version__)"
|
||||||
- name: Run CI script
|
- name: Run CI script
|
||||||
env:
|
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"
|
CLAWHDF5_REQUIRE_INTEROP: "1"
|
||||||
run: bash scripts/ci-test.sh
|
run: bash scripts/ci-test.sh
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+115
@@ -28,6 +28,81 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Memory footprint
|
||||||
|
|
||||||
|
`cargo run --release -p clawhdf5-bench --bin search_harness -- --footprint --full`,
|
||||||
|
384-dim `f32`. The figure that matters is **reopened**: a store loaded from
|
||||||
|
disk, which is what a long-lived process holds.
|
||||||
|
|
||||||
|
Measured with a counting global allocator, not RSS. RSS cannot see this from
|
||||||
|
inside one process — freeing a large structure returns its pages to the
|
||||||
|
allocator's pool rather than to the OS, so allocating the next one shows no
|
||||||
|
change at all. Measured that way a store holding the corpus twice and one
|
||||||
|
holding it once came out *identical* (1.00x both), which is how the first
|
||||||
|
attempt at this measurement went.
|
||||||
|
|
||||||
|
| N | vectors (raw) | reopened, before | reopened, after |
|
||||||
|
|---:|---:|---:|---:|
|
||||||
|
| 1 000 | 1 MiB | 5 MiB (3.41x) | 4 MiB (2.39x) |
|
||||||
|
| 10 000 | 15 MiB | 50 MiB (3.43x) | 35 MiB (2.42x) |
|
||||||
|
| 100 000 | 146 MiB | 505 MiB (3.44x) | **357 MiB (2.43x)** |
|
||||||
|
|
||||||
|
The cache stored every embedding twice — once as a `Vec<Vec<f32>>` and once
|
||||||
|
flattened for the batched kernels, kept in lock-step on every push, update and
|
||||||
|
compaction. Storing only the flat buffer and indexing into it gives back
|
||||||
|
almost exactly one copy of the corpus (148 MiB at 100k) and one heap
|
||||||
|
allocation per entry. Recall and query latency are unchanged.
|
||||||
|
|
||||||
|
What remains at 2.43x: the flat vectors (1.0x), the HNSW index's own copy of
|
||||||
|
them (1.0x), and text, ids and graph (~0.4x). The index copy is the next
|
||||||
|
target — it is what a quantised or borrowed representation would address.
|
||||||
|
|
||||||
|
### Quantising the index copy (`quantized_index`)
|
||||||
|
|
||||||
|
`MemoryConfig::quantized_index` stores the index's copy as `i8` instead of
|
||||||
|
`f32`. Same harness, same binary, `--footprint --full` with and without
|
||||||
|
`--int8`:
|
||||||
|
|
||||||
|
| N | vectors (raw) | indexes, f32 | indexes, int8 | reopened, f32 | reopened, int8 |
|
||||||
|
|---:|---:|---:|---:|---:|---:|
|
||||||
|
| 1 000 | 1 MiB | 2 MiB | 1 MiB | 4 MiB (2.40x) | 2 MiB (1.64x) |
|
||||||
|
| 10 000 | 15 MiB | 32 MiB | 14 MiB | 44 MiB (3.03x) | 27 MiB (1.81x) |
|
||||||
|
| 100 000 | 146 MiB | 266 MiB | **123 MiB** | 399 MiB (2.72x) | **256 MiB (1.74x)** |
|
||||||
|
|
||||||
|
The scale is **per row**, not global. A unit-length row in `d` dimensions has
|
||||||
|
components around `1/sqrt(d)`, so a fixed `[-1, 1]` scale spends fewer than 12
|
||||||
|
of the 255 levels on a 128-dimensional vector: measured against an exact
|
||||||
|
ranking that gives 0.35 top-10 overlap — unusable. Scaling each row by its own
|
||||||
|
largest component brings the same measurement to 0.99.
|
||||||
|
|
||||||
|
Quantised distances still cost recall on their own, and **`ef` does not buy it
|
||||||
|
back**, because the loss is in the distances rather than in the graph
|
||||||
|
(`--ann-only --full`, N = 100 000):
|
||||||
|
|
||||||
|
| ef | recall@10, f32 | recall@10, int8 | recall@10, int8 + re-score |
|
||||||
|
|---:|---:|---:|---:|
|
||||||
|
| 32 | 0.9775 | 0.9415 | 0.9785 |
|
||||||
|
| 64 | 0.9945 | 0.9625 | 0.9940 |
|
||||||
|
| 128 | 0.9995 | 0.9670 | 0.9990 |
|
||||||
|
| 256 | 0.9995 | 0.9670 (ceiling) | 0.9990 |
|
||||||
|
|
||||||
|
Re-scoring closes the gap: the store already holds the exact embeddings, so
|
||||||
|
the query path re-scores the candidate pool against them before fusion. That
|
||||||
|
is done automatically whenever the index is quantised. What it costs is
|
||||||
|
throughput — about 13% of QPS and 16% of build time at 100 000 x 384. So the
|
||||||
|
setting trades ~13% of query speed for ~36% of the process's memory at equal
|
||||||
|
recall. It is **off by default**: the right side of that trade depends on
|
||||||
|
whether the deployment is short of memory or short of CPU.
|
||||||
|
|
||||||
|
A measurement trap worth recording: the synthetic `clustered` generator in the
|
||||||
|
`clawhdf5-ann` tests draws clusters far tighter than any real embedding, so
|
||||||
|
neighbours there sit closer together than the quantisation error and top-10
|
||||||
|
*identity* is noise. Scored on that fixture int8 looks catastrophic (0.57
|
||||||
|
overlap) — a fact about the fixture, not the storage. The tests use random
|
||||||
|
vectors, and recall is measured against brute-force ground truth rather than
|
||||||
|
against the f32 index, whose own approximation errors a re-scored search is
|
||||||
|
entitled to get right.
|
||||||
|
|
||||||
## Read harness
|
## Read harness
|
||||||
|
|
||||||
Produced by `cargo run --release -p clawhdf5-bench --bin read_harness`: a 4096 x
|
Produced by `cargo run --release -p clawhdf5-bench --bin read_harness`: a 4096 x
|
||||||
@@ -653,6 +728,46 @@ add. There is no case here for changing the default; `TokenFilter::Stemmed`
|
|||||||
is available via `HDF5Memory::set_token_filter` for callers who want Hit@5/@10
|
is available via `HDF5Memory::set_token_filter` for callers who want Hit@5/@10
|
||||||
over rank-1 precision.
|
over rank-1 precision.
|
||||||
|
|
||||||
|
### Re-ranking and recency — full haystack, n=500
|
||||||
|
|
||||||
|
`reranker::rerank` combines temporal decay, source authority and Hebbian
|
||||||
|
activation. Until now its combined score contained **no relevance term at
|
||||||
|
all** — `RerankInput` did not carry the retrieval score — so a caller that
|
||||||
|
re-ranked its candidates threw the retriever's ordering away and returned them
|
||||||
|
ordered by age. The OpenClaw backend did exactly that on every search.
|
||||||
|
|
||||||
|
Measuring that is unambiguous. "Recency" below is the share of
|
||||||
|
`knowledge-update` questions where the newest gold session outranked the stale
|
||||||
|
one (see `newest_gold_first`); ~45% is chance.
|
||||||
|
|
||||||
|
| Mode | Hit@1 | Hit@5 | Hit@10 | MRR | recency |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Hybrid 0.4/0.6, no re-rank | 51.6% | **81.4%** | 87.8% | 0.6430 | 45.0% |
|
||||||
|
| + re-rank, **metadata only** (pre-fix) | 11.0% | 24.8% | 43.8% | 0.1829 | **87.5%** |
|
||||||
|
| + re-rank, relevance-led, half-life 1 day | **52.0%** | 79.8% | 87.8% | 0.6403 | 51.7% |
|
||||||
|
| + re-rank, relevance-led, half-life 7 days | 51.8% | 80.8% | 87.6% | **0.6437** | **52.2%** |
|
||||||
|
| + re-rank, relevance-led, half-life 30 days | 51.8% | 81.0% | 87.8% | 0.6427 | 51.4% |
|
||||||
|
| + re-rank, relevance-led, half-life 90 days | **52.0%** | 80.4% | 87.8% | 0.6425 | 50.8% |
|
||||||
|
|
||||||
|
**The pre-fix row is the finding.** Ordering candidates by recency alone costs
|
||||||
|
40.6pp of Hit@1 and two thirds of MRR: the results are the newest memories in
|
||||||
|
the pool rather than the ones that answer the question. It does ace the recency
|
||||||
|
metric, which is exactly what makes that metric worth having — a number that
|
||||||
|
only goes up when a change is good would not have caught this.
|
||||||
|
|
||||||
|
With relevance leading, retrieval is preserved (Hit@1 +0.4pp, MRR −0.003
|
||||||
|
against no re-ranking) and recency discrimination gains 6–7pp. That is a real
|
||||||
|
improvement but not a solved problem: recency only breaks near-ties, so it
|
||||||
|
cannot reach the 87.5% the degenerate ordering gets. Those two rows are the
|
||||||
|
ends of a trade-off, and the default sits deliberately near the relevance end.
|
||||||
|
|
||||||
|
**Half-life is not a sensitive knob.** Across 1, 7, 30 and 90 days recency
|
||||||
|
moves 1.4pp and MRR 0.003 — inside the noise of a 500-question run — because
|
||||||
|
the temporal term is capped by its weight (0.3) while relevance differences
|
||||||
|
between candidates are larger. The 24-hour default is kept; there is no
|
||||||
|
measured reason to change it, and a corpus-matched value is not the lever it
|
||||||
|
looks like.
|
||||||
|
|
||||||
### Weight sweep — full haystack, n=500
|
### Weight sweep — full haystack, n=500
|
||||||
|
|
||||||
`0.7/0.3` was a documented default, never a searched one. Sweeping
|
`0.7/0.3` was a documented default, never a searched one. Sweeping
|
||||||
|
|||||||
@@ -1,5 +1,84 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v2.6.0 (2026-09-20)
|
||||||
|
|
||||||
|
### Upgrade Notes
|
||||||
|
- **Re-ranked results change, substantially for the better.** `RerankInput`
|
||||||
|
and `ReRankConfig` gained fields (`relevance`, `relevance_weight`), so
|
||||||
|
literal constructions need updating; `..Default::default()` does not. Any
|
||||||
|
caller that re-ranked was previously getting results ordered by age with the
|
||||||
|
retrieval score discarded — see below.
|
||||||
|
- **Breaking:** `MemoryCache::embeddings` is a `cache::Embeddings` rather than
|
||||||
|
a `Vec<Vec<f32>>` (indexing still yields a `&[f32]` row); `embeddings_flat`
|
||||||
|
is gone, replaced by `flat_embeddings()`; `rebuild_flat()` is a deprecated
|
||||||
|
no-op.
|
||||||
|
- `MemoryConfig` gained `quantized_index` (default `false`, so behaviour is
|
||||||
|
unchanged unless you opt in); literal constructions need the field.
|
||||||
|
|
||||||
|
### Retrieval quality
|
||||||
|
- `clawhdf5-agent`: **re-ranking discarded the retrieval score.**
|
||||||
|
`reranker::rerank` built its combined score from temporal decay, source
|
||||||
|
authority and Hebbian activation only — `RerankInput` had no relevance field
|
||||||
|
— so re-ranking a candidate pool reordered it by age and threw the
|
||||||
|
retriever's ordering away. The OpenClaw backend re-ranked every search, so
|
||||||
|
this was its shipping behaviour: measured over the full LongMemEval haystack
|
||||||
|
it cost **40.6pp of Hit@1** (11.0% vs 51.6%) and two thirds of MRR (0.183 vs
|
||||||
|
0.643). `RerankInput::relevance` and `ReRankConfig::relevance_weight` (1.0 by
|
||||||
|
default) fix it: relevance leads and the metadata signals break near-ties,
|
||||||
|
which restores retrieval (Hit@1 +0.4pp vs no re-ranking) and improves
|
||||||
|
recency discrimination by 6–7pp. **Breaking:** `RerankInput` and
|
||||||
|
`ReRankConfig` gained fields, so literal constructions need updating;
|
||||||
|
`..Default::default()` does not.
|
||||||
|
- `clawhdf5-bench`: the LongMemEval harness feeds the dataset's real session
|
||||||
|
dates to the store instead of a synthetic counter (decay needs true
|
||||||
|
intervals, not just the right order), and reports `newest_gold_first` — on a
|
||||||
|
`knowledge-update` question, did the newest gold session outrank the stale
|
||||||
|
one it supersedes? Plain recall cannot see this, because both are labelled
|
||||||
|
gold. New `--rerank-sweep`.
|
||||||
|
|
||||||
|
### Memory
|
||||||
|
- `clawhdf5-agent`: **`MemoryConfig::quantized_index`** stores the vector
|
||||||
|
index's own copy of the embeddings as `i8` rather than `f32`, which at 100k
|
||||||
|
384-dim entries takes the index from 266 to 123 MiB and the whole reopened
|
||||||
|
store from 399 to 256 MiB (2.72x -> **1.74x** the raw vectors). Quantised
|
||||||
|
distances are approximate and `ef` cannot compensate — recall@10 tops out at
|
||||||
|
0.967 against f32's 0.9995 — so the query path re-scores the candidate pool
|
||||||
|
against the exact embeddings the store already holds, which restores recall
|
||||||
|
(0.9940 vs 0.9945 at ef=64) for about 13% of QPS. **Off by default**: it
|
||||||
|
trades query speed for memory, and which side is worth more depends on the
|
||||||
|
deployment. The setting is persisted, so a reopened store does not silently
|
||||||
|
revert to four times the index memory.
|
||||||
|
- `clawhdf5-ann`: `Storage::Int8` and the `build_with` / `new_with` /
|
||||||
|
`from_graph_bytes_with` constructors that select it. The scale is per row,
|
||||||
|
not global — a fixed `[-1, 1]` scale spends fewer than 12 of the 255 levels
|
||||||
|
on a unit-length 128-dim vector and is unusable (0.35 top-10 overlap against
|
||||||
|
an exact ranking, versus 0.99 per row). `compact()` keeps the storage it was
|
||||||
|
given; serialized indexes still carry f32 vectors, so a quantised index is
|
||||||
|
rebuilt rather than loaded.
|
||||||
|
- `clawhdf5-agent`: **a loaded store holds ~30% less memory** (100k 384-dim
|
||||||
|
entries: 505 -> 357 MiB, 3.44x -> 2.43x the raw vectors). The cache kept
|
||||||
|
every embedding twice — a `Vec<Vec<f32>>` and a flattened copy for the
|
||||||
|
batched kernels, maintained in lock-step — so it now stores only the flat
|
||||||
|
buffer and indexes into it. Recall and query latency are unchanged.
|
||||||
|
**Breaking:** `MemoryCache::embeddings` is a `cache::Embeddings` rather than
|
||||||
|
a `Vec<Vec<f32>>` (indexing still yields a `&[f32]` row); `embeddings_flat`
|
||||||
|
is gone, replaced by `flat_embeddings()`; `rebuild_flat()` is a deprecated
|
||||||
|
no-op. Rows are now always exactly `dim` long — shorter ones are
|
||||||
|
zero-padded — which makes the ragged-row case that used to silently
|
||||||
|
misalign the flattened copy unrepresentable.
|
||||||
|
- `clawhdf5-bench`: `search_harness --footprint` reports live heap use per
|
||||||
|
stage, measured with a counting allocator (RSS cannot see a structure freed
|
||||||
|
into the allocator's own pool).
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- The Python interop suites honour **`CLAWHDF5_PYTHON`**, and `ci-test.sh`
|
||||||
|
picks up a `.venv/bin/python` automatically. On a PEP 668 "externally
|
||||||
|
managed" system h5py cannot be installed into the system interpreter at all,
|
||||||
|
so every interop suite — the h5py writer round-trips, the facade, netCDF4
|
||||||
|
and the reference files — was skipping silently. A silent skip here is
|
||||||
|
exactly how the v5 compound-datatype bug reached a release.
|
||||||
|
`CLAWHDF5_REQUIRE_INTEROP=1` still turns a skip into a failure.
|
||||||
|
|
||||||
## v2.5.0 (2026-09-19)
|
## v2.5.0 (2026-09-19)
|
||||||
|
|
||||||
### Upgrade Notes
|
### Upgrade Notes
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
|
|||||||
(plain closest-M capped recall on clustered data: 0.31 recall@10 at 100K). Its
|
(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()`
|
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
|
(tied to the checkpoint by a generation id; stale/damaged sidecars are
|
||||||
ignored and the index rebuilt). `hybrid_search` keeps one incremental BM25
|
ignored and the index rebuilt). `MemoryConfig::quantized_index` (off by
|
||||||
|
default, persisted) stores the index's own copy of the embeddings as `i8`,
|
||||||
|
which roughly halves a loaded store's memory (2.72x -> 1.74x the raw vectors
|
||||||
|
at 100K); because quantised distances are approximate and `ef` cannot
|
||||||
|
compensate, the query path then re-scores the candidate pool against the
|
||||||
|
exact embeddings, which holds recall at the f32 index's level and costs
|
||||||
|
~13% of QPS. `hybrid_search` keeps one incremental BM25
|
||||||
index for the life of the store and never writes the store: Hebbian
|
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
|
activation boosts are persisted by the next checkpoint (or on drop), not per
|
||||||
query. Measure any search-path change with
|
query. Measure any search-path change with
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ members = [
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||||
|
|||||||
@@ -432,6 +432,13 @@ ClawhDF5's agent memory design draws from 15+ recent papers:
|
|||||||
| `agent` | no | Full agent memory layer |
|
| `agent` | no | Full agent memory layer |
|
||||||
| `float16` | **yes** | Half-precision embedding storage (2× compression) |
|
| `float16` | **yes** | Half-precision embedding storage (2× compression) |
|
||||||
| `hnsw` | **yes** | HNSW approximate vector index for `hybrid_search` (via `clawhdf5-ann`); disable for an exact linear scan |
|
| `hnsw` | **yes** | HNSW approximate vector index for `hybrid_search` (via `clawhdf5-ann`); disable for an exact linear scan |
|
||||||
|
|
||||||
|
`MemoryConfig::quantized_index` (off by default) stores the HNSW index's own
|
||||||
|
copy of the embeddings as `i8`, roughly halving a loaded store's memory
|
||||||
|
(2.72x -> 1.74x the raw vectors at 100k x 384). Quantised distances are
|
||||||
|
approximate, so the query path re-scores the candidate pool against the exact
|
||||||
|
embeddings the store already holds — recall matches the `f32` index, at about
|
||||||
|
13% fewer queries per second. See `BENCHMARKS.md`, "Quantising the index copy".
|
||||||
| `parallel` | no | Rayon parallel search |
|
| `parallel` | no | Rayon parallel search |
|
||||||
| `fast-math` | no | BLAS matrix-vector multiply |
|
| `fast-math` | no | BLAS matrix-vector multiply |
|
||||||
| `accelerate` | no | Apple Accelerate / AMX (macOS) |
|
| `accelerate` | no | Apple Accelerate / AMX (macOS) |
|
||||||
@@ -492,6 +499,14 @@ cargo build -p clawhdf5-agent --features "agent,float16,accelerate,parallel,gpu"
|
|||||||
# Tests
|
# Tests
|
||||||
cargo test --workspace # all 1,650+ tests
|
cargo test --workspace # all 1,650+ tests
|
||||||
cargo test -p clawhdf5-agent # agent memory tests
|
cargo test -p clawhdf5-agent # agent memory tests
|
||||||
|
scripts/ci-test.sh # what CI runs: fmt, clippy matrix, tests,
|
||||||
|
# h5py/netCDF4 interop, no_std
|
||||||
|
|
||||||
|
# The interop suites need a Python with h5py; on a PEP 668 system that has to
|
||||||
|
# be a virtualenv. `ci-test.sh` finds `.venv` on its own, or set
|
||||||
|
# CLAWHDF5_PYTHON. Without one they skip — set CLAWHDF5_REQUIRE_INTEROP=1 to
|
||||||
|
# make that a failure instead.
|
||||||
|
python3 -m venv .venv && .venv/bin/pip install h5py numpy netCDF4 xarray
|
||||||
|
|
||||||
# Benchmarks
|
# Benchmarks
|
||||||
cargo bench -p clawhdf5-agent # agent memory suite
|
cargo bench -p clawhdf5-agent # agent memory suite
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-accel"
|
name = "clawhdf5-accel"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "SIMD-accelerated operations for rustyhdf5"
|
description = "SIMD-accelerated operations for rustyhdf5"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-agent"
|
name = "clawhdf5-agent"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "HDF5-backed persistent memory store for on-device AI agents"
|
description = "HDF5-backed persistent memory store for on-device AI agents"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -10,12 +10,12 @@ keywords = ["agent", "memory", "hdf5", "vector-search", "embedding"]
|
|||||||
categories = ["database", "science", "algorithms"]
|
categories = ["database", "science", "algorithms"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.5.0", features = ["parallel", "fast-checksum"] }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0", features = ["parallel", "fast-checksum"] }
|
||||||
clawhdf5 = { path = "../clawhdf5", version = "2.5.0" }
|
clawhdf5 = { path = "../clawhdf5", version = "2.6.0" }
|
||||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.5.0", features = ["mmap"] }
|
clawhdf5-io = { path = "../clawhdf5-io", version = "2.6.0", features = ["mmap"] }
|
||||||
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.5.0" }
|
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.6.0" }
|
||||||
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.5.0", optional = true }
|
clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.6.0", optional = true }
|
||||||
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.5.0", optional = true, default-features = false }
|
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.6.0", optional = true, default-features = false }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
byteorder = "1"
|
byteorder = "1"
|
||||||
half = { workspace = true, optional = true }
|
half = { workspace = true, optional = true }
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,143 @@
|
|||||||
|
|
||||||
use crate::vector_search;
|
use crate::vector_search;
|
||||||
|
|
||||||
|
/// Every entry's embedding, in one contiguous `[N x dim]` buffer.
|
||||||
|
///
|
||||||
|
/// Rows are always exactly `dim` long: a shorter one is zero-padded, a longer
|
||||||
|
/// one truncated. The previous `Vec<Vec<f32>>` allowed ragged rows, which
|
||||||
|
/// silently misaligned the flattened copy that the batched kernels read — a
|
||||||
|
/// single wrong-length embedding shifted every row after it. Padding makes
|
||||||
|
/// that unrepresentable. A record stored without an embedding therefore holds
|
||||||
|
/// a zero row, and is told apart by its norm being zero rather than by length.
|
||||||
|
///
|
||||||
|
/// This used to be two fields — a `Vec<Vec<f32>>` and a flattened copy kept in
|
||||||
|
/// lock-step — which stored the whole corpus twice and cost one heap
|
||||||
|
/// allocation per entry on top. At 100k 384-dim entries that duplicate was
|
||||||
|
/// ~150 MiB. Indexing yields a `&[f32]` row, so `embeddings[i]` still reads
|
||||||
|
/// the same way.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Embeddings {
|
||||||
|
flat: Vec<f32>,
|
||||||
|
dim: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Embeddings {
|
||||||
|
pub fn new(dim: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
flat: Vec::new(),
|
||||||
|
dim,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of embeddings.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.flat.len().checked_div(self.dim).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.len() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole buffer, `[N x dim]` row-major — what batched kernels read.
|
||||||
|
pub fn as_flat(&self) -> &[f32] {
|
||||||
|
&self.flat
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dim(&self) -> usize {
|
||||||
|
self.dim
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row `i`, or `None` if out of range.
|
||||||
|
pub fn get(&self, i: usize) -> Option<&[f32]> {
|
||||||
|
let start = i.checked_mul(self.dim)?;
|
||||||
|
self.flat.get(start..start.checked_add(self.dim)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> impl ExactSizeIterator<Item = &[f32]> {
|
||||||
|
self.flat.chunks_exact(self.dim.max(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append one embedding. A row whose length doesn't match `dim` is padded
|
||||||
|
/// or truncated, so the buffer stays rectangular whatever a caller passes.
|
||||||
|
pub fn push(&mut self, embedding: &[f32]) {
|
||||||
|
if self.dim == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let take = embedding.len().min(self.dim);
|
||||||
|
self.flat.extend_from_slice(&embedding[..take]);
|
||||||
|
self.flat.resize(self.flat.len() + (self.dim - take), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace row `i`. Out-of-range indices are ignored.
|
||||||
|
pub fn set(&mut self, i: usize, embedding: &[f32]) {
|
||||||
|
let Some(start) = i.checked_mul(self.dim) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if start + self.dim > self.flat.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let take = embedding.len().min(self.dim);
|
||||||
|
self.flat[start..start + take].copy_from_slice(&embedding[..take]);
|
||||||
|
self.flat[start + take..start + self.dim].fill(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep only the rows `keep` returns true for, preserving order.
|
||||||
|
pub fn retain(&mut self, mut keep: impl FnMut(usize) -> bool) {
|
||||||
|
if self.dim == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut write = 0usize;
|
||||||
|
for read in 0..self.len() {
|
||||||
|
if keep(read) {
|
||||||
|
if write != read {
|
||||||
|
let (dst, src) = (write * self.dim, read * self.dim);
|
||||||
|
self.flat.copy_within(src..src + self.dim, dst);
|
||||||
|
}
|
||||||
|
write += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.flat.truncate(write * self.dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the contents with `rows`.
|
||||||
|
pub fn reset_from(&mut self, dim: usize, rows: impl IntoIterator<Item = Vec<f32>>) {
|
||||||
|
self.dim = dim;
|
||||||
|
self.flat.clear();
|
||||||
|
for row in rows {
|
||||||
|
self.push(&row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adopt an already-flat buffer, trimming any partial trailing row.
|
||||||
|
pub fn set_flat(&mut self, dim: usize, mut flat: Vec<f32>) {
|
||||||
|
self.dim = dim;
|
||||||
|
match flat.len().checked_div(dim) {
|
||||||
|
Some(rows) => flat.truncate(rows * dim),
|
||||||
|
None => flat.clear(),
|
||||||
|
}
|
||||||
|
self.flat = flat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for Embeddings {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.dim == other.dim && self.flat == other.flat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Index<usize> for Embeddings {
|
||||||
|
type Output = [f32];
|
||||||
|
|
||||||
|
fn index(&self, i: usize) -> &[f32] {
|
||||||
|
self.get(i).expect("embedding index out of range")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// In-memory cache for the /memory group data.
|
/// In-memory cache for the /memory group data.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MemoryCache {
|
pub struct MemoryCache {
|
||||||
pub chunks: Vec<String>,
|
pub chunks: Vec<String>,
|
||||||
pub embeddings: 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>,
|
||||||
@@ -28,8 +155,7 @@ impl MemoryCache {
|
|||||||
pub fn new(embedding_dim: usize) -> Self {
|
pub fn new(embedding_dim: usize) -> Self {
|
||||||
Self {
|
Self {
|
||||||
chunks: Vec::new(),
|
chunks: Vec::new(),
|
||||||
embeddings: 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(),
|
||||||
@@ -41,15 +167,14 @@ impl MemoryCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild `embeddings_flat` from `embeddings` from scratch. Callers that
|
/// Kept for callers that used to have to re-flatten after a bulk load.
|
||||||
/// populate `embeddings` directly (bulk loads) must call this afterward.
|
/// The buffer is always flat now, so there is nothing to rebuild.
|
||||||
pub fn rebuild_flat(&mut self) {
|
#[deprecated(note = "embeddings are stored flat; this is a no-op")]
|
||||||
self.embeddings_flat.clear();
|
pub fn rebuild_flat(&mut self) {}
|
||||||
self.embeddings_flat
|
|
||||||
.reserve(self.embeddings.len() * self.embedding_dim);
|
/// The embeddings as one contiguous `[N x dim]` buffer.
|
||||||
for emb in &self.embeddings {
|
pub fn flat_embeddings(&self) -> &[f32] {
|
||||||
self.embeddings_flat.extend_from_slice(emb);
|
self.embeddings.as_flat()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total number of entries (including tombstoned).
|
/// Total number of entries (including tombstoned).
|
||||||
@@ -79,8 +204,7 @@ impl MemoryCache {
|
|||||||
let idx = self.chunks.len();
|
let idx = self.chunks.len();
|
||||||
let norm = vector_search::compute_norm(&embedding);
|
let norm = vector_search::compute_norm(&embedding);
|
||||||
self.chunks.push(chunk);
|
self.chunks.push(chunk);
|
||||||
self.embeddings_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);
|
||||||
@@ -118,20 +242,7 @@ impl MemoryCache {
|
|||||||
if idx < self.chunks.len() {
|
if idx < self.chunks.len() {
|
||||||
let norm = vector_search::compute_norm(&embedding);
|
let norm = vector_search::compute_norm(&embedding);
|
||||||
self.chunks[idx] = chunk;
|
self.chunks[idx] = chunk;
|
||||||
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 +294,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 +307,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 +316,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 +334,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 +357,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 +392,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 +428,15 @@ 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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ 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,
|
||||||
@@ -56,7 +56,7 @@ pub fn hybrid_search(
|
|||||||
pub fn hybrid_search_fused(
|
pub fn hybrid_search_fused(
|
||||||
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,
|
||||||
@@ -69,12 +69,12 @@ pub fn hybrid_search_fused(
|
|||||||
let vec_scores = {
|
let 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)
|
||||||
@@ -270,7 +270,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,
|
||||||
@@ -282,12 +282,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)
|
||||||
@@ -298,7 +298,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));
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use cache::MemoryCache;
|
use cache::MemoryCache;
|
||||||
#[cfg(feature = "hnsw")]
|
#[cfg(feature = "hnsw")]
|
||||||
use clawhdf5_ann::{DistanceMetric, HnswIndex};
|
use clawhdf5_ann::{DistanceMetric, HnswIndex, Storage};
|
||||||
use ephemeral::{EphemeralConfig, EphemeralStore};
|
use ephemeral::{EphemeralConfig, EphemeralStore};
|
||||||
|
|
||||||
/// HNSW construction parameters used for the agent's vector index. Cosine is the
|
/// HNSW construction parameters used for the agent's vector index. Cosine is the
|
||||||
@@ -139,6 +139,17 @@ pub struct MemoryConfig {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub wal_enabled: bool,
|
pub wal_enabled: bool,
|
||||||
pub wal_max_entries: usize,
|
pub wal_max_entries: usize,
|
||||||
|
/// Store the vector index's own copy of the embeddings as int8 rather than
|
||||||
|
/// f32, a quarter of the memory.
|
||||||
|
///
|
||||||
|
/// The index's copy is the single largest part of a loaded store's
|
||||||
|
/// footprint. Quantised distances are approximate, so the candidate pool
|
||||||
|
/// is re-scored against the cache's exact embeddings before fusion, which
|
||||||
|
/// restores recall; what it costs is throughput — roughly 13% of queries
|
||||||
|
/// per second and 16% of build time at 100K x 384. See `BENCHMARKS.md`.
|
||||||
|
///
|
||||||
|
/// Has no effect without the `hnsw` feature.
|
||||||
|
pub quantized_index: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryConfig {
|
impl MemoryConfig {
|
||||||
@@ -160,6 +171,7 @@ impl MemoryConfig {
|
|||||||
created_at,
|
created_at,
|
||||||
wal_enabled: true,
|
wal_enabled: true,
|
||||||
wal_max_entries: 500,
|
wal_max_entries: 500,
|
||||||
|
quantized_index: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,7 +456,17 @@ impl HDF5Memory {
|
|||||||
|
|
||||||
#[cfg(feature = "hnsw")]
|
#[cfg(feature = "hnsw")]
|
||||||
let loaded_index = if replay_only_appended {
|
let loaded_index = if replay_only_appended {
|
||||||
Self::load_vector_index(path, checkpoint.ann_generation, &cache, n_checkpoint)
|
Self::load_vector_index(
|
||||||
|
path,
|
||||||
|
checkpoint.ann_generation,
|
||||||
|
&cache,
|
||||||
|
n_checkpoint,
|
||||||
|
if config.quantized_index {
|
||||||
|
Storage::Int8
|
||||||
|
} else {
|
||||||
|
Storage::Float32
|
||||||
|
},
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -558,6 +580,7 @@ impl HDF5Memory {
|
|||||||
generation: Option<u64>,
|
generation: Option<u64>,
|
||||||
cache: &MemoryCache,
|
cache: &MemoryCache,
|
||||||
n_checkpoint: usize,
|
n_checkpoint: usize,
|
||||||
|
storage: Storage,
|
||||||
) -> Option<HnswIndex> {
|
) -> Option<HnswIndex> {
|
||||||
let generation = generation?;
|
let generation = generation?;
|
||||||
let bytes = std::fs::read(Self::vector_index_path(store)).ok()?;
|
let bytes = std::fs::read(Self::vector_index_path(store)).ok()?;
|
||||||
@@ -565,15 +588,17 @@ impl HDF5Memory {
|
|||||||
if u64::from_le_bytes(stamp.try_into().ok()?) != generation {
|
if u64::from_le_bytes(stamp.try_into().ok()?) != generation {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let vectors = cache.embeddings.get(..n_checkpoint)?.to_vec();
|
let vectors: Vec<Vec<f32>> = (0..n_checkpoint)
|
||||||
let mut index = HnswIndex::from_graph_bytes(graph, vectors).ok()?;
|
.map(|i| cache.embeddings.get(i).map(<[f32]>::to_vec))
|
||||||
|
.collect::<Option<_>>()?;
|
||||||
|
let mut index = HnswIndex::from_graph_bytes_with(graph, vectors, storage).ok()?;
|
||||||
if index.dimension() != cache.embedding_dim {
|
if index.dimension() != cache.embedding_dim {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// Records appended since (replayed from the WAL) join incrementally.
|
// Records appended since (replayed from the WAL) join incrementally.
|
||||||
for id in n_checkpoint..cache.embeddings.len() {
|
for id in n_checkpoint..cache.embeddings.len() {
|
||||||
if cache.embeddings[id].len() != index.dimension()
|
if cache.embeddings[id].len() != index.dimension()
|
||||||
|| index.insert(cache.embeddings[id].clone()) != id
|
|| index.insert(cache.embeddings[id].to_vec()) != id
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -801,6 +826,16 @@ impl HDF5Memory {
|
|||||||
// the index length drifts from the cache length (covering any mutation path
|
// the index length drifts from the cache length (covering any mutation path
|
||||||
// that doesn't call a hook, e.g. consolidation pushes).
|
// that doesn't call a hook, e.g. consolidation pushes).
|
||||||
|
|
||||||
|
/// How the index should store its copy of the vectors, per the config.
|
||||||
|
#[cfg(feature = "hnsw")]
|
||||||
|
fn index_storage(&self) -> Storage {
|
||||||
|
if self.config.quantized_index {
|
||||||
|
Storage::Int8
|
||||||
|
} else {
|
||||||
|
Storage::Float32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build an HNSW index over the entire cache, re-applying tombstones as
|
/// Build an HNSW index over the entire cache, re-applying tombstones as
|
||||||
/// soft-deletions so node ids stay aligned with cache indices.
|
/// soft-deletions so node ids stay aligned with cache indices.
|
||||||
///
|
///
|
||||||
@@ -816,11 +851,15 @@ impl HDF5Memory {
|
|||||||
if self.cache.embeddings.iter().any(|e| e.len() != dim) {
|
if self.cache.embeddings.iter().any(|e| e.len() != dim) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut index = HnswIndex::build_with_metric(
|
// The index owns its vectors, so it needs rows rather than the cache's
|
||||||
&self.cache.embeddings,
|
// flat buffer. This copy is the index's own; the cache keeps one.
|
||||||
|
let rows: Vec<Vec<f32>> = self.cache.embeddings.iter().map(<[f32]>::to_vec).collect();
|
||||||
|
let mut index = HnswIndex::build_with(
|
||||||
|
&rows,
|
||||||
HNSW_M,
|
HNSW_M,
|
||||||
HNSW_EF_CONSTRUCTION,
|
HNSW_EF_CONSTRUCTION,
|
||||||
DistanceMetric::Cosine,
|
DistanceMetric::Cosine,
|
||||||
|
self.index_storage(),
|
||||||
);
|
);
|
||||||
for (i, &t) in self.cache.tombstones.iter().enumerate() {
|
for (i, &t) in self.cache.tombstones.iter().enumerate() {
|
||||||
if t != 0 {
|
if t != 0 {
|
||||||
@@ -846,7 +885,7 @@ impl HDF5Memory {
|
|||||||
let dim = index.dimension();
|
let dim = index.dimension();
|
||||||
let appended = (self.hnsw_synced_len..n).all(|id| {
|
let appended = (self.hnsw_synced_len..n).all(|id| {
|
||||||
self.cache.embeddings[id].len() == dim
|
self.cache.embeddings[id].len() == dim
|
||||||
&& index.insert(self.cache.embeddings[id].clone()) == id
|
&& index.insert(self.cache.embeddings[id].to_vec()) == id
|
||||||
});
|
});
|
||||||
if appended {
|
if appended {
|
||||||
for id in self.hnsw_synced_len..n {
|
for id in self.hnsw_synced_len..n {
|
||||||
@@ -877,7 +916,7 @@ impl HDF5Memory {
|
|||||||
let emb_len = self.cache.embeddings[idx].len();
|
let emb_len = self.cache.embeddings[idx].len();
|
||||||
match self.hnsw.as_mut() {
|
match self.hnsw.as_mut() {
|
||||||
Some(index) if emb_len == index.dimension() => {
|
Some(index) if emb_len == index.dimension() => {
|
||||||
let id = index.insert(self.cache.embeddings[idx].clone());
|
let id = index.insert(self.cache.embeddings[idx].to_vec());
|
||||||
if id == idx {
|
if id == idx {
|
||||||
self.hnsw_synced_len = self.cache.embeddings.len();
|
self.hnsw_synced_len = self.cache.embeddings.len();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -466,7 +466,7 @@ impl ClawhdfBackend {
|
|||||||
let record = MemoryRecord {
|
let record = MemoryRecord {
|
||||||
id: i as u64,
|
id: i as u64,
|
||||||
chunk: cache.chunks[i].clone(),
|
chunk: cache.chunks[i].clone(),
|
||||||
embedding: cache.embeddings[i].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,
|
||||||
@@ -554,6 +554,7 @@ impl MemoryBackend for ClawhdfBackend {
|
|||||||
timestamp: r.timestamp,
|
timestamp: r.timestamp,
|
||||||
source_channel: r.source_channel.clone(),
|
source_channel: r.source_channel.clone(),
|
||||||
raw_activation: r.activation,
|
raw_activation: r.activation,
|
||||||
|
relevance: r.score,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -716,11 +717,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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ pub fn build_hdf5_file_with_meta(
|
|||||||
"wal_max_entries",
|
"wal_max_entries",
|
||||||
AttrValue::I64(config.wal_max_entries as i64),
|
AttrValue::I64(config.wal_max_entries as i64),
|
||||||
);
|
);
|
||||||
|
meta.set_attr(
|
||||||
|
"quantized_index",
|
||||||
|
AttrValue::I64(config.quantized_index.into()),
|
||||||
|
);
|
||||||
meta.set_attr(
|
meta.set_attr(
|
||||||
"edgehdf5_version",
|
"edgehdf5_version",
|
||||||
AttrValue::String(ZEROCLAW_VERSION.into()),
|
AttrValue::String(ZEROCLAW_VERSION.into()),
|
||||||
@@ -153,7 +157,7 @@ fn build_memory_group(
|
|||||||
{
|
{
|
||||||
let ds = group
|
let ds = group
|
||||||
.create_dataset("embeddings")
|
.create_dataset("embeddings")
|
||||||
.with_f32_data(&flat)
|
.with_f32_data(flat)
|
||||||
.with_shape(&[n, d]);
|
.with_shape(&[n, d]);
|
||||||
|
|
||||||
// Chunk size tuning: target ~256KB per chunk for optimal I/O
|
// Chunk size tuning: target ~256KB per chunk for optimal I/O
|
||||||
@@ -484,6 +488,7 @@ pub fn validate_and_load(
|
|||||||
wal_max_entries: optional_i64_attr(&attrs, "wal_max_entries")
|
wal_max_entries: optional_i64_attr(&attrs, "wal_max_entries")
|
||||||
.and_then(|v| usize::try_from(v).ok())
|
.and_then(|v| usize::try_from(v).ok())
|
||||||
.unwrap_or(500),
|
.unwrap_or(500),
|
||||||
|
quantized_index: optional_bool_attr(&attrs, "quantized_index", false),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load /memory group
|
// Load /memory group
|
||||||
@@ -563,12 +568,7 @@ fn load_memory_group(
|
|||||||
.collect(),
|
.collect(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Unflatten embeddings
|
// No unflattening: the cache stores the buffer as it is on disk.
|
||||||
let embeddings: Vec<Vec<f32>> = flat_embeddings
|
|
||||||
.chunks(embedding_dim)
|
|
||||||
.map(|c| c.to_vec())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Read activation_weights if present, default to vec![1.0; N] for backward compat
|
// Read activation_weights if present, default to vec![1.0; N] for backward compat
|
||||||
let activation_weights = match read_f32_dataset(&group, "activation_weights") {
|
let activation_weights = match read_f32_dataset(&group, "activation_weights") {
|
||||||
Ok(w) if w.len() == n => w,
|
Ok(w) if w.len() == n => w,
|
||||||
@@ -576,7 +576,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;
|
||||||
@@ -584,7 +584,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,10 +29,26 @@ impl HDF5Memory {
|
|||||||
// Over-fetch so the merge sees a useful vector pool; cosine
|
// Over-fetch so the merge sees a useful vector pool; cosine
|
||||||
// distance from the index converts back to similarity (1 - d).
|
// distance from the index converts back to similarity (1 - d).
|
||||||
let pool = (k * 8).max(64);
|
let pool = (k * 8).max(64);
|
||||||
let vec_scores: Vec<(usize, f32)> = index
|
let candidates = index.search(query_embedding, pool, pool);
|
||||||
.search(query_embedding, pool, pool)
|
// A quantised index returns approximate distances, and no
|
||||||
|
// amount of `ef` fixes that — the loss is in the distances,
|
||||||
|
// not the graph. Re-score the pool against the cache's exact
|
||||||
|
// embeddings, which cost nothing extra to keep: recall then
|
||||||
|
// matches an f32 index. See `BENCHMARKS.md`.
|
||||||
|
let exact = index.storage() == clawhdf5_ann::Storage::Int8;
|
||||||
|
let vec_scores: Vec<(usize, f32)> = candidates
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, dist)| (id, 1.0 - dist))
|
.map(|(id, dist)| {
|
||||||
|
let score = if exact {
|
||||||
|
crate::vector_search::cosine_similarity(
|
||||||
|
query_embedding,
|
||||||
|
&self.cache.embeddings[id],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
1.0 - dist
|
||||||
|
};
|
||||||
|
(id, score)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
// Fusion normalises over every keyword match, so it needs all
|
// Fusion normalises over every keyword match, so it needs all
|
||||||
// the scores — but not ranked.
|
// the scores — but not ranked.
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -165,3 +165,72 @@ fn save_batch_then_search_is_consistent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quantized_index_matches_the_f32_index_after_re_scoring() {
|
||||||
|
// A quantised index holds approximate vectors, but the store still has the
|
||||||
|
// exact ones, so the query path re-scores the candidate pool before
|
||||||
|
// fusion. The results a caller sees should therefore be the same.
|
||||||
|
let dim = 64;
|
||||||
|
let n = 400;
|
||||||
|
let mut seed = 0x5EED_1234_5678_9ABC;
|
||||||
|
let vectors: Vec<Vec<f32>> = (0..n).map(|_| make_vector(&mut seed, dim)).collect();
|
||||||
|
let queries: Vec<Vec<f32>> = (0..20).map(|_| make_vector(&mut seed, dim)).collect();
|
||||||
|
|
||||||
|
let build = |dir: &TempDir, quantized: bool| {
|
||||||
|
let mut config = MemoryConfig::new(dir.path().join("mem.h5"), "agent", dim);
|
||||||
|
config.quantized_index = quantized;
|
||||||
|
let mut mem = HDF5Memory::create(config).unwrap();
|
||||||
|
for (i, v) in vectors.iter().enumerate() {
|
||||||
|
mem.save(entry(&format!("chunk {i}"), v.clone(), &format!("k{i}")))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
mem
|
||||||
|
};
|
||||||
|
|
||||||
|
let exact_dir = TempDir::new().unwrap();
|
||||||
|
let quant_dir = TempDir::new().unwrap();
|
||||||
|
let mut exact = build(&exact_dir, false);
|
||||||
|
let mut quantized = build(&quant_dir, true);
|
||||||
|
|
||||||
|
let k = 10;
|
||||||
|
let mut agree = 0;
|
||||||
|
for q in &queries {
|
||||||
|
let want: Vec<usize> = exact
|
||||||
|
.hybrid_search(q, "", 1.0, 0.0, k)
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.index)
|
||||||
|
.collect();
|
||||||
|
agree += quantized
|
||||||
|
.hybrid_search(q, "", 1.0, 0.0, k)
|
||||||
|
.iter()
|
||||||
|
.filter(|r| want.contains(&r.index))
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
let overlap = agree as f64 / (k * queries.len()) as f64;
|
||||||
|
assert!(
|
||||||
|
overlap >= 0.95,
|
||||||
|
"quantised store should match the f32 one: {overlap}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quantized_index_setting_survives_a_reopen() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("mem.h5");
|
||||||
|
let mut config = MemoryConfig::new(path.clone(), "agent", 8);
|
||||||
|
config.quantized_index = true;
|
||||||
|
let mut mem = HDF5Memory::create(config).unwrap();
|
||||||
|
let mut seed = 7;
|
||||||
|
for i in 0..30 {
|
||||||
|
mem.save(entry(&format!("c{i}"), make_vector(&mut seed, 8), "t"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
mem.flush_wal().unwrap();
|
||||||
|
drop(mem);
|
||||||
|
|
||||||
|
// Reopening must not silently quadruple the index's memory, so the flag
|
||||||
|
// is part of the stored config rather than a per-session choice.
|
||||||
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
|
assert!(reopened.config().quantized_index);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-android"
|
name = "clawhdf5-android"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Android JNI bridge for edgehdf5-memory HDF5 backend"
|
description = "Android JNI bridge for edgehdf5-memory HDF5 backend"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-ann"
|
name = "clawhdf5-ann"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "HNSW approximate nearest neighbor index stored as HDF5"
|
description = "HNSW approximate nearest neighbor index stored as HDF5"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -10,9 +10,9 @@ keywords = ["hdf5", "ann", "hnsw", "nearest-neighbor"]
|
|||||||
categories = ["algorithms", "science"]
|
categories = ["algorithms", "science"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.5.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
||||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.5.0" }
|
clawhdf5-io = { path = "../clawhdf5-io", version = "2.6.0" }
|
||||||
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.5.0" }
|
clawhdf5-accel = { path = "../clawhdf5-accel", version = "2.6.0" }
|
||||||
rayon = { version = "1", optional = true }
|
rayon = { version = "1", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
+431
-62
@@ -154,6 +154,237 @@ impl Ord for FarCandidate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How the index keeps its copy of the vectors.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum Storage {
|
||||||
|
/// Exactly as given: `dim * 4` bytes per vector.
|
||||||
|
#[default]
|
||||||
|
Float32,
|
||||||
|
/// Each component scaled to an `i8`: `dim` bytes per vector, a quarter of
|
||||||
|
/// the space, at some cost in precision.
|
||||||
|
///
|
||||||
|
/// Only meaningful for [`DistanceMetric::Cosine`]: rows are stored
|
||||||
|
/// unit-length, so a quantised dot product reconstructs the similarity
|
||||||
|
/// directly. Requesting it for `L2` keeps `Float32`, because an L2
|
||||||
|
/// distance cannot be recovered from a dot product alone.
|
||||||
|
Int8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The index's copy of the vectors, flat and row-major.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Vectors {
|
||||||
|
F32 {
|
||||||
|
dim: usize,
|
||||||
|
flat: Vec<f32>,
|
||||||
|
},
|
||||||
|
/// `flat[i * dim + j]` is component `j` of vector `i` divided by
|
||||||
|
/// `scales[i]`; multiplying back recovers it.
|
||||||
|
///
|
||||||
|
/// The scale is per row rather than global. A unit-length row in `d`
|
||||||
|
/// dimensions has components around `1/sqrt(d)`, so a fixed `[-1, 1]`
|
||||||
|
/// scale spends fewer than 12 of the 255 levels on a 128-dimensional
|
||||||
|
/// vector and the reconstruction error swamps the gaps between near
|
||||||
|
/// neighbours — measured at 0.35 top-10 overlap with the exact ranking.
|
||||||
|
/// Scaling each row by its own largest component uses the full range.
|
||||||
|
Int8 {
|
||||||
|
dim: usize,
|
||||||
|
flat: Vec<i8>,
|
||||||
|
scales: Vec<f32>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Levels either side of zero. 127, not 128, so the range is symmetric.
|
||||||
|
const INT8_LEVELS: f32 = 127.0;
|
||||||
|
|
||||||
|
/// Quantise one row, returning the codes and the scale that inverts them.
|
||||||
|
fn quantise_row(v: &[f32], out: &mut Vec<i8>) -> f32 {
|
||||||
|
let max_abs = v.iter().fold(0.0f32, |m, x| m.max(x.abs()));
|
||||||
|
if max_abs <= f32::MIN_POSITIVE {
|
||||||
|
out.extend(core::iter::repeat_n(0i8, v.len()));
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let inv = INT8_LEVELS / max_abs;
|
||||||
|
out.extend(
|
||||||
|
v.iter()
|
||||||
|
.map(|x| (x * inv).round().clamp(-INT8_LEVELS, INT8_LEVELS) as i8),
|
||||||
|
);
|
||||||
|
max_abs / INT8_LEVELS
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vectors {
|
||||||
|
fn new(dim: usize, storage: Storage, metric: DistanceMetric) -> Self {
|
||||||
|
match storage {
|
||||||
|
Storage::Int8 if metric == DistanceMetric::Cosine => Vectors::Int8 {
|
||||||
|
dim,
|
||||||
|
flat: Vec::new(),
|
||||||
|
scales: Vec::new(),
|
||||||
|
},
|
||||||
|
_ => Vectors::F32 {
|
||||||
|
dim,
|
||||||
|
flat: Vec::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dim(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Vectors::F32 { dim, .. } | Vectors::Int8 { dim, .. } => *dim,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn storage(&self) -> Storage {
|
||||||
|
match self {
|
||||||
|
Vectors::F32 { .. } => Storage::Float32,
|
||||||
|
Vectors::Int8 { .. } => Storage::Int8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
let dim = self.dim();
|
||||||
|
if dim == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
match self {
|
||||||
|
Vectors::F32 { flat, .. } => flat.len() / dim,
|
||||||
|
Vectors::Int8 { flat, .. } => flat.len() / dim,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the row width, for a store seeded empty by `new`.
|
||||||
|
fn set_dim(&mut self, new_dim: usize) {
|
||||||
|
match self {
|
||||||
|
Vectors::F32 { dim, .. } | Vectors::Int8 { dim, .. } => *dim = new_dim,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, vector: &[f32]) {
|
||||||
|
match self {
|
||||||
|
Vectors::F32 { flat, .. } => flat.extend_from_slice(vector),
|
||||||
|
Vectors::Int8 { flat, scales, .. } => scales.push(quantise_row(vector, flat)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row `i` as `f32`, for callers that need the values back (serialization,
|
||||||
|
/// and the f32 fast paths). Quantised rows are reconstructed, so this is
|
||||||
|
/// lossy in exactly the way the storage is.
|
||||||
|
fn row(&self, i: usize) -> Vec<f32> {
|
||||||
|
let dim = self.dim();
|
||||||
|
let start = i * dim;
|
||||||
|
match self {
|
||||||
|
Vectors::F32 { flat, .. } => flat[start..start + dim].to_vec(),
|
||||||
|
Vectors::Int8 { flat, scales, .. } => flat[start..start + dim]
|
||||||
|
.iter()
|
||||||
|
.map(|&q| f32::from(q) * scales[i])
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distance between two stored vectors.
|
||||||
|
fn dist(&self, a: usize, b: usize, metric: DistanceMetric) -> f32 {
|
||||||
|
let dim = self.dim();
|
||||||
|
match self {
|
||||||
|
Vectors::F32 { flat, .. } => {
|
||||||
|
let (x, y) = (a * dim, b * dim);
|
||||||
|
compute_distance(&flat[x..x + dim], &flat[y..y + dim], metric)
|
||||||
|
}
|
||||||
|
Vectors::Int8 { flat, scales, .. } => {
|
||||||
|
let (x, y) = (a * dim, b * dim);
|
||||||
|
let dot = dot_i8(&flat[x..x + dim], &flat[y..y + dim]);
|
||||||
|
1.0 - dot as f32 * scales[a] * scales[b]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distance from a prepared query to stored vector `i`.
|
||||||
|
fn dist_query(&self, query: &Query, i: usize, metric: DistanceMetric) -> f32 {
|
||||||
|
let dim = self.dim();
|
||||||
|
let start = i * dim;
|
||||||
|
match (self, query) {
|
||||||
|
(Vectors::F32 { flat, .. }, Query::F32(q)) => {
|
||||||
|
compute_distance(q, &flat[start..start + dim], metric)
|
||||||
|
}
|
||||||
|
(Vectors::Int8 { flat, scales, .. }, Query::Int8(q, q_scale)) => {
|
||||||
|
let dot = dot_i8(q, &flat[start..start + dim]);
|
||||||
|
1.0 - dot as f32 * q_scale * scales[i]
|
||||||
|
}
|
||||||
|
// Mixed forms cannot occur: `Query` is built from the same storage.
|
||||||
|
_ => f32::MAX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a store from prepared rows.
|
||||||
|
fn from_rows(rows: &[Vec<f32>], storage: Storage, metric: DistanceMetric) -> Self {
|
||||||
|
let dim = rows.first().map_or(0, Vec::len);
|
||||||
|
let mut out = Vectors::new(dim, storage, metric);
|
||||||
|
for row in rows {
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepare `query` for comparison against this store.
|
||||||
|
fn query(&self, query: Vec<f32>) -> Query {
|
||||||
|
match self {
|
||||||
|
Vectors::F32 { .. } => Query::F32(query),
|
||||||
|
Vectors::Int8 { .. } => {
|
||||||
|
let mut codes = Vec::with_capacity(query.len());
|
||||||
|
let scale = quantise_row(&query, &mut codes);
|
||||||
|
Query::Int8(codes, scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a layer search is measuring distance *to*: an incoming query, or a
|
||||||
|
/// node already in the index (which is what insertion compares against).
|
||||||
|
enum Target<'a> {
|
||||||
|
Query(&'a Query),
|
||||||
|
Node(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vectors {
|
||||||
|
fn dist_to(&self, target: &Target<'_>, i: usize, metric: DistanceMetric) -> f32 {
|
||||||
|
match target {
|
||||||
|
Target::Query(q) => self.dist_query(q, i, metric),
|
||||||
|
Target::Node(n) => self.dist(*n, i, metric),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A search query in whichever form the store compares against.
|
||||||
|
enum Query {
|
||||||
|
F32(Vec<f32>),
|
||||||
|
/// Codes and the scale that inverts them, as in [`Vectors::Int8`].
|
||||||
|
Int8(Vec<i8>, f32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sum of products, widened so it cannot overflow: `dim` terms of at most
|
||||||
|
/// `127 * 127`, so `i32` suffices for any realistic dimension.
|
||||||
|
fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
/// Magic for [`HnswIndex::graph_to_bytes`].
|
/// Magic for [`HnswIndex::graph_to_bytes`].
|
||||||
const GRAPH_MAGIC: &[u8; 4] = b"CHG1";
|
const GRAPH_MAGIC: &[u8; 4] = b"CHG1";
|
||||||
|
|
||||||
@@ -174,8 +405,8 @@ pub const HNSW_FORMAT_VERSION: i64 = 2;
|
|||||||
/// HDF5 format.
|
/// HDF5 format.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct HnswIndex {
|
pub struct HnswIndex {
|
||||||
/// All vectors in the index.
|
/// All vectors in the index, flat and row-major.
|
||||||
vectors: Vec<Vec<f32>>,
|
vectors: Vectors,
|
||||||
/// Adjacency lists per layer. `graph[layer][node]` = list of neighbor IDs.
|
/// Adjacency lists per layer. `graph[layer][node]` = list of neighbor IDs.
|
||||||
graph: Vec<Vec<Vec<usize>>>,
|
graph: Vec<Vec<Vec<usize>>>,
|
||||||
/// Soft-deletion flags, one per node. Deleted nodes remain in the graph for
|
/// Soft-deletion flags, one per node. Deleted nodes remain in the graph for
|
||||||
@@ -214,6 +445,20 @@ impl HnswIndex {
|
|||||||
m: usize,
|
m: usize,
|
||||||
ef_construction: usize,
|
ef_construction: usize,
|
||||||
metric: DistanceMetric,
|
metric: DistanceMetric,
|
||||||
|
) -> Self {
|
||||||
|
Self::build_with(vectors, m, ef_construction, metric, Storage::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an index, choosing how the vectors are stored.
|
||||||
|
///
|
||||||
|
/// [`Storage::Int8`] keeps them at a quarter of the size; see its docs for
|
||||||
|
/// what that costs and when it applies.
|
||||||
|
pub fn build_with(
|
||||||
|
vectors: &[Vec<f32>],
|
||||||
|
m: usize,
|
||||||
|
ef_construction: usize,
|
||||||
|
metric: DistanceMetric,
|
||||||
|
storage: Storage,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
assert!(!vectors.is_empty(), "cannot build index from empty vectors");
|
assert!(!vectors.is_empty(), "cannot build index from empty vectors");
|
||||||
assert!(m >= 2, "m must be at least 2");
|
assert!(m >= 2, "m must be at least 2");
|
||||||
@@ -224,8 +469,11 @@ impl HnswIndex {
|
|||||||
|
|
||||||
let m_max0 = m * 2;
|
let m_max0 = m * 2;
|
||||||
let n = vectors.len();
|
let n = vectors.len();
|
||||||
let prepared: Vec<Vec<f32>> = vectors.iter().map(|v| prepare(v.clone(), metric)).collect();
|
let mut prepared = Vectors::new(dim, storage, metric);
|
||||||
let vectors: &[Vec<f32>] = &prepared;
|
for v in vectors {
|
||||||
|
prepared.push(&prepare(v.clone(), metric));
|
||||||
|
}
|
||||||
|
let vectors = &prepared;
|
||||||
|
|
||||||
// Assign levels to all nodes
|
// Assign levels to all nodes
|
||||||
let mut node_levels = Vec::with_capacity(n);
|
let mut node_levels = Vec::with_capacity(n);
|
||||||
@@ -322,9 +570,20 @@ impl HnswIndex {
|
|||||||
/// point for incremental [`HnswIndex::insert`] and as the result of
|
/// point for incremental [`HnswIndex::insert`] and as the result of
|
||||||
/// [`HnswIndex::compact`] when every vector has been deleted.
|
/// [`HnswIndex::compact`] when every vector has been deleted.
|
||||||
pub fn new(m: usize, ef_construction: usize, metric: DistanceMetric) -> Self {
|
pub fn new(m: usize, ef_construction: usize, metric: DistanceMetric) -> Self {
|
||||||
|
Self::new_with(m, ef_construction, metric, Storage::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`HnswIndex::new`], choosing how the vectors are stored.
|
||||||
|
pub fn new_with(
|
||||||
|
m: usize,
|
||||||
|
ef_construction: usize,
|
||||||
|
metric: DistanceMetric,
|
||||||
|
storage: Storage,
|
||||||
|
) -> Self {
|
||||||
assert!(m >= 2, "m must be at least 2");
|
assert!(m >= 2, "m must be at least 2");
|
||||||
Self {
|
Self {
|
||||||
vectors: Vec::new(),
|
// The dimension is set by the first insert.
|
||||||
|
vectors: Vectors::new(0, storage, metric),
|
||||||
graph: Vec::new(),
|
graph: Vec::new(),
|
||||||
deleted: Vec::new(),
|
deleted: Vec::new(),
|
||||||
entry_point: 0,
|
entry_point: 0,
|
||||||
@@ -351,7 +610,8 @@ impl HnswIndex {
|
|||||||
// Seed an empty index.
|
// Seed an empty index.
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
let node_level = assign_level(0, self.m);
|
let node_level = assign_level(0, self.m);
|
||||||
self.vectors.push(vector);
|
self.vectors.set_dim(vector.len());
|
||||||
|
self.vectors.push(&vector);
|
||||||
self.deleted.push(false);
|
self.deleted.push(false);
|
||||||
self.node_levels.push(node_level);
|
self.node_levels.push(node_level);
|
||||||
self.graph = (0..=node_level).map(|_| vec![Vec::new(); 1]).collect();
|
self.graph = (0..=node_level).map(|_| vec![Vec::new(); 1]).collect();
|
||||||
@@ -361,12 +621,12 @@ impl HnswIndex {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
vector.len(),
|
vector.len(),
|
||||||
self.vectors[0].len(),
|
self.vectors.dim(),
|
||||||
"insert dimension mismatch"
|
"insert dimension mismatch"
|
||||||
);
|
);
|
||||||
|
|
||||||
let node_level = assign_level(id, self.m);
|
let node_level = assign_level(id, self.m);
|
||||||
self.vectors.push(vector);
|
self.vectors.push(&vector);
|
||||||
self.deleted.push(false);
|
self.deleted.push(false);
|
||||||
self.node_levels.push(node_level);
|
self.node_levels.push(node_level);
|
||||||
|
|
||||||
@@ -387,7 +647,7 @@ impl HnswIndex {
|
|||||||
ep = greedy_closest(
|
ep = greedy_closest(
|
||||||
&self.vectors,
|
&self.vectors,
|
||||||
&self.graph[layer],
|
&self.graph[layer],
|
||||||
&self.vectors[id],
|
&Target::Node(id),
|
||||||
ep,
|
ep,
|
||||||
self.metric,
|
self.metric,
|
||||||
);
|
);
|
||||||
@@ -400,7 +660,7 @@ impl HnswIndex {
|
|||||||
let neighbors = search_layer(
|
let neighbors = search_layer(
|
||||||
&self.vectors,
|
&self.vectors,
|
||||||
&self.graph[layer],
|
&self.graph[layer],
|
||||||
&self.vectors[id],
|
&Target::Node(id),
|
||||||
ep,
|
ep,
|
||||||
self.ef_construction,
|
self.ef_construction,
|
||||||
self.metric,
|
self.metric,
|
||||||
@@ -466,16 +726,25 @@ impl HnswIndex {
|
|||||||
pub fn compact(&mut self) -> Vec<Option<usize>> {
|
pub fn compact(&mut self) -> Vec<Option<usize>> {
|
||||||
let mut mapping = vec![None; self.vectors.len()];
|
let mut mapping = vec![None; self.vectors.len()];
|
||||||
let mut surviving: Vec<Vec<f32>> = Vec::with_capacity(self.active_len());
|
let mut surviving: Vec<Vec<f32>> = Vec::with_capacity(self.active_len());
|
||||||
for (old, v) in self.vectors.iter().enumerate() {
|
for (old, slot) in mapping.iter_mut().enumerate() {
|
||||||
if !self.deleted[old] {
|
if !self.deleted[old] {
|
||||||
mapping[old] = Some(surviving.len());
|
*slot = Some(surviving.len());
|
||||||
surviving.push(v.clone());
|
surviving.push(self.vectors.row(old));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Rebuilding must keep the storage the caller chose; a compaction is
|
||||||
|
// not the place to silently quadruple the index's memory.
|
||||||
|
let storage = self.vectors.storage();
|
||||||
*self = if surviving.is_empty() {
|
*self = if surviving.is_empty() {
|
||||||
Self::new(self.m, self.ef_construction, self.metric)
|
Self::new_with(self.m, self.ef_construction, self.metric, storage)
|
||||||
} else {
|
} else {
|
||||||
Self::build_with_metric(&surviving, self.m, self.ef_construction, self.metric)
|
Self::build_with(
|
||||||
|
&surviving,
|
||||||
|
self.m,
|
||||||
|
self.ef_construction,
|
||||||
|
self.metric,
|
||||||
|
storage,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
mapping
|
mapping
|
||||||
}
|
}
|
||||||
@@ -490,24 +759,22 @@ impl HnswIndex {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// A vector of `(id, distance)` pairs sorted by distance (closest first).
|
/// A vector of `(id, distance)` pairs sorted by distance (closest first).
|
||||||
pub fn search(&self, query: &[f32], k: usize, ef: usize) -> Vec<(usize, f32)> {
|
pub fn search(&self, query: &[f32], k: usize, ef: usize) -> Vec<(usize, f32)> {
|
||||||
if self.vectors.is_empty() {
|
if self.vectors.len() == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(query.len(), self.vectors.dim(), "query dimension mismatch");
|
||||||
query.len(),
|
|
||||||
self.vectors[0].len(),
|
|
||||||
"query dimension mismatch"
|
|
||||||
);
|
|
||||||
let ef = ef.max(k);
|
let ef = ef.max(k);
|
||||||
let prepared_query = prepare(query.to_vec(), self.metric);
|
// Prepared and, for a quantised store, quantised once per search
|
||||||
let query = prepared_query.as_slice();
|
// rather than once per comparison.
|
||||||
|
let prepared = self.vectors.query(prepare(query.to_vec(), self.metric));
|
||||||
|
let target = Target::Query(&prepared);
|
||||||
|
|
||||||
let mut ep = self.entry_point;
|
let mut ep = self.entry_point;
|
||||||
let top_layer = self.graph.len().saturating_sub(1);
|
let top_layer = self.graph.len().saturating_sub(1);
|
||||||
|
|
||||||
// Greedy search from top layer down to layer 1
|
// Greedy search from top layer down to layer 1
|
||||||
for layer in (1..=top_layer).rev() {
|
for layer in (1..=top_layer).rev() {
|
||||||
ep = greedy_closest(&self.vectors, &self.graph[layer], query, ep, self.metric);
|
ep = greedy_closest(&self.vectors, &self.graph[layer], &target, ep, self.metric);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search layer 0 for the ef nearest *live* nodes. Deleted nodes are
|
// Search layer 0 for the ef nearest *live* nodes. Deleted nodes are
|
||||||
@@ -516,7 +783,7 @@ impl HnswIndex {
|
|||||||
let candidates = search_layer(
|
let candidates = search_layer(
|
||||||
&self.vectors,
|
&self.vectors,
|
||||||
&self.graph[0],
|
&self.graph[0],
|
||||||
query,
|
&target,
|
||||||
ep,
|
ep,
|
||||||
ef,
|
ef,
|
||||||
self.metric,
|
self.metric,
|
||||||
@@ -543,14 +810,13 @@ impl HnswIndex {
|
|||||||
pub fn to_hdf5_bytes(&self) -> Result<Vec<u8>, FormatError> {
|
pub fn to_hdf5_bytes(&self) -> Result<Vec<u8>, FormatError> {
|
||||||
let mut fw = FmtWriter::new();
|
let mut fw = FmtWriter::new();
|
||||||
let n = self.vectors.len();
|
let n = self.vectors.len();
|
||||||
let dim = if n > 0 { self.vectors[0].len() } else { 0 };
|
let dim = self.vectors.dim();
|
||||||
|
|
||||||
// Flatten vectors into a 1D array for storage
|
// Flatten vectors into a 1D array for storage
|
||||||
let flat_vectors: Vec<f32> = self
|
let mut flat_vectors: Vec<f32> = Vec::with_capacity(n * dim);
|
||||||
.vectors
|
for i in 0..n {
|
||||||
.iter()
|
flat_vectors.extend_from_slice(&self.vectors.row(i));
|
||||||
.flat_map(|v| v.iter().copied())
|
}
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut group = fw.create_group("ann");
|
let mut group = fw.create_group("ann");
|
||||||
|
|
||||||
@@ -709,7 +975,9 @@ impl HnswIndex {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
vectors,
|
// Serialized files carry f32 vectors and no storage tag: a
|
||||||
|
// quantised index is rebuilt, not loaded.
|
||||||
|
vectors: Vectors::from_rows(&vectors, Storage::Float32, metric),
|
||||||
graph,
|
graph,
|
||||||
deleted,
|
deleted,
|
||||||
entry_point,
|
entry_point,
|
||||||
@@ -773,6 +1041,16 @@ impl HnswIndex {
|
|||||||
/// `bytes` is validated — a corrupt or mismatched graph is an error, never
|
/// `bytes` is validated — a corrupt or mismatched graph is an error, never
|
||||||
/// an index that panics or walks out of bounds during a search.
|
/// an index that panics or walks out of bounds during a search.
|
||||||
pub fn from_graph_bytes(bytes: &[u8], vectors: Vec<Vec<f32>>) -> Result<Self, FormatError> {
|
pub fn from_graph_bytes(bytes: &[u8], vectors: Vec<Vec<f32>>) -> Result<Self, FormatError> {
|
||||||
|
Self::from_graph_bytes_with(bytes, vectors, Storage::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As [`from_graph_bytes`](Self::from_graph_bytes), choosing how the
|
||||||
|
/// rehydrated vectors are stored.
|
||||||
|
pub fn from_graph_bytes_with(
|
||||||
|
bytes: &[u8],
|
||||||
|
vectors: Vec<Vec<f32>>,
|
||||||
|
storage: Storage,
|
||||||
|
) -> Result<Self, FormatError> {
|
||||||
let bad = |what: &str| FormatError::SerializationError(format!("HNSW graph: {what}"));
|
let bad = |what: &str| FormatError::SerializationError(format!("HNSW graph: {what}"));
|
||||||
let body_len = bytes
|
let body_len = bytes
|
||||||
.len()
|
.len()
|
||||||
@@ -863,7 +1141,14 @@ impl HnswIndex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
vectors: vectors.into_iter().map(|v| prepare(v, metric)).collect(),
|
vectors: Vectors::from_rows(
|
||||||
|
&vectors
|
||||||
|
.into_iter()
|
||||||
|
.map(|v| prepare(v, metric))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
storage,
|
||||||
|
metric,
|
||||||
|
),
|
||||||
graph,
|
graph,
|
||||||
deleted,
|
deleted,
|
||||||
entry_point,
|
entry_point,
|
||||||
@@ -882,16 +1167,17 @@ impl HnswIndex {
|
|||||||
|
|
||||||
/// Returns true if the index is empty.
|
/// Returns true if the index is empty.
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.vectors.is_empty()
|
self.vectors.len() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How this index stores its copy of the vectors.
|
||||||
|
pub fn storage(&self) -> Storage {
|
||||||
|
self.vectors.storage()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the dimension of vectors in the index.
|
/// Returns the dimension of vectors in the index.
|
||||||
pub fn dimension(&self) -> usize {
|
pub fn dimension(&self) -> usize {
|
||||||
if self.vectors.is_empty() {
|
self.vectors.dim()
|
||||||
0
|
|
||||||
} else {
|
|
||||||
self.vectors[0].len()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the number of layers in the graph.
|
/// Returns the number of layers in the graph.
|
||||||
@@ -916,17 +1202,17 @@ impl HnswIndex {
|
|||||||
|
|
||||||
/// Greedy search: find the single closest node to `query` starting from `ep`.
|
/// Greedy search: find the single closest node to `query` starting from `ep`.
|
||||||
fn greedy_closest(
|
fn greedy_closest(
|
||||||
vectors: &[Vec<f32>],
|
vectors: &Vectors,
|
||||||
layer: &[Vec<usize>],
|
layer: &[Vec<usize>],
|
||||||
query: &[f32],
|
target: &Target<'_>,
|
||||||
mut ep: usize,
|
mut ep: usize,
|
||||||
metric: DistanceMetric,
|
metric: DistanceMetric,
|
||||||
) -> usize {
|
) -> usize {
|
||||||
let mut best_dist = compute_distance(query, &vectors[ep], metric);
|
let mut best_dist = vectors.dist_to(target, ep, metric);
|
||||||
loop {
|
loop {
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
for &neighbor in &layer[ep] {
|
for &neighbor in &layer[ep] {
|
||||||
let d = compute_distance(query, &vectors[neighbor], metric);
|
let d = vectors.dist_to(target, neighbor, metric);
|
||||||
if d < best_dist {
|
if d < best_dist {
|
||||||
best_dist = d;
|
best_dist = d;
|
||||||
ep = neighbor;
|
ep = neighbor;
|
||||||
@@ -950,15 +1236,15 @@ fn greedy_closest(
|
|||||||
/// instead meant a query whose neighbourhood had been deleted got back fewer
|
/// instead meant a query whose neighbourhood had been deleted got back fewer
|
||||||
/// than `k` results, or none, however many live records were nearby.
|
/// than `k` results, or none, however many live records were nearby.
|
||||||
fn search_layer(
|
fn search_layer(
|
||||||
vectors: &[Vec<f32>],
|
vectors: &Vectors,
|
||||||
layer: &[Vec<usize>],
|
layer: &[Vec<usize>],
|
||||||
query: &[f32],
|
target: &Target<'_>,
|
||||||
ep: usize,
|
ep: usize,
|
||||||
ef: usize,
|
ef: usize,
|
||||||
metric: DistanceMetric,
|
metric: DistanceMetric,
|
||||||
skip: Option<&[bool]>,
|
skip: Option<&[bool]>,
|
||||||
) -> Vec<Candidate> {
|
) -> Vec<Candidate> {
|
||||||
let ep_dist = compute_distance(query, &vectors[ep], metric);
|
let ep_dist = vectors.dist_to(target, ep, metric);
|
||||||
|
|
||||||
// Min-heap of candidates to explore
|
// Min-heap of candidates to explore
|
||||||
let mut candidates = BinaryHeap::new();
|
let mut candidates = BinaryHeap::new();
|
||||||
@@ -980,7 +1266,7 @@ fn search_layer(
|
|||||||
visited.begin(vectors.len());
|
visited.begin(vectors.len());
|
||||||
visited.insert(ep);
|
visited.insert(ep);
|
||||||
search_layer_visit(
|
search_layer_visit(
|
||||||
vectors, layer, query, ef, metric, skip, visited, candidates, results,
|
vectors, layer, target, ef, metric, skip, visited, candidates, results,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1023,9 +1309,9 @@ thread_local! {
|
|||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn search_layer_visit(
|
fn search_layer_visit(
|
||||||
vectors: &[Vec<f32>],
|
vectors: &Vectors,
|
||||||
layer: &[Vec<usize>],
|
layer: &[Vec<usize>],
|
||||||
query: &[f32],
|
target: &Target<'_>,
|
||||||
ef: usize,
|
ef: usize,
|
||||||
metric: DistanceMetric,
|
metric: DistanceMetric,
|
||||||
skip: Option<&[bool]>,
|
skip: Option<&[bool]>,
|
||||||
@@ -1044,7 +1330,7 @@ fn search_layer_visit(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let d = compute_distance(query, &vectors[neighbor], metric);
|
let d = vectors.dist_to(target, neighbor, metric);
|
||||||
let furthest_dist = results.peek().map_or(f32::MAX, |f| f.distance);
|
let furthest_dist = results.peek().map_or(f32::MAX, |f| f.distance);
|
||||||
|
|
||||||
if d < furthest_dist || results.len() < ef {
|
if d < furthest_dist || results.len() < ef {
|
||||||
@@ -1095,7 +1381,7 @@ fn search_layer_visit(
|
|||||||
/// remaining slots are then filled with the closest rejected candidates, so a
|
/// remaining slots are then filled with the closest rejected candidates, so a
|
||||||
/// node is never left under-connected.
|
/// node is never left under-connected.
|
||||||
fn select_neighbors(
|
fn select_neighbors(
|
||||||
vectors: &[Vec<f32>],
|
vectors: &Vectors,
|
||||||
candidates: &[(usize, f32)],
|
candidates: &[(usize, f32)],
|
||||||
max_conn: usize,
|
max_conn: usize,
|
||||||
metric: DistanceMetric,
|
metric: DistanceMetric,
|
||||||
@@ -1111,7 +1397,7 @@ fn select_neighbors(
|
|||||||
}
|
}
|
||||||
let diverse = selected
|
let diverse = selected
|
||||||
.iter()
|
.iter()
|
||||||
.all(|&s| compute_distance(&vectors[id], &vectors[s], metric) > dist_to_node);
|
.all(|&s| vectors.dist(id, s, metric) > dist_to_node);
|
||||||
if diverse {
|
if diverse {
|
||||||
selected.push(id);
|
selected.push(id);
|
||||||
} else {
|
} else {
|
||||||
@@ -1136,7 +1422,7 @@ fn batch_len(linked: usize) -> usize {
|
|||||||
/// layers, found by searching the graph as it currently stands.
|
/// layers, found by searching the graph as it currently stands.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn plan_batch(
|
fn plan_batch(
|
||||||
vectors: &[Vec<f32>],
|
vectors: &Vectors,
|
||||||
graph: &[Vec<Vec<usize>>],
|
graph: &[Vec<Vec<usize>>],
|
||||||
node_levels: &[usize],
|
node_levels: &[usize],
|
||||||
batch: std::ops::Range<usize>,
|
batch: std::ops::Range<usize>,
|
||||||
@@ -1150,7 +1436,7 @@ fn plan_batch(
|
|||||||
let mut ep = entry_point;
|
let mut ep = entry_point;
|
||||||
// Phase 1: greedy descent from the top layer down to node_level + 1.
|
// Phase 1: greedy descent from the top layer down to node_level + 1.
|
||||||
for layer in (node_level + 1..=ep_level).rev() {
|
for layer in (node_level + 1..=ep_level).rev() {
|
||||||
ep = greedy_closest(vectors, &graph[layer], &vectors[i], ep, metric);
|
ep = greedy_closest(vectors, &graph[layer], &Target::Node(i), ep, metric);
|
||||||
}
|
}
|
||||||
// Phase 2: search and select on every layer the node lives on.
|
// Phase 2: search and select on every layer the node lives on.
|
||||||
let mut plan = Vec::with_capacity(node_level.min(ep_level) + 1);
|
let mut plan = Vec::with_capacity(node_level.min(ep_level) + 1);
|
||||||
@@ -1159,7 +1445,7 @@ fn plan_batch(
|
|||||||
let neighbors = search_layer(
|
let neighbors = search_layer(
|
||||||
vectors,
|
vectors,
|
||||||
&graph[layer],
|
&graph[layer],
|
||||||
&vectors[i],
|
&Target::Node(i),
|
||||||
ep,
|
ep,
|
||||||
ef_construction,
|
ef_construction,
|
||||||
metric,
|
metric,
|
||||||
@@ -1186,7 +1472,7 @@ fn plan_batch(
|
|||||||
/// Prune every `(layer, node)` neighbour list in `overflowed` back to its
|
/// Prune every `(layer, node)` neighbour list in `overflowed` back to its
|
||||||
/// limit. Each list belongs to a different node, so they are independent.
|
/// limit. Each list belongs to a different node, so they are independent.
|
||||||
fn prune_overflowed(
|
fn prune_overflowed(
|
||||||
vectors: &[Vec<f32>],
|
vectors: &Vectors,
|
||||||
graph: &mut [Vec<Vec<usize>>],
|
graph: &mut [Vec<Vec<usize>>],
|
||||||
overflowed: Vec<(usize, usize)>,
|
overflowed: Vec<(usize, usize)>,
|
||||||
(m, m_max0): (usize, usize),
|
(m, m_max0): (usize, usize),
|
||||||
@@ -1226,7 +1512,7 @@ const PARALLEL_MIN: usize = 8;
|
|||||||
/// prunes is too fine-grained to parallelise profitably — measured 1.45x on 16
|
/// prunes is too fine-grained to parallelise profitably — measured 1.45x on 16
|
||||||
/// cores; bulk builds batch their pruning instead, see `prune_overflowed`.)
|
/// cores; bulk builds batch their pruning instead, see `prune_overflowed`.)
|
||||||
fn link_back(
|
fn link_back(
|
||||||
vectors: &[Vec<f32>],
|
vectors: &Vectors,
|
||||||
layer: &mut [Vec<usize>],
|
layer: &mut [Vec<usize>],
|
||||||
new_id: usize,
|
new_id: usize,
|
||||||
selected: &[usize],
|
selected: &[usize],
|
||||||
@@ -1248,7 +1534,7 @@ fn link_back(
|
|||||||
|
|
||||||
/// Trim `node`'s neighbour list back to `max_conn` with [`select_neighbors`].
|
/// Trim `node`'s neighbour list back to `max_conn` with [`select_neighbors`].
|
||||||
fn prune_connections(
|
fn prune_connections(
|
||||||
vectors: &[Vec<f32>],
|
vectors: &Vectors,
|
||||||
neighbors: &mut Vec<usize>,
|
neighbors: &mut Vec<usize>,
|
||||||
node: usize,
|
node: usize,
|
||||||
max_conn: usize,
|
max_conn: usize,
|
||||||
@@ -1259,7 +1545,7 @@ fn prune_connections(
|
|||||||
}
|
}
|
||||||
let mut scored: Vec<(usize, f32)> = neighbors
|
let mut scored: Vec<(usize, f32)> = neighbors
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&n| (n, compute_distance(&vectors[node], &vectors[n], metric)))
|
.map(|&n| (n, vectors.dist(node, n, metric)))
|
||||||
.collect();
|
.collect();
|
||||||
scored.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
|
scored.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
|
||||||
*neighbors = select_neighbors(vectors, &scored, max_conn, metric);
|
*neighbors = select_neighbors(vectors, &scored, max_conn, metric);
|
||||||
@@ -1519,6 +1805,88 @@ mod tests {
|
|||||||
assert!(recall >= 0.95, "incremental recall@10 = {recall}");
|
assert!(recall >= 0.95, "incremental recall@10 = {recall}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn int8_storage_needs_an_exact_re_score_to_match_f32() {
|
||||||
|
// Cosine only: rows are unit-length, so a quantised dot product
|
||||||
|
// reconstructs the similarity directly.
|
||||||
|
//
|
||||||
|
// Not the `clustered` generator: its clusters are far tighter than any
|
||||||
|
// real embedding, so neighbours sit closer together than the
|
||||||
|
// quantisation error and top-10 identity there is noise — that would
|
||||||
|
// measure the fixture, not the storage.
|
||||||
|
let mut vectors = make_random_vectors(3060, 128, 5);
|
||||||
|
let queries = vectors.split_off(3000);
|
||||||
|
let f32_index =
|
||||||
|
HnswIndex::build_with(&vectors, 8, 40, DistanceMetric::Cosine, Storage::Float32);
|
||||||
|
let quantised =
|
||||||
|
HnswIndex::build_with(&vectors, 8, 40, DistanceMetric::Cosine, Storage::Int8);
|
||||||
|
assert_eq!(quantised.storage(), Storage::Int8);
|
||||||
|
|
||||||
|
// Ground truth, not the f32 index's answers: re-scoring can beat that
|
||||||
|
// index, and measuring against it would score being right as drift.
|
||||||
|
let truth: Vec<Vec<usize>> = queries
|
||||||
|
.iter()
|
||||||
|
.map(|q| {
|
||||||
|
let mut d: Vec<(usize, f32)> = vectors
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, v)| (i, compute_distance(q, v, DistanceMetric::Cosine)))
|
||||||
|
.collect();
|
||||||
|
d.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||||
|
d[..10].iter().map(|x| x.0).collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let recall = |got: &dyn Fn(&[f32]) -> Vec<usize>| -> f64 {
|
||||||
|
let mut hits = 0;
|
||||||
|
for (q, want) in queries.iter().zip(&truth) {
|
||||||
|
hits += got(q).iter().filter(|id| want.contains(id)).count();
|
||||||
|
}
|
||||||
|
hits as f64 / (10 * queries.len()) as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
let exact_recall = recall(&|q| f32_index.search(q, 10, 64).iter().map(|r| r.0).collect());
|
||||||
|
let raw_recall = recall(&|q| quantised.search(q, 10, 64).iter().map(|r| r.0).collect());
|
||||||
|
// Quantised distances alone cost recall, and `ef` cannot buy it back:
|
||||||
|
// the loss is in the distances, not in the graph.
|
||||||
|
assert!(
|
||||||
|
raw_recall < exact_recall,
|
||||||
|
"int8 alone should cost recall: {raw_recall} vs {exact_recall}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-scoring a wider candidate pool against the exact vectors — what a
|
||||||
|
// caller holding them (the agent's embedding cache) does — puts it
|
||||||
|
// back, because only the *ordering* was approximate.
|
||||||
|
let rescored_recall = recall(&|q| {
|
||||||
|
let mut pool: Vec<(usize, f32)> = quantised
|
||||||
|
.search(q, 40, 64)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, _)| {
|
||||||
|
(
|
||||||
|
id,
|
||||||
|
compute_distance(q, &vectors[id], DistanceMetric::Cosine),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
pool.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||||
|
pool.truncate(10);
|
||||||
|
pool.into_iter().map(|p| p.0).collect()
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
rescored_recall >= exact_recall - 0.01,
|
||||||
|
"int8 + exact re-score should match f32: {rescored_recall} vs {exact_recall} (raw {raw_recall})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn int8_storage_falls_back_to_f32_for_non_cosine_metrics() {
|
||||||
|
// L2 distance is not recoverable from a quantised dot product, so the
|
||||||
|
// store silently stays f32 rather than returning wrong distances.
|
||||||
|
let vectors = clustered(100, 8, 5, 3);
|
||||||
|
let index = HnswIndex::build_with(&vectors, 8, 40, DistanceMetric::L2, Storage::Int8);
|
||||||
|
assert_eq!(index.storage(), Storage::Float32);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deletions_near_the_query_do_not_shrink_or_degrade_results() {
|
fn deletions_near_the_query_do_not_shrink_or_degrade_results() {
|
||||||
let mut vectors = clustered(2040, 16, 20, 11);
|
let mut vectors = clustered(2040, 16, 20, 11);
|
||||||
@@ -1650,6 +2018,7 @@ mod tests {
|
|||||||
vec![1.2, 0.0], // 3
|
vec![1.2, 0.0], // 3
|
||||||
vec![-2.0, 0.0], // 4
|
vec![-2.0, 0.0], // 4
|
||||||
];
|
];
|
||||||
|
let store = Vectors::from_rows(&vectors, Storage::Float32, DistanceMetric::L2);
|
||||||
let scored: Vec<(usize, f32)> = (1..5)
|
let scored: Vec<(usize, f32)> = (1..5)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
(
|
(
|
||||||
@@ -1659,12 +2028,12 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
select_neighbors(&vectors, &scored, 2, DistanceMetric::L2),
|
select_neighbors(&store, &scored, 2, DistanceMetric::L2),
|
||||||
[1, 4]
|
[1, 4]
|
||||||
);
|
);
|
||||||
// Spare capacity is filled with the closest rejected candidates.
|
// Spare capacity is filled with the closest rejected candidates.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
select_neighbors(&vectors, &scored, 3, DistanceMetric::L2),
|
select_neighbors(&store, &scored, 3, DistanceMetric::L2),
|
||||||
[1, 4, 2]
|
[1, 4, 2]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1790,7 +2159,7 @@ mod tests {
|
|||||||
|
|
||||||
// Verify vectors match
|
// Verify vectors match
|
||||||
for i in 0..loaded.len() {
|
for i in 0..loaded.len() {
|
||||||
assert_eq!(loaded.vectors[i], index.vectors[i]);
|
assert_eq!(loaded.vectors.row(i), index.vectors.row(i));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,4 @@
|
|||||||
|
|
||||||
mod hnsw;
|
mod hnsw;
|
||||||
|
|
||||||
pub use hnsw::{DistanceMetric, HnswIndex};
|
pub use hnsw::{DistanceMetric, HnswIndex, Storage};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-bench"
|
name = "clawhdf5-bench"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Benchmark harnesses for clawhdf5-agent (Track 8)"
|
description = "Benchmark harnesses for clawhdf5-agent (Track 8)"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ mod embedder;
|
|||||||
|
|
||||||
use clawhdf5_agent::bm25::TokenFilter;
|
use clawhdf5_agent::bm25::TokenFilter;
|
||||||
use clawhdf5_agent::hybrid::Fusion;
|
use clawhdf5_agent::hybrid::Fusion;
|
||||||
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
use clawhdf5_agent::reranker::{ReRankConfig, RerankInput, rerank};
|
||||||
|
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry, SearchResult};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
@@ -69,9 +70,17 @@ fn describe(mode: Mode) -> String {
|
|||||||
Fusion::Weighted { vector, keyword } => format!("vector_{vector:.1}_keyword_{keyword:.1}"),
|
Fusion::Weighted { vector, keyword } => format!("vector_{vector:.1}_keyword_{keyword:.1}"),
|
||||||
Fusion::Rrf { k } => format!("rrf_k{k:.0}"),
|
Fusion::Rrf { k } => format!("rrf_k{k:.0}"),
|
||||||
};
|
};
|
||||||
match mode.tokens {
|
let tokens = match mode.tokens {
|
||||||
TokenFilter::Plain => fusion,
|
TokenFilter::Plain => fusion,
|
||||||
TokenFilter::Stemmed => format!("{fusion}_stemmed"),
|
TokenFilter::Stemmed => format!("{fusion}_stemmed"),
|
||||||
|
};
|
||||||
|
match mode.rerank {
|
||||||
|
None => tokens,
|
||||||
|
Some(cfg) if cfg.relevance_weight == 0.0 => format!("{tokens}_rerank_metadata"),
|
||||||
|
Some(cfg) => format!(
|
||||||
|
"{tokens}_rerank_blended_hl{:.0}d",
|
||||||
|
cfg.temporal_half_life_secs / 86_400.0
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +92,9 @@ struct Mode {
|
|||||||
fusion: Fusion,
|
fusion: Fusion,
|
||||||
/// How keyword tokens are normalised before indexing and querying.
|
/// How keyword tokens are normalised before indexing and querying.
|
||||||
tokens: TokenFilter,
|
tokens: TokenFilter,
|
||||||
|
/// Re-rank the retrieved candidates with recency and friends, relative to
|
||||||
|
/// the question's own date.
|
||||||
|
rerank: Option<ReRankConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Mode {
|
impl Mode {
|
||||||
@@ -91,9 +103,17 @@ impl Mode {
|
|||||||
label,
|
label,
|
||||||
fusion: Fusion::Weighted { vector, keyword },
|
fusion: Fusion::Weighted { vector, keyword },
|
||||||
tokens: TokenFilter::Plain,
|
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 {
|
const fn stemmed(mut self, label: &'static str) -> Self {
|
||||||
self.label = label;
|
self.label = label;
|
||||||
self.tokens = TokenFilter::Stemmed;
|
self.tokens = TokenFilter::Stemmed;
|
||||||
@@ -121,11 +141,61 @@ const RRF: Mode = Mode {
|
|||||||
label: "Hybrid (reciprocal rank fusion, k=60)",
|
label: "Hybrid (reciprocal rank fusion, k=60)",
|
||||||
fusion: Fusion::Rrf { k: 60.0 },
|
fusion: Fusion::Rrf { k: 60.0 },
|
||||||
tokens: TokenFilter::Plain,
|
tokens: TokenFilter::Plain,
|
||||||
|
rerank: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The same two configurations with stemmed keyword tokens, so the tokenizer's
|
/// The same two configurations with stemmed keyword tokens, so the tokenizer's
|
||||||
/// effect is isolated from everything else.
|
/// effect is isolated from everything else.
|
||||||
const BM25_STEMMED: Mode = BM25_ONLY.stemmed("BM25 only, stemmed tokens");
|
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")]
|
#[cfg(feature = "embeddings")]
|
||||||
const HYBRID_STEMMED: Mode = HYBRID.stemmed("Hybrid 0.4/0.6, stemmed tokens");
|
const HYBRID_STEMMED: Mode = HYBRID.stemmed("Hybrid 0.4/0.6, stemmed tokens");
|
||||||
|
|
||||||
@@ -217,6 +287,37 @@ struct Question {
|
|||||||
haystack_session_ids: Vec<String>,
|
haystack_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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -235,11 +336,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
|
||||||
}
|
}
|
||||||
@@ -297,6 +408,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,15 +438,21 @@ fn evaluate_question(
|
|||||||
// 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),
|
||||||
@@ -339,7 +466,6 @@ fn evaluate_question(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
turn_has_answer.push(turn.has_answer);
|
turn_has_answer.push(turn.has_answer);
|
||||||
ts += 1.0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,11 +482,87 @@ fn evaluate_question(
|
|||||||
// Set of session IDs that contain the answer
|
// 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_with(&query_emb, &q.question, mode.fusion, top_k);
|
// Re-ranking only reorders; it needs a candidate pool larger than `top_k`
|
||||||
|
// to have anything to promote.
|
||||||
|
let pool = if mode.rerank.is_some() {
|
||||||
|
top_k * 4
|
||||||
|
} else {
|
||||||
|
top_k
|
||||||
|
};
|
||||||
|
let mut results = memory.hybrid_search_with(&query_emb, &q.question, mode.fusion, pool);
|
||||||
|
if let Some(config) = mode.rerank {
|
||||||
|
// "Now" is the moment the question was asked, so decay measures how
|
||||||
|
// stale each memory was at that point.
|
||||||
|
let now = session_time(&q.question_date, q.haystack_sessions.len());
|
||||||
|
let inputs: Vec<RerankInput> = results
|
||||||
|
.iter()
|
||||||
|
.map(|r| RerankInput {
|
||||||
|
index: r.index,
|
||||||
|
timestamp: r.timestamp,
|
||||||
|
source_channel: r.source_channel.clone(),
|
||||||
|
raw_activation: r.activation,
|
||||||
|
relevance: r.score,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let order: Vec<usize> = rerank(&inputs, &config, now)
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.index)
|
||||||
|
.collect();
|
||||||
|
let by_index: HashMap<usize, SearchResult> =
|
||||||
|
results.into_iter().map(|r| (r.index, r)).collect();
|
||||||
|
results = order
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|i| by_index.get(&i).cloned())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
results.truncate(top_k);
|
||||||
let latency = t0.elapsed();
|
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;
|
||||||
@@ -415,6 +617,7 @@ fn evaluate_question(
|
|||||||
hit5_turn,
|
hit5_turn,
|
||||||
hit10_turn,
|
hit10_turn,
|
||||||
rr_turn,
|
rr_turn,
|
||||||
|
newest_gold_first,
|
||||||
latency,
|
latency,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,6 +769,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!(
|
||||||
@@ -679,6 +900,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}",
|
||||||
@@ -701,6 +930,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() {
|
||||||
@@ -709,6 +940,16 @@ fn main() {
|
|||||||
limit = Some(v.parse().expect("--limit must be a positive integer"));
|
limit = Some(v.parse().expect("--limit must be a positive integer"));
|
||||||
}
|
}
|
||||||
"--sweep" => sweep = true,
|
"--sweep" => sweep = true,
|
||||||
|
"--rerank-sweep" => {
|
||||||
|
// Re-ranking needs the vector stage to have candidates worth
|
||||||
|
// reordering, so this is an embeddings-only comparison.
|
||||||
|
#[cfg(feature = "embeddings")]
|
||||||
|
{
|
||||||
|
rerank_sweep = true;
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "embeddings"))]
|
||||||
|
eprintln!("warning: --rerank-sweep needs --features embeddings; ignoring");
|
||||||
|
}
|
||||||
"--embeddings" => {
|
"--embeddings" => {
|
||||||
weights_dir = Some(args.next().expect("--embeddings needs a directory"));
|
weights_dir = Some(args.next().expect("--embeddings needs a directory"));
|
||||||
}
|
}
|
||||||
@@ -727,6 +968,9 @@ fn main() {
|
|||||||
BM25-only, vector-only, and hybrid separately. Requires\n\
|
BM25-only, vector-only, and hybrid separately. Requires\n\
|
||||||
--features embeddings; without it the vector stage is\n\
|
--features embeddings; without it the vector stage is\n\
|
||||||
inert and only the BM25 row is produced.\n\
|
inert and only the BM25 row is produced.\n\
|
||||||
|
--rerank-sweep\n\
|
||||||
|
compare re-ranking off, metadata-only (the old\n\
|
||||||
|
behaviour) and blended at several half-lives.\n\
|
||||||
--sweep instead of the three named modes, sweep vector_weight\n\
|
--sweep instead of the three named modes, sweep vector_weight\n\
|
||||||
from 0.0 to 1.0 in 0.1 steps. The 0.7/0.3 default was\n\
|
from 0.0 to 1.0 in 0.1 steps. The 0.7/0.3 default was\n\
|
||||||
never searched; this is what searches it."
|
never searched; this is what searches it."
|
||||||
@@ -792,6 +1036,10 @@ fn main() {
|
|||||||
{
|
{
|
||||||
if sweep {
|
if sweep {
|
||||||
sweep_modes()
|
sweep_modes()
|
||||||
|
} else if rerank_sweep {
|
||||||
|
let mut modes = vec![HYBRID, hybrid_rerank_metadata_only()];
|
||||||
|
modes.extend(hybrid_rerank_half_lives());
|
||||||
|
modes
|
||||||
} else {
|
} else {
|
||||||
vec![
|
vec![
|
||||||
BM25_ONLY,
|
BM25_ONLY,
|
||||||
@@ -800,6 +1048,8 @@ fn main() {
|
|||||||
RRF,
|
RRF,
|
||||||
BM25_STEMMED,
|
BM25_STEMMED,
|
||||||
HYBRID_STEMMED,
|
HYBRID_STEMMED,
|
||||||
|
hybrid_rerank_metadata_only(),
|
||||||
|
hybrid_rerank_blended(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -916,6 +1166,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);
|
||||||
@@ -927,3 +1185,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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
|
||||||
use clawhdf5_ann::{DistanceMetric, HnswIndex};
|
use clawhdf5_ann::{DistanceMetric, HnswIndex, Storage};
|
||||||
|
|
||||||
const DIM: usize = 384;
|
const DIM: usize = 384;
|
||||||
const K: usize = 10;
|
const K: usize = 10;
|
||||||
@@ -84,6 +84,22 @@ struct Dataset {
|
|||||||
/// that appears only on clustered data points at graph connectivity.
|
/// that appears only on clustered data points at graph connectivity.
|
||||||
static UNIFORM: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
static UNIFORM: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// `--int8`: build the HNSW index over int8-quantised vectors (a quarter of
|
||||||
|
/// the memory) instead of f32, to price the recall it costs.
|
||||||
|
static INT8: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// `--rerank`: re-score the candidate pool against the exact vectors before
|
||||||
|
/// taking the top K.
|
||||||
|
static RERANK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
|
fn storage() -> Storage {
|
||||||
|
if INT8.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
Storage::Int8
|
||||||
|
} else {
|
||||||
|
Storage::Float32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn make_dataset(n: usize, seed: u64) -> Dataset {
|
fn make_dataset(n: usize, seed: u64) -> Dataset {
|
||||||
let mut rng = Rng(seed);
|
let mut rng = Rng(seed);
|
||||||
if UNIFORM.load(std::sync::atomic::Ordering::Relaxed) {
|
if UNIFORM.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
@@ -169,6 +185,11 @@ fn text_for(cluster: usize, i: usize, rng: &mut Rng) -> String {
|
|||||||
// Measurement helpers
|
// Measurement helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Exact cosine distance between unit-length vectors.
|
||||||
|
fn exact_dist(a: &[f32], b: &[f32]) -> f32 {
|
||||||
|
1.0 - a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>()
|
||||||
|
}
|
||||||
|
|
||||||
fn exact_top_k(vectors: &[Vec<f32>], query: &[f32], k: usize) -> Vec<usize> {
|
fn exact_top_k(vectors: &[Vec<f32>], query: &[f32], k: usize) -> Vec<usize> {
|
||||||
// Vectors are unit length, so cosine order == dot-product order.
|
// Vectors are unit length, so cosine order == dot-product order.
|
||||||
let mut scored: Vec<(usize, f32)> = vectors
|
let mut scored: Vec<(usize, f32)> = vectors
|
||||||
@@ -198,6 +219,57 @@ fn summarize(mut samples: Vec<Duration>) -> Latency {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Counts live heap bytes, so a structure's cost can be measured by
|
||||||
|
/// difference.
|
||||||
|
///
|
||||||
|
/// RSS cannot do this from inside one process: freeing a large structure
|
||||||
|
/// returns its pages to the allocator's pool rather than to the OS, so
|
||||||
|
/// allocating the next one shows no change. Measured that way, a store that
|
||||||
|
/// holds the corpus twice and one that holds it once look identical.
|
||||||
|
struct CountingAllocator;
|
||||||
|
|
||||||
|
static LIVE_BYTES: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
|
||||||
|
|
||||||
|
// SAFETY: every method forwards to the system allocator with the same layout
|
||||||
|
// it was given, and only adds bookkeeping around it.
|
||||||
|
unsafe impl std::alloc::GlobalAlloc for CountingAllocator {
|
||||||
|
unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
|
||||||
|
let ptr = unsafe { std::alloc::System.alloc(layout) };
|
||||||
|
if !ptr.is_null() {
|
||||||
|
LIVE_BYTES.fetch_add(layout.size() as i64, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn dealloc(&self, ptr: *mut u8, layout: std::alloc::Layout) {
|
||||||
|
LIVE_BYTES.fetch_sub(layout.size() as i64, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
unsafe { std::alloc::System.dealloc(ptr, layout) }
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn realloc(&self, ptr: *mut u8, layout: std::alloc::Layout, new_size: usize) -> *mut u8 {
|
||||||
|
let new_ptr = unsafe { std::alloc::System.realloc(ptr, layout, new_size) };
|
||||||
|
if !new_ptr.is_null() {
|
||||||
|
LIVE_BYTES.fetch_add(
|
||||||
|
new_size as i64 - layout.size() as i64,
|
||||||
|
std::sync::atomic::Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
new_ptr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[global_allocator]
|
||||||
|
static ALLOCATOR: CountingAllocator = CountingAllocator;
|
||||||
|
|
||||||
|
/// Live heap bytes right now.
|
||||||
|
fn heap_bytes() -> u64 {
|
||||||
|
LIVE_BYTES.load(std::sync::atomic::Ordering::Relaxed).max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mib(bytes: u64) -> f64 {
|
||||||
|
bytes as f64 / (1 << 20) as f64
|
||||||
|
}
|
||||||
|
|
||||||
fn micros(d: Duration) -> f64 {
|
fn micros(d: Duration) -> f64 {
|
||||||
d.as_secs_f64() * 1e6
|
d.as_secs_f64() * 1e6
|
||||||
}
|
}
|
||||||
@@ -219,11 +291,12 @@ fn bench_ann(n: usize, json: &mut Vec<serde_json::Value>) {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let index = HnswIndex::build_with_metric(
|
let index = HnswIndex::build_with(
|
||||||
&data.vectors,
|
&data.vectors,
|
||||||
HNSW_M,
|
HNSW_M,
|
||||||
HNSW_EF_CONSTRUCTION,
|
HNSW_EF_CONSTRUCTION,
|
||||||
DistanceMetric::Cosine,
|
DistanceMetric::Cosine,
|
||||||
|
storage(),
|
||||||
);
|
);
|
||||||
let build = started.elapsed();
|
let build = started.elapsed();
|
||||||
|
|
||||||
@@ -240,7 +313,8 @@ fn bench_ann(n: usize, json: &mut Vec<serde_json::Value>) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"\n### HNSW, N = {n}, dim = {DIM}, M = {HNSW_M}, ef_construction = {HNSW_EF_CONSTRUCTION}\n"
|
"\n### HNSW, N = {n}, dim = {DIM}, M = {HNSW_M}, ef_construction = {HNSW_EF_CONSTRUCTION}, storage = {:?}\n",
|
||||||
|
index.storage()
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
"build: {:.1} ms ({:.0} vectors/s) · exact scan: {:.0} QPS, p50 {:.0} µs\n",
|
"build: {:.1} ms ({:.0} vectors/s) · exact scan: {:.0} QPS, p50 {:.0} µs\n",
|
||||||
@@ -251,12 +325,26 @@ fn bench_ann(n: usize, json: &mut Vec<serde_json::Value>) {
|
|||||||
);
|
);
|
||||||
println!("| ef | recall@{K} | QPS | p50 µs | p99 µs |");
|
println!("| ef | recall@{K} | QPS | p50 µs | p99 µs |");
|
||||||
println!("|---:|---:|---:|---:|---:|");
|
println!("|---:|---:|---:|---:|---:|");
|
||||||
|
// With a quantised index the distances it returns are approximate, so
|
||||||
|
// the candidates are re-scored against the exact vectors the caller
|
||||||
|
// already holds (in the agent, the embedding cache) before taking the
|
||||||
|
// top K. `--rerank` prices that: it costs one exact distance per
|
||||||
|
// candidate and is what decides whether int8 is usable.
|
||||||
|
let rerank = RERANK.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let pool = if rerank { K * 4 } else { K };
|
||||||
for ef in EF_VALUES {
|
for ef in EF_VALUES {
|
||||||
let mut hits = 0usize;
|
let mut hits = 0usize;
|
||||||
let mut samples = Vec::with_capacity(data.queries.len());
|
let mut samples = Vec::with_capacity(data.queries.len());
|
||||||
for (q, want) in data.queries.iter().zip(&truth) {
|
for (q, want) in data.queries.iter().zip(&truth) {
|
||||||
let t = Instant::now();
|
let t = Instant::now();
|
||||||
let got = index.search(q, K, ef);
|
let mut got = index.search(q, pool, ef.max(pool));
|
||||||
|
if rerank {
|
||||||
|
for cand in &mut got {
|
||||||
|
cand.1 = exact_dist(&data.vectors[cand.0], q);
|
||||||
|
}
|
||||||
|
got.select_nth_unstable_by(K - 1, |a, b| a.1.total_cmp(&b.1));
|
||||||
|
got.truncate(K);
|
||||||
|
}
|
||||||
samples.push(t.elapsed());
|
samples.push(t.elapsed());
|
||||||
hits += got.iter().filter(|(id, _)| want.contains(id)).count();
|
hits += got.iter().filter(|(id, _)| want.contains(id)).count();
|
||||||
}
|
}
|
||||||
@@ -394,11 +482,12 @@ fn fusion_study(n: usize) {
|
|||||||
.map(|(i, c)| text_for(*c, i, &mut rng))
|
.map(|(i, c)| text_for(*c, i, &mut rng))
|
||||||
.collect();
|
.collect();
|
||||||
let bm25 = BM25Index::build(&texts, &vec![0u8; n]);
|
let bm25 = BM25Index::build(&texts, &vec![0u8; n]);
|
||||||
let index = HnswIndex::build_with_metric(
|
let index = HnswIndex::build_with(
|
||||||
&data.vectors,
|
&data.vectors,
|
||||||
HNSW_M,
|
HNSW_M,
|
||||||
HNSW_EF_CONSTRUCTION,
|
HNSW_EF_CONSTRUCTION,
|
||||||
DistanceMetric::Cosine,
|
DistanceMetric::Cosine,
|
||||||
|
storage(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let vec_pool = (K * 8).max(64);
|
let vec_pool = (K * 8).max(64);
|
||||||
@@ -450,6 +539,64 @@ fn fusion_study(n: usize) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What an in-memory store costs, stage by stage. The vectors are the floor:
|
||||||
|
/// everything above it is bookkeeping that could in principle be shared.
|
||||||
|
fn bench_footprint(n: usize) {
|
||||||
|
let data = make_dataset(n, 0xF007 ^ n as u64);
|
||||||
|
let mut rng = Rng(11);
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("footprint.h5");
|
||||||
|
|
||||||
|
let base = heap_bytes();
|
||||||
|
let entries: Vec<MemoryEntry> = data
|
||||||
|
.vectors
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, v)| MemoryEntry {
|
||||||
|
chunk: text_for(data.cluster_of[i], i, &mut rng),
|
||||||
|
embedding: v.clone(),
|
||||||
|
source_channel: "bench".into(),
|
||||||
|
timestamp: i as f64,
|
||||||
|
session_id: format!("s{}", i % 50),
|
||||||
|
tags: format!("t{i}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let after_entries = heap_bytes();
|
||||||
|
|
||||||
|
let mut config = MemoryConfig::new(path, "bench", DIM);
|
||||||
|
config.quantized_index = INT8.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let mut mem = HDF5Memory::create(config).unwrap();
|
||||||
|
mem.save_batch(entries).unwrap();
|
||||||
|
let after_store = heap_bytes();
|
||||||
|
|
||||||
|
// First query builds the vector and keyword indexes.
|
||||||
|
std::hint::black_box(mem.hybrid_search(&data.queries[0], "record", 0.7, 0.3, K));
|
||||||
|
let after_indexes = heap_bytes();
|
||||||
|
|
||||||
|
// Reopening is the figure that matters for a long-lived process, and the
|
||||||
|
// only one RSS reports honestly: memory freed when the ingest buffers went
|
||||||
|
// away stays in the allocator's pool, so the stage deltas above understate
|
||||||
|
// what was given back.
|
||||||
|
let path = mem.config().path.clone();
|
||||||
|
drop(mem);
|
||||||
|
let before_open = heap_bytes();
|
||||||
|
let reopened = HDF5Memory::open(&path).unwrap();
|
||||||
|
let after_open = heap_bytes();
|
||||||
|
let loaded = after_open.saturating_sub(before_open);
|
||||||
|
drop(reopened);
|
||||||
|
|
||||||
|
let raw = (n * DIM * 4) as u64;
|
||||||
|
println!(
|
||||||
|
"| {n} | {:.0} | {:.0} | {:.0} | {:.0} | {:.0} | {:.2}x |",
|
||||||
|
mib(raw),
|
||||||
|
mib(after_entries.saturating_sub(base)),
|
||||||
|
mib(after_store.saturating_sub(after_entries)),
|
||||||
|
mib(after_indexes.saturating_sub(after_store)),
|
||||||
|
mib(loaded),
|
||||||
|
loaded as f64 / raw as f64,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
let full = args.iter().any(|a| a == "--full");
|
let full = args.iter().any(|a| a == "--full");
|
||||||
@@ -464,6 +611,14 @@ fn main() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if args.iter().any(|a| a == "--int8") {
|
||||||
|
INT8.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
println!("(int8-quantised index vectors)");
|
||||||
|
}
|
||||||
|
if args.iter().any(|a| a == "--rerank") {
|
||||||
|
RERANK.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
println!("(candidates re-scored against exact vectors)");
|
||||||
|
}
|
||||||
if args.iter().any(|a| a == "--uniform") {
|
if args.iter().any(|a| a == "--uniform") {
|
||||||
UNIFORM.store(true, std::sync::atomic::Ordering::Relaxed);
|
UNIFORM.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
println!("(uniform random data)");
|
println!("(uniform random data)");
|
||||||
@@ -485,6 +640,18 @@ fn main() {
|
|||||||
|
|
||||||
let mut json = Vec::new();
|
let mut json = Vec::new();
|
||||||
println!("## Search harness");
|
println!("## Search harness");
|
||||||
|
|
||||||
|
if args.iter().any(|a| a == "--footprint") {
|
||||||
|
println!("\n### Resident memory, {DIM}-dim f32\n");
|
||||||
|
println!(
|
||||||
|
"| N | vectors (raw) | entries MiB | store MiB | indexes MiB | reopened MiB | reopened / raw |"
|
||||||
|
);
|
||||||
|
println!("|---:|---:|---:|---:|---:|---:|---:|");
|
||||||
|
for &n in sizes {
|
||||||
|
bench_footprint(n);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// `--e2e-only` skips the index benchmarks, so the end-to-end section runs
|
// `--e2e-only` skips the index benchmarks, so the end-to-end section runs
|
||||||
// in a process that has not already spun up a thread pool.
|
// in a process that has not already spun up a thread pool.
|
||||||
if !args.iter().any(|a| a == "--e2e-only") {
|
if !args.iter().any(|a| a == "--e2e-only") {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-cli"
|
name = "clawhdf5-cli"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "CLI for clawhdf5 agent memory — create, save, search, recall, stats"
|
description = "CLI for clawhdf5 agent memory — create, save, search, recall, stats"
|
||||||
@@ -14,7 +14,7 @@ name = "clawhdf5"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.5.0" }
|
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.6.0" }
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ enum Commands {
|
|||||||
/// Enable write-ahead log
|
/// Enable write-ahead log
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
wal: bool,
|
wal: bool,
|
||||||
|
/// Store the vector index's copy of the embeddings as int8, roughly
|
||||||
|
/// halving a loaded store's memory at about 13% fewer queries/second
|
||||||
|
#[arg(long)]
|
||||||
|
quantized_index: bool,
|
||||||
},
|
},
|
||||||
/// Save a memory entry (reads JSON from stdin or --json)
|
/// Save a memory entry (reads JSON from stdin or --json)
|
||||||
Save {
|
Save {
|
||||||
@@ -88,9 +92,15 @@ fn main() {
|
|||||||
|
|
||||||
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Create { agent_id, dim, wal } => {
|
Commands::Create {
|
||||||
|
agent_id,
|
||||||
|
dim,
|
||||||
|
wal,
|
||||||
|
quantized_index,
|
||||||
|
} => {
|
||||||
let mut config = MemoryConfig::new(cli.path.clone(), &agent_id, dim);
|
let mut config = MemoryConfig::new(cli.path.clone(), &agent_id, dim);
|
||||||
config.wal_enabled = wal;
|
config.wal_enabled = wal;
|
||||||
|
config.quantized_index = quantized_index;
|
||||||
let mem = HDF5Memory::create(config)?;
|
let mem = HDF5Memory::create(config)?;
|
||||||
let j = serde_json::json!({
|
let j = serde_json::json!({
|
||||||
"status": "created",
|
"status": "created",
|
||||||
@@ -98,6 +108,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"embedding_dim": dim,
|
"embedding_dim": dim,
|
||||||
"wal_enabled": wal,
|
"wal_enabled": wal,
|
||||||
|
"quantized_index": quantized_index,
|
||||||
"count": mem.count(),
|
"count": mem.count(),
|
||||||
});
|
});
|
||||||
println!("{}", serde_json::to_string_pretty(&j)?);
|
println!("{}", serde_json::to_string_pretty(&j)?);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-derive"
|
name = "clawhdf5-derive"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Derive macros for rustyhdf5 HDF5 traits"
|
description = "Derive macros for rustyhdf5 HDF5 traits"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-filters"
|
name = "clawhdf5-filters"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Filter and compression pipeline for clawhdf5"
|
description = "Filter and compression pipeline for clawhdf5"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-format"
|
name = "clawhdf5-format"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Pure-Rust HDF5 binary format parsing and writing — no C dependencies"
|
description = "Pure-Rust HDF5 binary format parsing and writing — no C dependencies"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -25,7 +25,7 @@ pco = { version = "1.0", optional = true }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
criterion = { workspace = true }
|
criterion = { workspace = true }
|
||||||
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.5.0" }
|
clawhdf5-derive = { path = "../clawhdf5-derive", version = "2.6.0" }
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "bench"
|
name = "bench"
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ use crate::filter_pipeline::{
|
|||||||
FilterDescription, FilterPipeline,
|
FilterDescription, FilterPipeline,
|
||||||
};
|
};
|
||||||
use crate::filters::compress_chunk;
|
use crate::filters::compress_chunk;
|
||||||
|
|
||||||
/// Round a file offset up to the next cache-line boundary.
|
/// Round a file offset up to the next cache-line boundary.
|
||||||
///
|
///
|
||||||
/// This ensures chunk data starts at an address that is a multiple of the
|
/// This ensures chunk data starts at an address that is a multiple of the
|
||||||
@@ -928,6 +927,7 @@ pub fn write_selection_to_buffer(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::chunked_read::read_chunked_data;
|
use crate::chunked_read::read_chunked_data;
|
||||||
use crate::data_layout::DataLayout;
|
use crate::data_layout::DataLayout;
|
||||||
@@ -1512,9 +1512,20 @@ mod tests {
|
|||||||
|
|
||||||
// ---- h5py round-trip tests for chunked writes ----
|
// ---- h5py round-trip tests for chunked writes ----
|
||||||
|
|
||||||
|
/// The Python interpreter to drive interop checks with.
|
||||||
|
///
|
||||||
|
/// `CLAWHDF5_PYTHON` lets these run against a virtualenv holding h5py,
|
||||||
|
/// which on a PEP 668 "externally managed" system is the only place it
|
||||||
|
/// can be installed. Without it the suite silently skips, and a silent
|
||||||
|
/// skip here is how a datatype bug once reached a release.
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
fn python() -> String {
|
||||||
|
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "std")]
|
#[cfg(feature = "std")]
|
||||||
fn h5py_available() -> bool {
|
fn h5py_available() -> bool {
|
||||||
std::process::Command::new("python3")
|
std::process::Command::new(python())
|
||||||
.args(["-c", "import h5py"])
|
.args(["-c", "import h5py"])
|
||||||
.output()
|
.output()
|
||||||
.map(|o| o.status.success())
|
.map(|o| o.status.success())
|
||||||
@@ -1526,10 +1537,10 @@ mod tests {
|
|||||||
if !h5py_available() {
|
if !h5py_available() {
|
||||||
panic!("h5py not installed — skipping interop test");
|
panic!("h5py not installed — skipping interop test");
|
||||||
}
|
}
|
||||||
let o = std::process::Command::new("python3")
|
let o = std::process::Command::new(python())
|
||||||
.args(["-c", script])
|
.args(["-c", script])
|
||||||
.output()
|
.output()
|
||||||
.expect("python3");
|
.expect("python interpreter");
|
||||||
if !o.status.success() {
|
if !o.status.success() {
|
||||||
panic!("h5py: {}", String::from_utf8_lossy(&o.stderr));
|
panic!("h5py: {}", String::from_utf8_lossy(&o.stderr));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
use clawhdf5_format::data_read::{read_object_references, read_region_references};
|
use clawhdf5_format::data_read::{read_object_references, read_region_references};
|
||||||
use clawhdf5_format::datatype::{Datatype, ReferenceType};
|
use clawhdf5_format::datatype::{Datatype, ReferenceType};
|
||||||
|
/// The Python interpreter to drive interop checks with.
|
||||||
|
///
|
||||||
|
/// `CLAWHDF5_PYTHON` lets these run against a virtualenv holding h5py, which
|
||||||
|
/// on a PEP 668 "externally managed" system is the only place it can be
|
||||||
|
/// installed. Without it the suite silently skips, and a silent skip here is
|
||||||
|
/// how a datatype bug once reached a release.
|
||||||
|
fn python() -> String {
|
||||||
|
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn object_ref_single_valid() {
|
fn object_ref_single_valid() {
|
||||||
@@ -173,7 +182,7 @@ print('ok')
|
|||||||
"#,
|
"#,
|
||||||
path.display()
|
path.display()
|
||||||
);
|
);
|
||||||
let output = std::process::Command::new("python3")
|
let output = std::process::Command::new(python())
|
||||||
.args(["-c", &script])
|
.args(["-c", &script])
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,18 @@
|
|||||||
//! (and vice versa). They require python3 + h5py to be installed.
|
//! (and vice versa). They require python3 + h5py to be installed.
|
||||||
|
|
||||||
use clawhdf5_format::file_writer::{AttrValue, CompoundTypeBuilder, EnumTypeBuilder, FileWriter};
|
use clawhdf5_format::file_writer::{AttrValue, CompoundTypeBuilder, EnumTypeBuilder, FileWriter};
|
||||||
|
/// The Python interpreter to drive interop checks with.
|
||||||
|
///
|
||||||
|
/// `CLAWHDF5_PYTHON` lets these run against a virtualenv holding h5py, which
|
||||||
|
/// on a PEP 668 "externally managed" system is the only place it can be
|
||||||
|
/// installed. Without it the suite silently skips, and a silent skip here is
|
||||||
|
/// how a datatype bug once reached a release.
|
||||||
|
fn python() -> String {
|
||||||
|
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn h5py_available() -> bool {
|
fn h5py_available() -> bool {
|
||||||
std::process::Command::new("python3")
|
std::process::Command::new(python())
|
||||||
.args(["-c", "import h5py"])
|
.args(["-c", "import h5py"])
|
||||||
.output()
|
.output()
|
||||||
.map(|o| o.status.success())
|
.map(|o| o.status.success())
|
||||||
@@ -17,10 +26,10 @@ fn h5py_read(_path: &std::path::Path, script: &str) -> String {
|
|||||||
if !h5py_available() {
|
if !h5py_available() {
|
||||||
panic!("h5py not installed — skipping interop test");
|
panic!("h5py not installed — skipping interop test");
|
||||||
}
|
}
|
||||||
let o = std::process::Command::new("python3")
|
let o = std::process::Command::new(python())
|
||||||
.args(["-c", script])
|
.args(["-c", script])
|
||||||
.output()
|
.output()
|
||||||
.expect("python3");
|
.expect("python interpreter");
|
||||||
if !o.status.success() {
|
if !o.status.success() {
|
||||||
panic!("h5py: {}", String::from_utf8_lossy(&o.stderr));
|
panic!("h5py: {}", String::from_utf8_lossy(&o.stderr));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-gpu"
|
name = "clawhdf5-gpu"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders"
|
description = "GPU-accelerated vector operations for rustyhdf5 using wgpu compute shaders"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-io"
|
name = "clawhdf5-io"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "I/O abstraction layer for rustyhdf5"
|
description = "I/O abstraction layer for rustyhdf5"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -10,7 +10,7 @@ keywords = ["hdf5", "io", "science", "data"]
|
|||||||
categories = ["filesystem", "science"]
|
categories = ["filesystem", "science"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.5.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
||||||
memmap2 = { version = "0.9", optional = true }
|
memmap2 = { version = "0.9", optional = true }
|
||||||
libc = { version = "0.2", optional = true }
|
libc = { version = "0.2", optional = true }
|
||||||
tokio = { version = "1", features = ["fs", "io-util"], optional = true }
|
tokio = { version = "1", features = ["fs", "io-util"], optional = true }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-migrate"
|
name = "clawhdf5-migrate"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "CLI to migrate SQLite agent memory databases to HDF5 format"
|
description = "CLI to migrate SQLite agent memory databases to HDF5 format"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -14,9 +14,9 @@ name = "clawhdf5-migrate"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.5.0" }
|
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.6.0" }
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.5.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
||||||
clawhdf5 = { path = "../clawhdf5", version = "2.5.0" }
|
clawhdf5 = { path = "../clawhdf5", version = "2.6.0" }
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
half = { workspace = true }
|
half = { workspace = true }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-napi"
|
name = "clawhdf5-napi"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Node.js native addon (napi-rs) exposing clawhdf5-agent to TypeScript/JavaScript"
|
description = "Node.js native addon (napi-rs) exposing clawhdf5-agent to TypeScript/JavaScript"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -10,7 +10,7 @@ repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
|||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.5.0" }
|
clawhdf5-agent = { path = "../clawhdf5-agent", version = "2.6.0" }
|
||||||
napi = { version = "2", default-features = false, features = ["napi9"] }
|
napi = { version = "2", default-features = false, features = ["napi9"] }
|
||||||
napi-derive = "2"
|
napi-derive = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-netcdf4"
|
name = "clawhdf5-netcdf4"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "NetCDF-4 read support built on rustyhdf5 — pure Rust, no C dependencies"
|
description = "NetCDF-4 read support built on rustyhdf5 — pure Rust, no C dependencies"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -10,8 +10,8 @@ keywords = ["netcdf", "netcdf4", "hdf5", "science", "climate"]
|
|||||||
categories = ["parser-implementations", "science"]
|
categories = ["parser-implementations", "science"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5 = { path = "../clawhdf5", version = "2.5.0" }
|
clawhdf5 = { path = "../clawhdf5", version = "2.6.0" }
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.5.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ use clawhdf5_netcdf4::{AttrValue, NetCDF4File};
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
/// The Python interpreter to drive interop checks with.
|
||||||
|
///
|
||||||
|
/// `CLAWHDF5_PYTHON` lets these run against a virtualenv holding h5py, which
|
||||||
|
/// on a PEP 668 "externally managed" system is the only place it can be
|
||||||
|
/// installed. Without it the suite silently skips, and a silent skip here is
|
||||||
|
/// how a datatype bug once reached a release.
|
||||||
|
fn python() -> String {
|
||||||
|
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// When `CLAWHDF5_REQUIRE_INTEROP=1` (set in CI), a missing Python dependency
|
/// When `CLAWHDF5_REQUIRE_INTEROP=1` (set in CI), a missing Python dependency
|
||||||
/// is a test failure instead of a silent skip.
|
/// is a test failure instead of a silent skip.
|
||||||
@@ -17,7 +26,7 @@ fn interop_required() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn netcdf4_python_available() -> bool {
|
fn netcdf4_python_available() -> bool {
|
||||||
Command::new("python3")
|
Command::new(python())
|
||||||
.args(["-c", "import netCDF4; print(netCDF4.__version__)"])
|
.args(["-c", "import netCDF4; print(netCDF4.__version__)"])
|
||||||
.output()
|
.output()
|
||||||
.map(|o| o.status.success())
|
.map(|o| o.status.success())
|
||||||
@@ -25,7 +34,7 @@ fn netcdf4_python_available() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn xarray_available() -> bool {
|
fn xarray_available() -> bool {
|
||||||
Command::new("python3")
|
Command::new(python())
|
||||||
.args(["-c", "import xarray; print(xarray.__version__)"])
|
.args(["-c", "import xarray; print(xarray.__version__)"])
|
||||||
.output()
|
.output()
|
||||||
.map(|o| o.status.success())
|
.map(|o| o.status.success())
|
||||||
@@ -59,7 +68,7 @@ macro_rules! skip_if_no_xarray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_python(script: &str) {
|
fn run_python(script: &str) {
|
||||||
let output = Command::new("python3")
|
let output = Command::new(python())
|
||||||
.args(["-c", script])
|
.args(["-c", script])
|
||||||
.output()
|
.output()
|
||||||
.expect("failed to run python3");
|
.expect("failed to run python3");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5-py"
|
name = "clawhdf5-py"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
|
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -14,8 +14,8 @@ name = "clawhdf5"
|
|||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5_rs = { path = "../clawhdf5", version = "2.5.0", package = "clawhdf5" }
|
clawhdf5_rs = { path = "../clawhdf5", version = "2.6.0", package = "clawhdf5" }
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.5.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
||||||
pyo3 = "0.29"
|
pyo3 = "0.29"
|
||||||
numpy = "0.29"
|
numpy = "0.29"
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rustyhdf5"
|
name = "rustyhdf5"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
|
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clawhdf5"
|
name = "clawhdf5"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Pure-Rust HDF5 reader/writer — no C dependencies"
|
description = "Pure-Rust HDF5 reader/writer — no C dependencies"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -10,16 +10,16 @@ keywords = ["hdf5", "science", "data", "binary"]
|
|||||||
categories = ["parser-implementations", "science", "encoding"]
|
categories = ["parser-implementations", "science", "encoding"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.5.0" }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0" }
|
||||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.5.0" }
|
clawhdf5-io = { path = "../clawhdf5-io", version = "2.6.0" }
|
||||||
rayon = { version = "1", optional = true }
|
rayon = { version = "1", optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
criterion = { workspace = true }
|
criterion = { workspace = true }
|
||||||
clawhdf5-io = { path = "../clawhdf5-io", version = "2.5.0", features = ["mmap"] }
|
clawhdf5-io = { path = "../clawhdf5-io", version = "2.6.0", features = ["mmap"] }
|
||||||
clawhdf5-format = { path = "../clawhdf5-format", version = "2.5.0", features = ["parallel", "fast-checksum"] }
|
clawhdf5-format = { path = "../clawhdf5-format", version = "2.6.0", features = ["parallel", "fast-checksum"] }
|
||||||
clawhdf5-filters = { path = "../clawhdf5-filters", version = "2.5.0" }
|
clawhdf5-filters = { path = "../clawhdf5-filters", version = "2.6.0" }
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "mmap_bench"
|
name = "mmap_bench"
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ use clawhdf5::{AttrValue, CompoundTypeBuilder, DType, File, FileBuilder};
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
/// The Python interpreter to drive interop checks with.
|
||||||
|
///
|
||||||
|
/// `CLAWHDF5_PYTHON` lets these run against a virtualenv holding h5py, which
|
||||||
|
/// on a PEP 668 "externally managed" system is the only place it can be
|
||||||
|
/// installed. Without it the suite silently skips, and a silent skip here is
|
||||||
|
/// how a datatype bug once reached a release.
|
||||||
|
fn python() -> String {
|
||||||
|
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// When `CLAWHDF5_REQUIRE_INTEROP=1` (set in CI), a missing Python dependency
|
/// When `CLAWHDF5_REQUIRE_INTEROP=1` (set in CI), a missing Python dependency
|
||||||
/// is a test failure instead of a silent skip.
|
/// is a test failure instead of a silent skip.
|
||||||
@@ -17,7 +26,7 @@ fn interop_required() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn python_available() -> bool {
|
fn python_available() -> bool {
|
||||||
Command::new("python3")
|
Command::new(python())
|
||||||
.args(["-c", "import h5py; print(h5py.__version__)"])
|
.args(["-c", "import h5py; print(h5py.__version__)"])
|
||||||
.output()
|
.output()
|
||||||
.map(|o| o.status.success())
|
.map(|o| o.status.success())
|
||||||
@@ -39,7 +48,7 @@ macro_rules! skip_if_no_python {
|
|||||||
|
|
||||||
/// Run a Python script and panic if it fails.
|
/// Run a Python script and panic if it fails.
|
||||||
fn run_python(script: &str) {
|
fn run_python(script: &str) {
|
||||||
let output = Command::new("python3")
|
let output = Command::new(python())
|
||||||
.args(["-c", script])
|
.args(["-c", script])
|
||||||
.output()
|
.output()
|
||||||
.expect("failed to run python3");
|
.expect("failed to run python3");
|
||||||
@@ -52,7 +61,7 @@ fn run_python(script: &str) {
|
|||||||
|
|
||||||
/// Run a Python script and return stdout as a trimmed string.
|
/// Run a Python script and return stdout as a trimmed string.
|
||||||
fn run_python_output(script: &str) -> String {
|
fn run_python_output(script: &str) -> String {
|
||||||
let output = Command::new("python3")
|
let output = Command::new(python())
|
||||||
.args(["-c", script])
|
.args(["-c", script])
|
||||||
.output()
|
.output()
|
||||||
.expect("failed to run python3");
|
.expect("failed to run python3");
|
||||||
|
|||||||
@@ -364,6 +364,11 @@ cargo install --path crates/clawhdf5-cli
|
|||||||
clawhdf5 --path agent.h5 create --agent-id my-agent --dim 384 --wal
|
clawhdf5 --path agent.h5 create --agent-id my-agent --dim 384 --wal
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Add `--quantized-index` to store the vector index's copy of the embeddings as
|
||||||
|
int8. That roughly halves a loaded store's memory at about 13% fewer queries
|
||||||
|
per second, with recall unchanged — the query path re-scores candidates
|
||||||
|
against the exact embeddings. The setting is recorded in the file.
|
||||||
|
|
||||||
Output:
|
Output:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -146,3 +146,26 @@ created with `external=[...]` storage returns
|
|||||||
`FormatError::ExternalDataFilesUnsupported`. Neither is resolved. If support is
|
`FormatError::ExternalDataFilesUnsupported`. Neither is resolved. If support is
|
||||||
added, file names must be confined to the opened file's directory, as the
|
added, file names must be confined to the opened file's directory, as the
|
||||||
virtual-dataset resolver now does.
|
virtual-dataset resolver now does.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python interop suites skip silently when no interpreter has h5py
|
||||||
|
|
||||||
|
**Status:** fixed on `main` in `a29c1b2` (2026-09-19).
|
||||||
|
|
||||||
|
On a system where `python3` is a PEP 668 "externally managed" interpreter,
|
||||||
|
h5py cannot be installed into it at all, and every interop suite — the h5py
|
||||||
|
writer round-trips, the facade suite, netCDF4, and the reference files —
|
||||||
|
returned `false` from its availability probe and skipped without failing. CI
|
||||||
|
reported `SKIP` and a green run. This is the same class of gap that let the
|
||||||
|
compound-datatype v5 bug above reach a release.
|
||||||
|
|
||||||
|
The probes now read `CLAWHDF5_PYTHON`, and `scripts/ci-test.sh` picks up
|
||||||
|
`.venv/bin/python` automatically. To restore the coverage on a fresh checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv && .venv/bin/pip install h5py numpy netCDF4
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `CLAWHDF5_REQUIRE_INTEROP=1` in any automated runner so a missing
|
||||||
|
interpreter is a failure rather than a skip.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@redclaw/clawhdf5",
|
"name": "@redclaw/clawhdf5",
|
||||||
"version": "2.5.0",
|
"version": "2.6.0",
|
||||||
"description": "Node.js bindings for clawhdf5 — HDF5-backed agent memory with hippocampal consolidation",
|
"description": "Node.js bindings for clawhdf5 — HDF5-backed agent memory with hippocampal consolidation",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
|
|||||||
+15
-2
@@ -20,6 +20,13 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# Interop suites drive a Python interpreter. On a PEP 668 "externally managed"
|
||||||
|
# system h5py can only live in a virtualenv, so pick one up here — before any
|
||||||
|
# test step, since the non-ignored interop suites read the same variable.
|
||||||
|
if [ -z "${CLAWHDF5_PYTHON:-}" ] && [ -x "$SCRIPT_DIR/../.venv/bin/python" ]; then
|
||||||
|
export CLAWHDF5_PYTHON="$SCRIPT_DIR/../.venv/bin/python"
|
||||||
|
fi
|
||||||
PASS=0
|
PASS=0
|
||||||
FAIL=0
|
FAIL=0
|
||||||
STEPS=()
|
STEPS=()
|
||||||
@@ -85,12 +92,18 @@ run_step "cargo test (ann parallel)" cargo test \
|
|||||||
|
|
||||||
# 5. Python interop suites. The h5py writer tests are #[ignore]d so a plain
|
# 5. Python interop suites. The h5py writer tests are #[ignore]d so a plain
|
||||||
# `cargo test` stays hermetic; run them explicitly here.
|
# `cargo test` stays hermetic; run them explicitly here.
|
||||||
if python3 -c "import h5py" >/dev/null 2>&1 || [ "${CLAWHDF5_REQUIRE_INTEROP:-0}" = "1" ]; then
|
# On a PEP 668 "externally managed" system h5py can only live in a
|
||||||
|
# virtualenv, so honour CLAWHDF5_PYTHON (and a local .venv) rather than
|
||||||
|
# skipping — the tests read the same variable.
|
||||||
|
PYTHON="${CLAWHDF5_PYTHON:-python3}"
|
||||||
|
if "$PYTHON" -c "import h5py" >/dev/null 2>&1 || [ "${CLAWHDF5_REQUIRE_INTEROP:-0}" = "1" ]; then
|
||||||
run_step "h5py interop (format, ignored tests)" cargo test \
|
run_step "h5py interop (format, ignored tests)" cargo test \
|
||||||
-p clawhdf5-format --test writer_h5py_tests -- --include-ignored
|
-p clawhdf5-format --test writer_h5py_tests -- --include-ignored
|
||||||
else
|
else
|
||||||
echo ""
|
echo ""
|
||||||
echo "==> [h5py interop] SKIPPED: python3 with h5py not available"
|
echo "==> [h5py interop] SKIPPED: no h5py in $PYTHON"
|
||||||
|
echo " (set CLAWHDF5_PYTHON=/path/to/venv/bin/python, or create .venv;"
|
||||||
|
echo " CLAWHDF5_REQUIRE_INTEROP=1 makes this a failure instead)"
|
||||||
STEPS+=("SKIP: h5py interop (format, ignored tests)")
|
STEPS+=("SKIP: h5py interop (format, ignored tests)")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user