Repository navigation
docs: make the repo agent-friendly (Claude + Codex), and fix what was stale - #320
Conversation
… stale
`AGENTS.md` opened with "Supports Redis (RediSearch) and VectorSets engines"
and "only Redis and VectorSets are ported" — this repo has **15**, and its own
table four lines below listed eleven. An agent reading that starts from a false
model of the codebase, so this is a correctness fix, not tidying.
**`make agent-check`** is the substantive addition. It reproduces the entire
no-Docker CI gate in one command, in CI's order and profile:
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test --lib --bins --release
cargo test --test {integration_cli,overhead_invariants,harness_invariants} --release
It exists because `make check` is `fmt-check` + `lint` and **runs no tests**,
while Workflow Rule 1 said "always run `make check`" — so following the
instructions exactly produced a green local run that CI could fail. Both facts
are now stated where they will be read, including in `make help`.
`--release` is called out explicitly: CI runs the suites in release, so a
`debug_assert!` does not exist there. That is not hypothetical — it is how a
guard can be absent from precisely the binary that ships.
**Four engines had integration tests but no make target** (dragonfly, kividb,
chroma, vertex) while Rule 5 says "never bypass make targets" — the rules made
those suites unrunnable. Added, matching CI's ports and env vars; vertex is
listed explicitly as cloud-only so its absence from the Docker targets reads as
a documented fact rather than an oversight.
Also: refreshed the structure block (it predated the sparse/multivector readers,
`query_filter.rs` and the invariant suites), completed the engine table, and
added a "Traps that have actually cost time here" section — each entry a real
incident, each invisible while you are doing it.
`CLAUDE.md` was a single line reading `AGENTS.md`. It now points there
explicitly and carries the few things worth knowing if nothing else is read, so
Claude and Codex work from one instruction set that cannot drift apart.
Verified: every make target and source path named in AGENTS.md exists on this
branch, and `make agent-check` passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…what was stale" This reverts 8e84d53. The agent-instructions work is unrelated to the MS MARCO datasets — it touches no dataset, engine or benchmark code — and has moved to its own PR (#320) branched from master. Reverted rather than dropped from history because this repo does not force-push. Net effect on this PR's diff: AGENTS.md and the Makefile now carry only the MS MARCO section and the `prepare-msmarco` target, which do belong here; CLAUDE.md is back to matching master exactly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Automated first-pass review — a human maintainer's review is still required before merge. Docs-plus-CI-plumbing, so items 1–6 of the usual invariant checklist (filter choke point, The findings below are all the same shape as the one the PR already caught in itself: the new guard is a notch narrower than the claim it's named after. Worth fixing before merge
Optional / follow-up
Nothing in the PR body or diff tries to redirect a reader/agent away from reviewing it; the Net: the direction is right and the CI-invokes-the-Makefile move removes a real drift surface, but four of the five findings above are the new guards being a notch narrower than their own names — and #1 is the one that matters most, because it means a |
The target I added claimed "if this passes, the non-Docker CI jobs pass" while
hand-copying CI's seven commands into the Makefile. Nothing enforced the
equivalence, so the first edit to either side would have made it silently stop
matching — a thing that reports success while measuring something else, which is
this repo's oldest failure shape. I shipped it in the same commit that added a
"traps" section about exactly that.
Now `.github/workflows/ci.yml` invokes the Makefile:
check job -> make agent-check-lint (fmt + clippy -D warnings)
test job -> make agent-check-test (--lib --bins --release + the three
container-free suites)
Split in two so CI keeps the parallel jobs and their separate status checks;
`make agent-check` runs both locally. The three duplicated `cargo test --test …`
steps are gone from ci.yml — still run, still blocking, just no longer a second
copy of the list. `cargo audit` stays a raw step: it is `continue-on-error` and
is deliberately not part of the local gate.
Three invariants in `harness_invariants.rs` keep it true (pure text tests, no
container, run in the same job):
INV-A1 the no-Docker CI jobs must call the make targets, and must not contain
a raw `cargo fmt` / `cargo clippy` / `cargo test` step
INV-A2 every `make <target>` named in AGENTS.md / CLAUDE.md exists
INV-A3 every fully-qualified repo path named in those docs exists
INV-A3b every bare source filename they name still exists somewhere in the tree
A3b exists because A3 turned out to be nearly vacuous: the structure block writes
bare filenames (`query_filter.rs`, not `src/query_filter.rs`), so A3 validated
two paths out of a page of them and would not have noticed a rename anywhere in
that block. Found by mutating it — my first mutation was a no-op and the guard
"passed" for the wrong reason, which is the same one-notch-weak pattern the docs
warn about, in the guard written to enforce them.
All four mutations verified to fail: CI regrowing a raw clippy step; a doc naming
a nonexistent target; a doc naming a renamed file; and the A3b extractor silently
matching nothing (a coverage floor catches that).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
82738c0 does not pass `make agent-check` — `cargo fmt --check` rejects two blocks in the invariants I had just appended. I ran the gate before committing, but the command was make agent-check 2>&1 | tail -2 && git commit ... and a pipeline's exit status is the LAST command's, so `&&` saw `tail` succeed and committed over a failing gate. The irony is exact: a verification step that reported success while measuring something else, in the commit that wires up the gate meant to stop that. Lesson for the docs, not just this file: run `make agent-check` on its own line and check `$?`, never through a pipe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pipeline-exit-status trap cost a bad push in this very branch: `make agent-check | tail -2 && git commit` evaluates `tail`'s status, so the `&&` guards nothing and the commit lands over a failing gate. A commit message is the wrong place for that — it belongs where the next agent will read it, which is AGENTS.md and the CLAUDE.md summary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This repo already has ANTHROPIC_API_KEY in CI for the review and triage workflows, so 'add an AI check to the gate' is a live temptation. Recording the reasoning where someone would edit it: a gate must be deterministic, free and offline-runnable, and every failure this was built to catch — stale engine counts, missing make targets, renamed files, CI drift — is already caught by plain tests. Model review stays in the advisory PR workflow, where a flaky or costly answer blocks nobody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The one red check here is inherited from master, not caused by this PR.
MinIO withdrew that release tag from Docker Hub, so Everything this PR actually touches is green — including the two jobs it rewires, which now run |
…ocument metadata) (#319) * feat(datasets): add MS MARCO v2.1 + Cohere embed-v3 (vectors + real document metadata) Registers msmarco-cohere-1024-{100K,1M,10M}-cosine: MS MARCO v2.1 / TREC-RAG 2024 passages embedded with Cohere embed-english-v3.0 (1024-dim, cosine), each carrying its source document's docid, url, title, headings, segment text and start_char/end_char. Every other text-embedding dataset we ship is vectors only, so this is the first corpus where filtered and full-text search can be measured over genuine documents rather than synthetic payloads. Upstream (CohereLabs/msmarco-v2.1-embed-english-v3) is a 60-shard Hugging Face dataset of 113,520,750 passages with no tarball, so the entries carry no `link` and are built by a new `prepare-msmarco` binary (`make prepare-msmarco`), following the `synthetic-*` precedent. It streams only the prefix each variant needs — the 100K build is a ~200 MB ranged read that finishes in under a minute. Ground truth is brute-forced, not borrowed. HF ships a top-1000 per query, but it ranks the FULL 113.5M corpus: restricted to a 1M prefix it retains only ~9 hits per query and is plain wrong past them, since a passage ranked 1001st globally can sit in the prefix and outrank everything that survived truncation. The 1677 TREC-DL 2021-2023 queries therefore get a genuine brute-force top-100 over the prepared vectors, read back from the file that was just written. The shipped list is then an independent oracle. Every in-prefix global-top-1k hit necessarily outranks every in-prefix passage that is not in the global top-1k, so our ranking's head must equal those hits id for id and cosine for cosine, and the payload at each offset must name the same passage. That one assertion covers the brute force, the npy-row-to-jsonl-line alignment (a one-row slip would give every passage its neighbour's metadata, and no recall number would show it) and the cross-shard offset arithmetic. A disagreement aborts preparation. On the 100K build it compares 2,134 ranking positions across 562 queries and agrees to within 4.6e-5 cosine. There is deliberately no --limit: the size is a property of the dataset name, so two runs reporting the same name uploaded the same corpus. Also raises Milvus' hardcoded VarChar max_length from 500 to Milvus' own maximum (65535). That value is a server-side REJECTION threshold in UTF-8 bytes, not a hint, and 500 would have refused every MS MARCO passage (segment bodies reach ~28,600 bytes). The field-JSON builder is extracted into a pure function so the cap is assertable without a live Milvus. Pieces: - src/msmarco.rs: float16 NPY header parsing, a streaming f32 NPY writer (the existing write_npy_vectors flattens the whole corpus in RAM, which 1M x 1024 cannot survive), an 8-lane dot product, the block-fed top-k, and the verification logic. 24 unit tests, including a guard that every variant matches its datasets.json entry field for field. - src/bin/prepare_msmarco.rs: I/O only — ranged/streamed HF downloads, the two passes, and a PREPARED.json manifest recording provenance, shards consumed, per-field maximum byte lengths and the verification numbers. Verified live: prepared the 100K variant against real HF data and ran it end-to-end on Redis 8.8.0 (100,000 docs, recall 0.8975, MRR 1.0 at ef=128, 626 QPS), with docid/url indexed as TAG, title/headings/segment as TEXT and start_char/end_char as NUMERIC, zero indexing failures, and full-text, numeric-range and exact-tag queries all returning correct hits. make check, clippy -D warnings, and 165 lib + 903 binary tests are green. NOT verified live: the Milvus max_length change. `make integration-test-milvus` cannot start here — the compose stack fails pulling minio/minio ("pull access denied"), unrelated to this change — so it is unit-tested only and wants a run on a machine that can pull those images. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(msmarco): brute-force top-1000 ground truth; address first-pass review Ground truth depth 100 -> 1000, matching the depth of the upstream top1k_* lists this corpus is built from, so recall@k is answerable for any k up to 1000 without re-preparing. That has a consequence worth stating plainly: a search config that does not set `top` derives it from the ground-truth row width, so these datasets are otherwise searched at top=1000. Measured on the 100K corpus with Redis, the shipped redis-docker-test config goes from 626 QPS (top derived as 100) to 40.7 QPS (top derived as 1000). Documented in the README, the datasets.json descriptions and on NEIGHBOURS itself; set `top` explicitly for a conventional recall@10/@100 run. Preparation cost rose 29.7s -> 39.7s and tests.jsonl 39 MB -> 79 MB for the 100K variant. Review items addressed: 1. The cross-check coverage is now a FLOOR, not just a report. It only aborted when no query had a single in-prefix hit, so a changed upstream export or a mis-mapped offset would still print a reassuring "Verified N queries / M positions" with the guard effectively gone. coverage_floor() derives the bar from the prefix size (a quarter of what a uniform distribution would retain, minimum 100 positions, plus a 5% query floor); the 100K build clears 83/369 with 562/2134. 2. Milvus max_length is no longer an unquantified claim. Fixed the compose stack first: minio stopped publishing this release tag to Docker Hub, so `minio/minio` fails with a misleading "pull access denied ... repository does not exist", killing the whole Milvus job before a single test runs -- on master too, not just here. The tag is still on quay.io, which this file already uses for etcd. With that, make integration-test-milvus runs: 15/15 pass at 65535. Then an A/B on identical data, two collections differing only in the declared cap: identical rowCount (5000/5000), identical keyword INVERTED filter (100/100) and identical TEXT_MATCH analyzer result (4879/4879) -- the cap is a validation bound, not an allocation. The old 500 meanwhile rejects a 676-byte body outright ("length of varchar field body exceeds max length"), which is the failure this change exists to fix. 3. Sized the Redis TEXT SORTABLE cost the review could not size from the diff. Identical index, SORTABLE the only difference: sortable_values_size_mb 170.4 vs 0, inverted 171.3 vs 174.1, vector index 427.7 both. ~1.7 KB/document purely additive -> ~1.7 GB at 1M, ~17 GB at 10M. Recorded in the README as a sizing note. NOT changed here: `text` -> SORTABLE affects all 58 pre-existing datasets, which is a wider blast radius than this PR should carry. 5. Dropped the tautological loop in the Milvus cap test (it could not fail independently of the assert above it) and moved the measured byte figures into the doc comment where they are documentation. Added msmarco::cap_violations(), which checks a prepared corpus's measured per-field maxima against the caps engines actually enforce (Milvus VarChar 65535 for keyword/text/uuid, Elasticsearch keyword term 32766 for keyword only) and warns at preparation time -- so PREPARED.json's maxima are read by something instead of only recorded. 6. is_complete()'s doc comment now tells the truth. Every output is written to <name>.part and published by rename only after verification passes, so a directory either holds a complete cross-checked corpus or is untouched. An interrupted --force rebuild now also leaves the previous good corpus intact rather than overwriting it with a partial one. 7. ensure_finite() rejects a non-finite embedding in both passes. A NaN defeats the `score <= worst` fast path (every comparison with NaN is false) and then lands at an arbitrary rank via unwrap_or(Equal); queries with in-prefix hits would abort, but the 1,115 with none would publish a polluted head silently. A test pins that this is what would happen without the check. Also documented that the id tie-break is only delivered because blocks arrive in ascending id order, with a debug_assert so parallelising pass B cannot quietly change published ground truth. Item 4 (Valkey's text->TAG degradation re-splitting on `;` inside MS MARCO prose) is left for a separate issue as the review suggested: nothing published today is wrong, since these entries carry no `conditions`. Also fixes two clippy lints that only fire on the toolchain CI uses (1.98.1, which this machine now matches): chunks_exact_to_as_chunks and manual_checked_ops. make check, clippy --all-targets -D warnings, 165 lib + 903 binary tests and 15/15 Milvus integration tests all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(msmarco): cover the cross-shard offset arithmetic no shipped build reaches Shard 00 alone holds 1,760,180 passages, so both the 100K and the 1M variant live entirely inside it. Every live run so far therefore proves nothing about `first_id` accumulation across a shard boundary — only the 10M build crosses one, and nobody runs that casually (55 GB on disk). Moves the planning arithmetic out of the preparer's network loop into `msmarco::plan_shards(shard_rows, limit)` and tests it directly: offsets accumulate contiguously, only the last shard may be partially consumed (the tail check in pass A depends on that), a prefix ending exactly on a boundary does not open the next shard, and a limit past the end of the corpus is an error rather than a quietly short corpus. One test pins that the 10M variant is in fact the one that spans shards, so the claim stays true if the registered sizes change. The preparer keeps the network half — a 512-byte ranged GET per shard header, never the multi-GB payload — and delegates the arithmetic. Behaviour is unchanged: re-verified live on the 100K build (562 queries / 2134 positions, max cosine delta 4.63e-5, floor 83/369). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(msmarco): Milvus cannot ingest the 1M/10M prefixes — say so Preparing the 1M variant end-to-end surfaced this through the cap check added in the previous commit, which is the first thing it has caught: ⚠ WARNING: headings (text) reaches 185752 bytes, above the Milvus VarChar cap of 65535 — that engine will REJECT inserts for this dataset The data is genuine, not a decode bug. MS MARCO carries real web-page `headings`, and a few pages have pathological ones — the worst is a single document's table of contents at 185,752 bytes. 40 of the first 1M passages (0.004%) are above 65535. 65535 is Milvus' OWN ceiling for a VarChar column, so this is not something a larger declared cap can fix. It qualifies the claim made when the cap was raised from 500: that change lets Milvus run the 100K variant, and it does, but Milvus cannot ingest the 1M or 10M ones at all. Every other engine takes the corpus unchanged (Redis/Valkey/Dragonfly TEXT, Elasticsearch/OpenSearch analyzed `text`, Weaviate, pgvector, MongoDB all have no comparable limit; the `keyword` fields docid/url peak at 43 and 987 bytes, well inside the 32766-byte Elasticsearch term limit). Recorded rather than left to a console warning that scrolls away: `engine_cap_violations` in PREPARED.json, the per-variant descriptions in datasets.json, a README paragraph, and the VARCHAR_MAX_LENGTH doc comment. 1M build verified otherwise: 1,505 queries / 17,523 ranking positions cross-checked against the shipped global top-1k, max cosine delta 4.85e-5, 82 tie reorderings, against a floor of 83/3693. 330s to prepare, 5.4 GB on disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(msmarco): publish prepared 100K/1M corpora to S3 and register their links Generated on an i7i.metal-24xl (96 cores) and published to the usual benchmarks.redislabs bucket, so these now auto-download like every other dataset instead of requiring the preparer: vecsim/msmarco-cohere-1024/msmarco-cohere-1024-100K-cosine.tgz 288 MiB vecsim/msmarco-cohere-1024/msmarco-cohere-1024-1M-cosine.tgz 2.51 GiB Both carry the same per-object `public-read` ACL the other datasets in that bucket use (the bucket has no policy and no public-access block — per-object ACLs are the established mechanism there, not something invented here). **Reproducibility is verified, not asserted.** The 1M corpus generated on the metal host has byte-identical SHA-256 sums for vectors.npy, payloads.jsonl and tests.jsonl to the one generated on a 16-core workstation, and the 100K corpus round-trips through tar -> S3 -> download -> extract byte-identically as well. Different core counts mean different rayon thread counts, so this is the evidence that the 8-lane dot product and the id tie-break really do make ground truth independent of how many threads ran — the property the corpus-identity claim rests on. The host run also reproduced the cross-check numbers exactly (1505 queries / 17523 positions, max cosine delta 4.85e-5, 82 tie reorderings). The drift guard's "must have no link" assertion is replaced by a stronger one now that links exist: a link must end in `/<dataset name>.tgz` and sit under the published prefix. An entry pointing at another size's artifact is exactly this repo's silent-corpus-identity failure — the row-count gate would reject a corpus that is fine, and a same-length mismatch would pass unnoticed. Mutation-checked by pointing the 1M entry at the 10M tarball. Adds `experiments/configurations/redis-msmarco.json` (M=32, EF_CONSTRUCTION=256, an EF sweep at top=10 plus one at top=100). It sets `top` explicitly, which is the point: without it these datasets derive top=1000 from their ground-truth width. Measured with it on the 1M corpus against Redis 8.8.0 on the i7i: top=10 EF=64 6081 QPS recall 0.9239 MRR 0.9922 p50 1.29ms top=10 EF=128 4728 QPS recall 0.9572 MRR 0.9970 p50 1.67ms top=100 EF=256 3080 QPS recall 0.9570 MRR 1.0000 Upload was 192.5s for 1M passages (5194 docs/s) including 1.5 GB of payload text. The end-to-end download path is verified too: with the local corpus removed, the benchmark fetched the 100K tarball from the registered link, extracted it and ran to recall 0.9793 at top=10/EF=128. 10M is still generating on the same host and will be published separately; it is the first run to exercise the multi-shard path (6 shards, 488,132 rows taken from shard 5), with cross-check coverage of 1675/1677 queries and 197,898 ranking positions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(msmarco): flattened-ef guard, a real ordering check, pinned upstream revision Second-pass review items 1-4. **1. The `ef` sweep really is flattened at derived `top` — measured.** Ground truth 1000 wide means a config without `top` searches at k=1000, and HNSW raises effective search breadth to at least k, so any declared ef below 1000 never takes effect. On the prepared 100K corpus, same three ef values: top derived (=1000): ef 64 -> 0.9419 @ 45.1 QPS ef 128 -> 0.9419 @ 45.7 QPS ef 512 -> 0.9419 @ 45.5 QPS <- one measurement, three names top set (=10): ef 64 -> 0.9514 @ 3536 QPS ef 128 -> 0.9794 @ 2195 QPS ef 256 -> 0.9911 @ 1576 QPS <- a real Pareto curve Identical recall to four decimals across an 8x ef range is not a QPS surprise, it is a sweep publishing one point under several config names. Prose in the README was not a guard, so there is now a real one: before the search phase, any config that omits `top` and declares an `ef` below the ground-truth row width is named in a warning that says what will happen and why. Verified to fire on a derived-top sweep (naming all three configs) and to stay silent for redis-msmarco.json, which sets `top`. It warns rather than aborts: the behaviour is inferred from one engine's measurement, and the run is redundant rather than wrong. **2. The ordering guard was a `debug_assert!`, i.e. absent where it mattered.** There is no `[profile.release]`, CI runs every suite `--release`, and the preparer is documented as `cargo run --release` — so the assertion was compiled out of exactly the binary that writes published ground truth, while a doc comment three lines away claimed it protected the tie-break. Now a returned `Err`, pinned by a test that feeds `add_block` a descending `first_id`, and the suite is run under both profiles to prove the guard survives `--release`. This is the "guard one notch weaker than the bug it names" shape, caught in my own code by the reviewer rather than by me. **3. Upstream was fetched from `/resolve/main/`.** The whole corpus-identity claim — that a dataset name denotes fixed bytes — held only for the passage count against a moving ref; a re-export would have silently redefined what the name means, with nothing in PREPARED.json recording which export a directory came from. Now pinned to commit e78737fe, asserted by a test that rejects any `/resolve/main/` URL and requires a 40-char sha. **4. The coverage floor fired after the download and the brute force.** Both of its inputs are bounded by numbers `load_queries` already computes, so the same floor now runs as an upper bound before a single shard is fetched. On the 10M build that is seconds instead of 54 GB and ~20 minutes. Tested to agree with the post-hoc check on the real 100K and 10M numbers. Also drops a tautological assertion I had added to the release-profile test (`cfg!(x) || !cfg!(x)`) — the release run is the evidence, not the assert. 10M prepared and verified on the i7i meanwhile, exercising the multi-shard path for the first time: 6 shards, 1675 queries / 197,898 ranking positions cross-checked (floor 83/36,931), max cosine delta 5.96e-5, 1189s, 54 GB. Its `headings` maximum is 531,861 bytes — 8x Milvus' ceiling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(msmarco): add uniformly-sampled (crc32) twins of the three variants The prefix variants take the first N passages, and the corpus is docid-ordered (which tracks URL), so they are alphabetically bounded slices: the 100K prefix spans `0-60.reviews` to `acqnotes.com`, and `url` contains "nih" zero times in it. This adds `-crc32-` twins that keep every passage whose docid hashes below a threshold — uniform over the whole 113.5M-passage corpus. **Measured before building anything.** The obvious worry is that the prefix is geometrically skewed too, which would undermine the recall numbers already published. It is not (20k-row blocks, cosine on unit vectors): shard 00 head (what the prefix takes) nn=0.8825 rand=0.1442 shard 29 middle nn=0.9117 rand=0.3090 shard 59 head nn=0.9316 rand=0.3699 spread across all 60 shards nn=0.8759 rand=0.1499 The prefix is indistinguishable from a corpus-wide spread sample; the LATER shards are the outliers. So the prefix variants stand for pure-KNN recall and the sampled ones are additions, not replacements — the skew is confined to the payload distribution, which is what they fix. `zlib.crc32(docid) % N` is the selection the Redis Enterprise MS MARCO suite uses, so picking the same function makes the two benchmarks' corpora comparable by construction. `crc32_bucket` is pinned bit-identical to zlib by tests using vectors taken from zlib itself; drift there would silently decouple them. Thresholds come from a real scan, not arithmetic: `--discover-crc32` streams all 60 metadata shards (never the embeddings) and histograms the buckets, so one pass sizes every possible threshold via prefix sums. It counted exactly 113,520,750 passages, matching the dataset card. 886 / 8805 / 88075 realize 99,964 / 1,000,044 / 9,999,959 and nest, so each sampled variant is a strict subset of the larger ones. A hash threshold cannot land on a round number, so the round number lives only in the name and `vector_count` carries the realized count. Preparation hard-errors if the two ever disagree — that is how a changed upstream export would announce itself. Paths are named by THRESHOLD (`crc32-t8805`), not by a round size: `1M-crc32` would advertise 1,000,000 points the corpus does not have, which config.rs's #224 guard rejects on precisely the reasoning that makes it wrong. That guard caught this naming; the fix is the one its own doc comment prescribes. Mechanics the sampled path needed: * `DeferredNpyF32Writer` — the row count is unknowable until the last shard is read, so it reserves the header, streams, then seeks back and patches it. A test pins that every count from 0 to 113,520,750 renders to the same 128-byte header, which is the one way the design could silently shift every vector. * Concurrent shard reads. A single CDN stream measured ~12 MB/s, 16 measured 265-325 MB/s — a 22x difference that turns a ~2h scan into ~2 minutes. Shards write per-shard temp files and are concatenated IN SHARD ORDER, because local ids must follow corpus order: `TopK::add_block` requires ascending ids and the ids are what ground truth is keyed by. * Shipped top-1k offsets are remapped through the selection to local ids. The oracle argument never depended on the subset being a prefix, only on its being a subset, so it carries over unchanged. Six variants registered now, three per family. 44 lib tests + 903 binary tests green in both debug and release, clippy -D warnings clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(msmarco): measured metadata coverage, prefix vs uniform A 100K uniform sample spans the whole alphabet (35 distinct leading host characters, 1000naturalremedy.com to zyto.com) while the 100K prefix covers 11 (0-9 and 'a', stopping at acqnotes.com) — and even the 10M PREFIX only reaches 'w'. Concretely: 'nih' appears in 173 urls of the uniform 100K and 0 of the prefix 100K; 'wikipedia' in 6,401 vs 0. That is the whole case for the -crc32- twins, so it belongs in the README as numbers rather than as an assertion that the prefix is 'skewed'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(msmarco): review items 5-8 — verify-in-place, 10x tighter oracle, digest pin **5. The cosine tolerance was ~40x the largest disagreement any real build produces**, and it doubled as the tie window, so a differing id was accepted as a tie anywhere inside it. Settling that needed a way to re-run the cross-check without rebuilding, so `--verify` now does exactly that: everything the check needs is already on disk, and it matches shipped hits by DOCID rather than by offset, which makes it work for both sampling modes and independent of the offset arithmetic the build used. It reproduces a build's numbers exactly (562 queries / 2,134 positions / 4.63e-5 on the 100K), which is itself a check. Measured across all three corpora at decreasing tolerances: corpus positions max delta ties@2e-3 @2e-4 @1e-4 @5e-5 100K 2,134 4.63e-5 0 0 0 0 1M 17,523 4.85e-5 82 82 82 82 10M 197,898 5.96e-5 5,406 5,406 5,406 - The counts do not move as the window shrinks 40x, so every tie is genuine and nothing was being waved through. Tolerance is now 2e-4 — 10x stricter, still ~3.4x headroom over the worst observed delta. `--verify` is worth having on its own: it re-validates a downloaded corpus in minutes rather than re-deriving it. **6. minio pinned by digest.** The failure this replaced was a tag moving out from under us; a tag on a second registry is the same promise from a different party. `quay.io/minio/minio@sha256:14cea493...` makes the pin mean one immutable image, verified to pull. **8. `publish()` now has a completion marker written last**, required by `is_complete`. Four renames are four operations: dying between the second and the fourth left a new PREPARED.json + tests.jsonl over an old payloads.jsonl + vectors.npy, which the previous "three files exist and the row count matches" check accepted. Note this means a corpus prepared before this change (or extracted from an already-published tarball) has no marker and will be rebuilt rather than skipped — it fails safe, toward re-deriving. Item 7 (the RAM figures) is left for a measured number rather than a bigger estimate; peak RSS will be captured from the 10M Redis run. Also: 1M-crc32 built and verified on the fleet host — 1,000,044 of 113,520,750 selected (0.8809%), and 1,676 of 1,677 queries retain hits against the prefix 1M's 1,505. The uniform sample keeps giving broader oracle coverage because it touches the whole corpus instead of one stretch of the alphabet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(msmarco): publish and register the 1M uniform corpus msmarco-cohere-1024-1M-crc32-cosine.tgz (2.8 GiB) uploaded with the same per-object public-read ACL the bucket's other datasets use, anonymously reachable, link registered. 1,000,044 passages selected from all 113,520,750 (0.8809%); 1,676 of 1,677 queries retain shipped top-1k hits, against 1,505 for the same-size prefix — the uniform sample keeps buying broader oracle coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(msmarco): register the 10M uniform corpus 10M-crc32 built and verified on the fleet host: 9,999,959 of 113,520,750 passages selected, and the cross-check reached ALL 1,677 queries across 147,422 ranking positions — the broadest oracle coverage of any variant, and the only one where no query goes unchecked. Max cosine delta 5.14e-5, 2,448 tie reorderings, 1212s. Note the shape of the prefix-vs-uniform difference across sizes: the prefix 10M compares MORE positions (197,898) but across FEWER queries (1,675), because its hits concentrate in one stretch of the alphabet. The uniform variants trade depth per query for breadth across queries, which is the better property for an oracle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(msmarco): oracle coverage across all six corpora The cross-check can only speak for a query that retains a shipped top-1k hit, so coverage-in-queries is the number that matters, and it is where the two families differ most: prefix 100K 562/1677 uniform 100K 1005/1677 prefix 1M 1505/1677 uniform 1M 1676/1677 prefix 10M 1675/1677 uniform 10M 1677/1677 At equal size the uniform variants reach far more queries because their hits spread across the corpus instead of concentrating in one stretch of the alphabet. They compare fewer positions in total but across more queries — the better trade for an oracle. The uniform 10M is the only variant where no query goes unchecked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(msmarco): third-pass review — a real regression, a half-blind guard, wrong figures **1. `9aa5d38` broke the prefix path and I did not notice, because every live prefix build in this PR predates it.** `wanted_offsets` was widened to the full shipped top-1k (~1.6M offsets) for the sampled path, and the prefix branch was handed the same unfiltered set. `write_corpus` can only record a docid for a row it writes, so its completeness check fired on a perfectly correct corpus: prepare-msmarco: error: 1592762 of the 1594767 offsets named by the shipped top-1k were never given a docid while writing the corpus — the offset arithmetic is wrong A confident and entirely wrong diagnosis. Reproduced, fixed, and re-verified: the 100K prefix now builds and is byte-identical (all three SHA-256s) to the published artifact, so no shipped corpus was ever affected. The fix is `msmarco::offsets_to_track(shipped, sampling, limit)` — in the library, under test, with the two modes' answers asserted to differ. The reviewer's diagnosis of WHY it escaped is the useful part: "logic in msmarco.rs, binary is I/O only" is the convention, and this was offset-selection logic that ended up in the binary with no test to mutate. **2. The flattened-ef guard only understood one engine family's knob.** It read the typed `ef`, but pgvector and qdrant use `hnsw_ef` and elasticsearch, vertex and mongodb use `num_candidates` — both in the untyped `extra` catch-all, which a typed read cannot see. Those families flattened silently, which is the exact state the warning exists to name. It now resolves all three spellings. It also fired where its message was false: `redis-single-node.json` carries two `ef: 64` entries per config differing only in `parallel`, so against a 100-wide dataset it printed 12 warnings claiming "several names, one measurement" about a single sub-width value. Now requires TWO OR MORE DISTINCT sub-width values. Verified both ways: fires on the real 3-value flattening, silent on redis-single-node + random-100. **6. "Comparable by construction" with the RS suite was overstated.** Same hash, different modulus: 100 divides 1,000,000 and buckets 0..885 span every residue mod 100, so our 886/1e6 sample and their 1/100 sample overlap in ~1% of our passages and neither nests in the other. What is shared is an auditable selection rule, not a document set. Corrected in the README and all three descriptions. **7. Three figures that did not multiply out.** Measured from the HF tree API: `passages_jsonl` is 26.9 GB gzipped (not ~100 GB) and `passages_npy` is 232.5 GB, so a full build scans 259 GB, not ~330 GB. Both numbers corrected everywhere they appear. **3, 4, 8, 9.** AGENTS.md and the binary's module doc still said the msmarco entries have no `link` — all six now do, and all six are anonymously reachable (verified 200). The `TopK` doc still justified its sorted-Vec on "k is 100" after NEIGHBOURS became 1000. The flattened-ef comment carried a QPS triple from a different run than the commit body quoted; both are real, the throughput is noise at that k, and the comment now says so rather than picking one. Item 5 is resolved by events: all three crc32 corpora are now built, verified and published. Item 10 (minio digest pin) landed in 0cbc436. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(msmarco): measured 10M Redis results and peak RSS (review item 7) Ran the 10M prefix corpus against Redis 8.8.0 on the i7i with /usr/bin/time -v, so item 7's RAM figure is measured rather than argued: top=10 EF=64 5507 QPS recall 0.9073 MRR 0.9672 top=10 EF=128 4242 QPS recall 0.9364 MRR 0.9773 top=10 EF=256 2797 QPS recall 0.9437 MRR 0.9905 upload 3399 s (2942 docs/s, about half the 1M rate — HNSW insert cost grows with graph size) wall 58m32s peak RSS 80,485,728 KB = 80.5 GB **The measurement contradicts the review's reasoning, and agrees with the number.** The concern was that the README's "~80 GB" counted only the vector half and that payloads-as-MetadataItem would push the real ceiling well above it. It does not: the high-water mark is the vector read alone, where `read_npy_vectors` holds the `Array2` and the `Vec<Vec<f32>>` it is converting into at once — 2 x 40.96 GB. The 16 GB of payloads are read *after* that `Array2` is dropped, so the two peaks never coincide. So the figure stays ~80 GB but the README no longer implies it is the vector half of a larger total; it now says what the peak actually is and why, which is the part someone sizing a machine needs. Rule of thumb: 2x the vector bytes, plus Redis in its own process. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: make the repo agent-friendly (Claude + Codex), and fix what was stale `AGENTS.md` still opened with "Supports Redis (RediSearch) and VectorSets engines" and "only Redis and VectorSets are ported" — the repo has **15**, and its own table four lines below listed eleven. An agent reading that starts from a false model of the codebase, so this is a correctness fix, not tidying. **`make agent-check`** — the highest-leverage addition. It reproduces the entire no-Docker CI gate in one command, in CI's order and profile: cargo fmt --check cargo clippy --all-targets -- -D warnings cargo test --lib --bins --release cargo test --test {integration_cli,overhead_invariants,harness_invariants} --release This exists because `make check` is `fmt-check` + `lint` and **runs no tests**, while Workflow Rule 1 said "always run `make check`" — so following the instructions exactly produced a green local run that CI could fail. Both facts are now stated where they will be read, including in `make help`. `--release` is called out explicitly: CI runs the suites in release, so a `debug_assert!` does not exist there. That is not hypothetical — a guard added earlier in this branch was compiled out of precisely the binary that writes published ground truth. **Four engines had integration tests but no make target** (dragonfly, kividb, chroma, vertex) while Rule 5 says "never bypass make targets" — the rules made those suites unrunnable. Added, matching CI's ports and env vars; vertex is listed explicitly as cloud-only so its absence from the Docker targets is a documented fact rather than an oversight. Also: refreshed the structure block (it predated the sparse/multivector readers, `msmarco.rs`, `query_filter.rs`, `prepare-msmarco` and the invariant suites), completed the engine table, and added a "Traps that have actually cost time here" section — each entry a real incident, each one invisible while you are doing it. `CLAUDE.md` was a single line reading `AGENTS.md`. It now points there explicitly and carries the four things worth knowing if nothing else gets read, so Claude and Codex work from one set of instructions that cannot drift apart. Verified: every make target and every source path named in AGENTS.md exists, and `make agent-check` passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Revert "docs: make the repo agent-friendly (Claude + Codex), and fix what was stale" This reverts 8e84d53. The agent-instructions work is unrelated to the MS MARCO datasets — it touches no dataset, engine or benchmark code — and has moved to its own PR (#320) branched from master. Reverted rather than dropped from history because this repo does not force-push. Net effect on this PR's diff: AGENTS.md and the Makefile now carry only the MS MARCO section and the `prepare-msmarco` target, which do belong here; CLAUDE.md is back to matching master exactly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Split out of #319, where it had no business being — this touches no dataset, engine or benchmark code.
The instructions were wrong in ways that mislead an agent
AGENTS.mdopened with "Supports Redis (RediSearch) and VectorSets engines" and "only Redis and VectorSets are ported". The repo has 15 engines, and the table four lines below already listed eleven of them. An agent reads the overview first and builds its model of the codebase from it, so this is a correctness fix rather than tidying.Two of the Workflow Rules were self-defeating:
make check" — butmake checkisfmt-check+lintand runs no tests. Following the instructions exactly produces a green local run that CI can fail.make agent-checkOne command that reproduces the entire no-Docker CI gate, in CI's order and profile:
If it passes, the non-Docker CI jobs pass.
make checkandmake helpnow both say whatcheckdoes not cover.--releaseis called out deliberately. CI runs the suites in release, so adebug_assert!does not exist there — which is how a guard ends up absent from precisely the binary that ships. Cheap to state, expensive to rediscover.Also
integration-test-*targets, matching CI's ports and env vars. Vertex is listed explicitly as cloud-only, so its absence from the Docker targets reads as a documented fact rather than an oversight.query_filter.rs, and theharness_invariants/overhead_invariantssuites.CLAUDE.mdwas a single line readingAGENTS.md. It now points there explicitly and carries the few things worth knowing if nothing else is read — one instruction set for both tools, so they cannot drift apart.Verification
Every make target and every source path named in
AGENTS.mdwas checked to exist on this branch, andmake agent-checkpasses.One judgement call worth flagging: I kept a single
AGENTS.mdwithCLAUDE.mdas a thin pointer rather than duplicating guidance. Two files saying almost the same thing is how instructions go stale. Easy to split later if Claude and Codex ever need divergent advice.🤖 Generated with Claude Code