Commit Graph
175 Commits
Author SHA1 Message Date
osobhandClaude Opus 5 c0a9206703 feat(agent): optional int8 vector index, re-scored against exact embeddings
`MemoryConfig::quantized_index` stores the HNSW index's own copy of the
embeddings as i8 rather than f32. At 100k x 384 that takes the index from
266 to 123 MiB and the whole reopened store from 399 to 256 MiB — 2.72x
to 1.74x the raw vectors, the largest remaining item in the footprint.

Quantised distances are approximate and `ef` cannot compensate, because
the loss is in the distances rather than in the graph: recall@10 tops out
at 0.967 against f32's 0.9995 and does not move between ef=128 and
ef=256. The store already holds the exact embeddings, though, so when the
index is quantised the query path re-scores the candidate pool against
them before fusion. That restores recall (0.9940 vs 0.9945 at ef=64) and
costs about 13% of QPS.

Off by default: it trades query speed for memory and which side is worth
more depends on the deployment. The flag is persisted in `/meta`, so a
reopened store does not silently revert to four times the index memory,
and the sidecar graph is rehydrated into the configured storage.

Also on the CLI as `create --quantized-index`.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:40:37 -07:00
osobhandClaude Opus 5 57756e69ec feat(ann): optional int8 storage for the index's vector copy
The HNSW index keeps its own copy of every vector, which at 100K x 384
f32 is ~146 MiB — the largest single item in the 2.43x footprint now
that the agent stores embeddings once. `Storage::Int8` cuts that copy to
a quarter by scaling each row to i8.

The scale is per row, not global. Unit-length rows in d dimensions have
components around 1/sqrt(d), so a fixed [-1, 1] scale spends fewer than
12 of the 255 levels on a 128-dimensional vector; measured against an
exact ranking that gives 0.35 top-10 overlap. Scaling each row by its
own largest component uses the full range and brings it to 0.99.

Quantised distances still cost recall on their own, and `ef` does not
buy it back because the loss is in the distances rather than the graph:
at N=100K recall@10 tops out at 0.967 against f32's 0.9995. Re-scoring a
wider candidate pool against the exact vectors removes the gap
(0.9940 vs 0.9945 at ef=64) for ~13% of query throughput and ~16% of
build time. That is the intended use, so it is what the test asserts —
against ground truth, not against the f32 index, whose own mistakes a
re-scored search is entitled to get right.

Default is unchanged: `Storage::Float32`, chosen by every existing
constructor. Serialized indexes carry f32 vectors and no storage tag, so
a quantised index is rebuilt rather than loaded; `compact()` keeps the
storage it was given.

The harness grows `--int8` and `--rerank` axes, and reports the storage
in each table header.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:35:24 -07:00
osobhandClaude Opus 5 6ad8ceb426 Merge feat/vector-footprint: store embeddings once; footprint measurement
CI / test (push) Failing after 1s
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:19:43 -07:00
osobhandClaude Opus 5 2e7e0456c1 perf(agent): store embeddings once, not twice
MemoryCache held every embedding in two places: a `Vec<Vec<f32>>` and a
flattened copy for the batched kernels, kept in lock-step on every push,
update and compaction. A store loaded from disk therefore carried the corpus
twice, plus one heap allocation per entry.

A new `cache::Embeddings` owns just the flat `[N x dim]` buffer and indexes
into it, so `embeddings[i]` still reads as a `&[f32]` row. The batch kernels
take a `VectorSet` (implemented for both `Embeddings` and `Vec<Vec<f32>>`)
instead of `&[Vec<f32>]`, so their callers and tests are unchanged. Loading no
longer unflattens what it just read.

100k 384-dim entries, reopened from disk: 505 -> 357 MiB, 3.44x -> 2.43x the
raw vectors. Recall (1.0000 at ef=64) and query latency are unchanged.

Rows are now always exactly `dim` long, shorter ones zero-padded. The old
representation allowed ragged rows, which silently misaligned the flattened
copy — every row after a wrong-length embedding — and `update` carried a
comment about falling back to a rebuild to avoid exactly that. It is now
unrepresentable. A record saved without an embedding holds a zero row and is
told apart by its norm, which is what `total_embeddings` now counts.

Measured with a counting allocator rather than RSS: freeing a structure
returns its pages to the allocator's pool, not the OS, so an RSS reading from
inside the process showed the two representations as identical.

Breaking: MemoryCache::embeddings changes type, embeddings_flat is replaced by
flat_embeddings(), rebuild_flat() is a deprecated no-op.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:17:55 -07:00
osobhandClaude Opus 5 dc0113d015 Merge feat/temporal-reranking: re-ranking keeps the retrieval score; recency metric
CI / test (push) Failing after 2s
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:05:52 -07:00
osobhandClaude Opus 5 8ea455bbcb fix(agent): re-ranking threw away the retrieval score
reranker::rerank built its combined score from temporal decay, source
authority and Hebbian activation. RerankInput carried no relevance score, so
it could not have used one: re-ranking a candidate pool reordered it purely by
age and discarded the retriever's ordering. The OpenClaw backend re-ranks
every search, so that was its shipping behaviour.

Measured over the full LongMemEval haystack (500 questions, real MiniLM
embeddings), ordering by metadata alone costs 40.6pp of Hit@1 (11.0% vs 51.6%)
and two thirds of MRR (0.1829 vs 0.6430) — the results are the newest memories
in the pool rather than the ones answering the question.

RerankInput::relevance and ReRankConfig::relevance_weight (1.0 by default)
make relevance lead, with the metadata signals breaking near-ties. Retrieval
is preserved (Hit@1 52.0%, +0.4pp against no re-ranking; MRR -0.003) and
recency discrimination improves 6-7pp, from chance to ~52%.

A half-life sweep (1, 7, 30, 90 days) moves recency 1.4pp and MRR 0.003 —
inside the noise — because the temporal term is capped by its weight while
relevance gaps are larger. The 24-hour default is kept: there is no measured
reason to change it. The two ends of the trade-off are recorded in
BENCHMARKS.md rather than just the good news.

Breaking: RerankInput and ReRankConfig gained fields.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 20:03:48 -07:00
osobhandClaude Opus 5 1e18ff5a86 style(bench): gate the rerank-sweep flag and helper on the embeddings feature
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 19:42:05 -07:00
osobhandClaude Opus 5 306a35347c bench: use real session dates, and measure recency discrimination
Two gaps in the LongMemEval harness, both of which had to close before any
recency feature could be judged.

The store was fed a synthetic counter (ts += 1.0 per turn) and the dataset's
own `haystack_dates` were ignored. Session order happened to be chronological,
so ordering was right, but the intervals were fiction — and exponential decay
is a function of the interval, so anything time-aware was being measured
against made-up ages. Dates are now parsed (civil-from-days, pinned against
reference values) and turns are spread over the minutes after their session
start; an unparseable date falls back to position so order still holds.

`newest_gold_first` measures what recall cannot. On a `knowledge-update`
question LongMemEval labels *both* the stale session and the one that
supersedes it as gold, so returning either scores as a hit even though only
one answers the question. The new metric asks whether the newest gold session
outranked the older ones. The current retriever scores 43-45% on it across
every mode — chance — which is the gap a temporal signal is supposed to close.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:39:30 -07:00