fix(skip-flags): --skip-upload must reuse the corpus, not destroy it (#238) - #271
Merged
Merged
Conversation
…238) `--skip-upload` exists to reuse an already-loaded corpus. With `--skip-vector-index` also set, `experiment.rs` still routed through `Engine::configure()` — which is destructive on 13 of the 15 engines — so the flag pair that means "do not upload, do not build an index, just use what is there" deleted the corpus first and then benchmarked the empty index, printing a QPS number and exiting 0. Measured live, before/after server-side row counts on the unfixed binary: redis FT.INFO num_docs 400 -> 0 (FT.DROPINDEX idx:redis-no-vector DD) still reported QPS 825.2 valkey DBSIZE 400 -> 0 (FT.DROPINDEX + SCAN/UNLINK) mongodb countDocuments 400 -> 0 (collection.drop()) still reported QPS 118.2 / 61.1 Semantics, now predictable from the flag name: --skip-upload = "the server already holds the corpus I want; do not create, drop, recreate or otherwise modify it." 1. configure() is never called on the --skip-upload path, in any flag combination. The removed arm was also unnecessary: --skip-vector-index rewrites the config name to `<engine>-no-vector`, so the prior `--skip-vector-index --keep-data` upload already left exactly the schema-only index the second phase needs. 2. New `Engine::corpus_row_count()` reads the row count back OFF THE LIVE SERVER (FT.INFO num_docs, GET /collections/<n> points_count, _count, countDocuments, SELECT count(*), VCARD) and the runner compares it with the dataset's measured corpus size before measuring anything: - fewer rows than declared, including zero (missing index) -> hard error; it changes the reported number, and nothing else catches it. A half-deleted Qdrant collection answers every query without error and reports mean_recall: 1.0. - more rows than declared -> warning. - engine cannot count -> printed note that the reuse went unverified. `--allow-partial-corpus` downgrades the hard error to a warning. Implemented for redis, valkey, dragonfly, kividb, vectorsets, qdrant, elasticsearch, opensearch, pgvector and mongodb; chroma, milvus, weaviate, turbopuffer and vertex keep the default `Ok(None)` note. One adjacent minimal touch: mongodb's `search()` now primes its schema-type cache, which configure() used to do — otherwise a numeric filter on the --skip-upload path would be built against string literals and match nothing. Tests: 2 live integration tests (both verified RED against 595740f: "destroyed the indexed corpus (400 -> 0 docs)" and "must be a hard error, but the run succeeded" with Recall 0.6000 printed on a half-deleted corpus) + 11 unit tests. Closes #238 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…238) `--skip-upload` exists to reuse an already-loaded corpus. With `--skip-vector-index` also set, `experiment.rs` still routed through `Engine::configure()` — which is destructive on 13 of the 15 engines — so the flag pair that means "do not upload, do not build an index, just use what is there" deleted the corpus first and then benchmarked the empty index, printing a QPS number and exiting 0. Measured live, before/after server-side row counts on the unfixed binary: redis FT.INFO num_docs 400 -> 0 (FT.DROPINDEX idx:redis-no-vector DD) still reported QPS 825.2 valkey DBSIZE 400 -> 0 (FT.DROPINDEX + SCAN/UNLINK) mongodb countDocuments 400 -> 0 (collection.drop()) still reported QPS 118.2 / 61.1 Semantics, now predictable from the flag name: --skip-upload = "the server already holds the corpus I want; do not create, drop, recreate or otherwise modify it." 1. configure() is never called on the --skip-upload path, in any flag combination. The removed arm was also unnecessary: --skip-vector-index rewrites the config name to `<engine>-no-vector`, so the prior `--skip-vector-index --keep-data` upload already left exactly the schema-only index the second phase needs. 2. New `Engine::corpus_row_count()` reads the row count back OFF THE LIVE SERVER (FT.INFO num_docs, GET /collections/<n> points_count, _count, countDocuments, SELECT count(*), VCARD) and the runner compares it with the dataset's measured corpus size before measuring anything: - fewer rows than declared, including zero (missing index) -> hard error; it changes the reported number, and nothing else catches it. A half-deleted Qdrant collection answers every query without error and reports mean_recall: 1.0. - more rows than declared -> warning. - engine cannot count -> printed note that the reuse went unverified. `--allow-partial-corpus` downgrades the hard error to a warning. Implemented for redis, valkey, dragonfly, kividb, vectorsets, qdrant, elasticsearch, opensearch, pgvector and mongodb; chroma, milvus, weaviate, turbopuffer and vertex keep the default `Ok(None)` note. One adjacent minimal touch: mongodb's `search()` now primes its schema-type cache, which configure() used to do — otherwise a numeric filter on the --skip-upload path would be built against string literals and match nothing. Tests: 2 live integration tests (both verified RED against 595740f: "destroyed the indexed corpus (400 -> 0 docs)" and "must be a hard error, but the run succeeded" with Recall 0.6000 printed on a half-deleted corpus) + 11 unit tests. Closes #238 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Docker Build ValidationPlatforms tested:
Tests performed:
|
…d honest Review round on #271. Nine blockers/must-fixes. A. `--skip-upload` alone still destroyed the corpus. `configure()` is not the only destructive call: `engine.delete()` runs at the end of every successful experiment unless `--keep-data`, which defaults to FALSE. Measured on this branch before the fix: qdrant 400 points -> collection MISSING, vectorsets `VCARD idx` 400 -> 0, redis `DBSIZE` 400 -> 0, all exit 0. `keep_data` is now forced on for `--skip-upload`: a run that did not create the corpus never deletes it. Both prior regression tests passed `--keep-data`, so neither covered this; two new ones (redis + qdrant) do. B. KiviDB could not count. Its `FT.INFO` has no `num_docs` — it reports `hnsw_live_count` — so the guard silently degraded to "cannot count" while the docs claimed coverage. `ft_info_num_docs` now reads either. C. A probe failure was fabricated into a corpus size of zero. `Err(_) => Ok(Some(0))` made the trait's own error path unreachable, so a `NOPERM` FT.INFO, a refused gRPC port or an unreachable mongod all reported "the corpus is empty or missing" over an intact corpus — pointing the user at the wrong problem and inviting a destructive re-upload. Errors now propagate; only a reply that names the index/collection as absent counts as 0. Qdrant additionally passed `points_count` through as an Option instead of `unwrap_or(0)`; ES/OS now reject a non-2xx (a CLOSED index is HTTP 400 over an intact corpus) and a reply with failed shards. D. A wrong `vector_count` disabled the guard. `corpus_completeness_target()` turns a declared-vs-measured conflict into an `Err`, which degraded to "cannot verify" — so an UNDER-declared count waved through the exact half-empty corpus the guard exists to catch (`Recall: 0.0000`, exit 0). The check now measures the corpus and warns about the bad declaration. E. The verdict never reached the artifact. Result files carry `params.corpus_reuse` (status, expected/actual rows, estimate flag, whether `--allow-partial-corpus` waived it) and the summary carries `rejected_experiments`, so a sweep that lost most of its configs no longer charts as a complete one. F. Test gaps. `Engine::name()` is the CONFIG name, so configs named `redis-*` let a redis-only mutation of the guard survive; the configs are renamed (`cfg238*`) and the mutant now dies. New live tests for valkey, mongodb, qdrant and kividb — three of the four engines proven destructive were protected by prose. The Surplus arm now has runtime coverage. `integration_redis`/`integration_qdrant` gain the port override the other suites already had, so a session can test its own container. G. `configure()`-only state, now that "configure never runs here" is a documented contract. pgvector re-derives `distance_op` (empty splices as `embedding $1` and failed EVERY query after a green reuse check); turbopuffer re-derives `distance_metric` (defaulted to cosine — silently the wrong metric, no error); milvus re-derives `metric_type`; chroma resolves `collection_id` by name. All five Redis-wire engines now prime the commandstats baseline in `search()`: `None` means "RESETSTAT ran", so on the skip-upload path the check compared against zero while the counters held every failure since server start, failing runs in which nothing failed. Vertex already self-heals and is untouched. H. pgvector's count no longer warms the page cache. The engine never VACUUMs and forces STORAGE PLAIN, so `count(*)` seq-scans the whole heap (~1.1GB at 1M rows) immediately before the search phase, turning a cold run warm. It now uses ANALYZE + `reltuples`, and an ESTIMATE may only warn, never abort. I. Accuracy. 13 engines -> 14 (only Vertex is non-destructive), in the shipped comment as well as the docs. The qdrant `mean_recall: 1.0` claim is deletion-pattern-dependent, not a property, and is now qualified everywhere it appears. The trait doc says plainly that only the four Redis-wire engines address a per-config object, so elsewhere a sibling config's corpus certifies as this one's — cardinality is not identity. The "engine cannot count" wording no longer blames the database for a gap in this tool. Also: the rejection message and the README now name `--exit-on-error false` as the remedy for a sweep, because `--allow-partial-corpus` re-publishes exactly the number the guard suppresses. MongoDB's `search()` keeps its `load_schema_types` call with a comment that says what it actually is — defensive symmetry with redis/valkey, not a fix for a numeric-filter bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ly; no force-push) # Conflicts: # README.md # src/bin/vector_db_benchmark/engine/dragonfly.rs # src/bin/vector_db_benchmark/engine/elasticsearch.rs # src/bin/vector_db_benchmark/engine/kividb.rs # src/bin/vector_db_benchmark/engine/mod.rs # src/bin/vector_db_benchmark/engine/mongodb_engine.rs # src/bin/vector_db_benchmark/engine/opensearch.rs # src/bin/vector_db_benchmark/engine/pgvector.rs # src/bin/vector_db_benchmark/engine/qdrant.rs # src/bin/vector_db_benchmark/engine/redis.rs # src/bin/vector_db_benchmark/engine/redis_utils.rs # src/bin/vector_db_benchmark/engine/valkey.rs # src/bin/vector_db_benchmark/engine/vectorsets.rs # src/bin/vector_db_benchmark/experiment.rs # tests/integration_redis.rs
This was referenced Aug 8, 2026
Merging rather than rebasing: the branch is already published and rebasing it once produced a non-fast-forward that must not be resolved by force-push. #263 replaced the fixed-count `Barrier` in all 14 engine search harnesses with `start_gate`, touching the same worker-spawn and error-propagation paths this branch adds `corpus_row_count()` and the pre-search reuse guard to, so the merge is exercised rather than assumed: see the commit that follows for the gate results on this tree.
…assert it The original #238 finding was measurement integrity, not abort behaviour: `SELECT count(*)` pulled the whole corpus into the page cache immediately before the search phase, converting a cold-cache pgvector run into a warm one. Switching to `ANALYZE` + `reltuples` was asserted to fix that but never measured. Measured on a cold 1M-row / 768-dim table built exactly like this engine's (SERIAL PK + vector NOT NULL, STORAGE PLAIN, never VACUUMed; 3906 MB, 500000 relpages), postgres restarted between runs: heap blocks touched cache primed cold wall clock SELECT count(*) 500 000 3906 MB 1.58 s ANALYZE items 30 001 234 MB 0.84 s `EXPLAIN (ANALYZE, BUFFERS)` confirms the plan: `Parallel Seq Scan on items … Buffers: shared hit=96 read=499904`. The decisive property is not the 16.7x ratio but the bound: ANALYZE samples `300 * default_statistics_target` = 30 000 rows REGARDLESS of table size, so its footprint is constant while count(*) grows linearly. At 1536 dims (~7.8 GB) count(*) primes all of it; ANALYZE still touches ~30 000 blocks. Stated honestly in both the code and the README: bounded is not free. ANALYZE still primes ~234 MB, ~6% of this heap, so the perturbation is capped rather than eliminated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Docker Build ValidationPlatforms tested:
Tests performed:
|
fcostaoliveira
added a commit
that referenced
this pull request
Aug 8, 2026
…gh corpus_row_count The textual conflict was one import line. The real conflict was semantic and the merge tools could not see it: #271 added `Engine::corpus_row_count()` to `vectorsets.rs` as `VCARD idx` — the literal key — while this branch was moving every other site to the per-config `idx:<config>`. Merged naively, the corpus verification `--skip-upload` depends on would count a DIFFERENT key from the one the upload and the search use. That is not theoretical. Reproduced live on the merge, seeding the legacy `idx` with exactly the expected 400 rows: Experiment stage: Reuse check — server holds 400 of 400 expected rows → QPS: 1924.5, Recall: 0.0000, Precision@returned: 0.0000, MRR: 0.0000 exit 0 The guard passed on the wrong key, the search ran against an empty per-config key, and a recall-0.0 result file was published under a config name that claims otherwise — a brand-new silent-wrong result created by the merge itself, in the exact area both PRs are about. `corpus_row_count` now probes `self.config.key`, with a comment naming the hazard so a future merge does not re-introduce it. `vectorsets.rs` again contains the string "idx" exactly once: the default-base argument to `derive_index_name`. REVIEWER FINDING (ensure_index_exists): not fixed by adding a second mechanism, because #271 already shipped the right one. VectorSets has no `ensure_index_exists` analogue and `VSIM` on a missing key returns an empty array with no error, so "search succeeded" is not evidence. But `--skip-upload` now runs `check_corpus_reuse_precondition` → `corpus_row_count` → `VCARD <key>`, and 0 rows against a declared 400 is classified `Short` → hard error: --skip-upload: the corpus you asked to reuse is empty or missing — config 'vectorsets-migrate' holds 0 of the 400 rows dataset 'vs-migrate' declares. So the README's promise that "--skip-upload against a missing/mismatched index now hard-errors instead of silently writing a recall 0.0 file" is now TRUE for the fifth member of the family, via the mechanism the other four also route through for their row counts. A `VCARD`-based check inside `vectorsets.rs` would have been a second, redundant gate. New integration test `test_vectorsets_skip_upload_hard_errors_on_a_pre_236_corpus` pins exactly this. It seeds the legacy un-suffixed key with EXACTLY the expected row count, which is what gives it teeth: a `corpus_row_count` reading the old key sees 400 of 400, returns Ok, and the run proceeds — the RED transcript above. It carries a positive control (upload, then `--skip-upload` against the config's OWN corpus must succeed) so it cannot pass by rejecting everything, and it runs over a private base (`idx236mig`) rather than the global `idx`, because `test_vectorsets_two_configs_do_not_clobber_each_other` asserts `idx` is never written and two tests mutating it would race at default test threads — the very property #236 restored. Docs: README's "Per-config index isolation" section now names VectorSets in the heading, lists `VECTORSETS_INDEX_NAME` with the other four `*_INDEX_NAME` knobs and their `_EXACT` rule, describes the single-key shape (no doc-key prefix), gives the pre-#236 cut-off alongside the pre-#151-4 one, and explains why the hard-error promise holds for an engine whose search cannot fail. The `--skip-upload` section's destructive-configure list now reads `DEL <key>` rather than `DEL idx`. Gates on the merge commit (new baseline: master fd92603 = 872): cargo test --lib --bins → 874 passed, 0 failed lib 126 | vector-db-benchmark 746 | generate-dataset 2 | bench_hdf5/jsonl/npy 0 (+2 = the two new index_naming tests) cargo clippy --all-targets -- -D warnings → clean cargo fmt --check → clean cargo test --test integration_vectorsets → 10/10 at DEFAULT test threads (live redis:8.8.0) cargo test --test overhead_invariants → 9/9 Two-config coexistence re-verified on the merged tree, server-side: after A: VCARD idx:vectorsets-iso-a=400 (VDIM 8) | legacy VCARD idx=0 after B: VCARD idx:vectorsets-iso-a=400 (VDIM 8), VCARD idx:vectorsets-iso-b=400 (VDIM 16) | EXISTS idx=0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fcostaoliveira
added a commit
that referenced
this pull request
Aug 8, 2026
Merged, not rebased: the branch is published and the standing rule here is to add a commit rather than force-push. #271 adds a `corpus_reuse` argument to `build_search_result_json`; kept alongside `engine_params`. It also adds `--allow-partial-corpus", which the invocation guard immediately flagged as unrecorded — the first live catch by that guard, and exactly the flag whose presence an artifact most needs to state, since it suppresses the short-corpus refusal and therefore permits a wrong recall under a config name that claims otherwise.
fcostaoliveira
added a commit
that referenced
this pull request
Aug 8, 2026
…/test debt Follow-up to the merge commit, closing the three reviewer blockers. BLOCKER 2 — get_memory_usage over-reported once configs could coexist. It returns server-wide `INFO memory:used_memory`. That was CORRECT as a per-config figure before this PR: the hardcoded key plus a destructive `DEL` in `configure()` guaranteed exactly one resident corpus, so the server total WAS this config's cost. Making configs coexist silently turned it into the sum over all of them — 2× for two configs, N× for an N-config sweep, and the new coexistence test is exactly that scenario. A metric that was right became wrong as a side effect of a correctness fix, which is the worst way to lose one. `VINFO` carries no memory field (verified live), but the corpus is a single key, so `MEMORY USAGE <key>` attributes it exactly. Published as `index_memory_bytes`, the same field name redis/valkey/dragonfly/kividb already use for their per-index `FT.INFO` figure, so no consumer needs a special case. `used_memory` keeps its shape and meaning as the global secondary, with the caveat spelled out in a comment mirroring redis.rs's. Measured on the merged tree, two 400-doc configs on one server: A alone: index_memory_bytes=287,740 used_memory=1,985,664 B, with A resident: index_memory_bytes=287,740 used_memory=2,319,864 MEMORY USAGE idx:vectorsets-mem-a=287,740 idx:vectorsets-mem-b=287,740 The per-config figure is flat and matches the key; the global one grows. New test `test_vectorsets_index_memory_is_per_config_not_server_wide` pins it and is RED without the fix (`index_memory_bytes` absent → -1 for both configs). Its final assertion deliberately does NOT check that `used_memory` grew between the two runs: that is global state any concurrent test moves either way, and it flaked 1-in-3 in the full suite. It asserts a floor instead (`used_memory > 2 × index_memory_bytes`) — same point, and other corpora landing only make it safer. BLOCKER 1 leftovers — the stale docs and the missing test. `corpus_row_count`'s doc comment still said "The key is the hardcoded `idx` (issue #236): this count therefore cannot distinguish two configs sharing one server", and `engine/mod.rs`'s trait doc still listed VectorSets among the engines with "exactly ONE such object per server, shared by every config". Both were written before this PR and both now assert the opposite of the code. Rewritten: VectorSets is the fifth per-config member, and its count is what makes the `--skip-upload` promise true for an engine whose search cannot fail. `test_vectorsets_corpus_row_count_tracks_the_live_key` is the family test #271 shipped for kividb/mongodb/qdrant/valkey and not for vectorsets — which is why CI could not see the hardcoded probe. Same shape: upload, `VREM` half the corpus behind the tool's back, assert the run hard-errors with "holds 200 of the 400 rows". A constant, or a read of the old shared key, fails it. BLOCKER 3 — no new mechanism added, because #271 already provides one. With `corpus_row_count` reading the derived key, `--skip-upload` against a pre-#236 corpus classifies `Short` and hard-errors before anything is measured. An `ensure_index_exists` analogue would be a second, redundant gate on the same path. Covered by `test_vectorsets_skip_upload_hard_errors_on_a_pre_236_corpus` (added in the merge commit), which seeds the legacy key with EXACTLY the expected row count so a probe on the wrong key would certify 400-of-400 and let the run publish recall 0.0. Docs — the third stale README claim. The "#230 datetime corpora" section still read "`VCARD idx`" and "the engine uses a single hardcoded key (`idx`, issue #236), there is not even a name change for the tool to detect. A stale corpus of the right size is found, the run succeeds, and only the recall number is wrong." Every clause was falsified. It now separates the two cut-offs: a pre-#230 corpus IS found (right size, wrong encoding — the one remaining silent case), while a pre-#236 corpus is NOT found and is rejected, with the reason spelled out: `VSIM` on a missing key returns an empty array with rc=0, so `failed_queries` stays 0 and the empty search is FAST — recall 0.0 at inflated QPS is worse than a plain zero. Gates (baseline: master fd92603 = 872): cargo test --lib --bins → 874 passed, 0 failed lib 126 | vector-db-benchmark 746 | generate-dataset 2 | bench_hdf5/jsonl/npy 0 cargo clippy --all-targets -- -D warnings → clean cargo fmt --check → clean cargo test --test integration_vectorsets → 12/12 at DEFAULT test threads, three consecutive runs, no flake (master, measured the same way in a clean worktree three times: 1 passed / 6 failed) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Docker Build ValidationPlatforms tested:
Tests performed:
|
1 similar comment
Docker Build ValidationPlatforms tested:
Tests performed:
|
fcostaoliveira
added a commit
that referenced
this pull request
Aug 8, 2026
…uns stop clobbering one corpus (#278) Closes #236. VectorSets addressed a single hardcoded vector-set key `idx`, so every config on one server shared and clobbered the same corpus. The key is now derived per config — `derive_index_name("VECTORSETS_INDEX_NAME", "idx", &engine_config.name)` → `idx:<config>` — making VectorSets the fifth per-config engine alongside Redis/Valkey/Dragonfly/KiviDB, with a `VECTORSETS_INDEX_NAME_EXACT` escape hatch guarded against two-config collisions at startup. Measured on master, same test file, dedicated container: config A uploads 400 rows, then config B overwrites them — `VCARD idx:vectorsets-iso-a = 0` while the legacy `VCARD idx` holds 400. Master passes 0-2 of 7 depending on interleaving (a race, measured over 11 runs: 0/7 ×3, 1/6 ×7, 2/5 ×1); the branch is 12/12 at default threads ×3 and under `--test-threads=1`, with `KEYS *` empty after every run. A merge hazard with #271 was caught and fixed before it landed: `corpus_row_count()` probed `VCARD "idx"` hardcoded while every other site moved to the derived key. Git did not conflict on that line. Reproduced live before fixing — the reuse guard printed `Reuse check — server holds 400 of 400 expected rows`, then published `Recall: 0.0000` at inflated QPS and exited 0. The probe now names `self.config.key` and carries a MERGE HAZARD comment; the vectorsets `corpus_row_count` test that #271 shipped for four other engines is added. Memory is now attributed per config: `index_memory_bytes` from `MEMORY USAGE <key>`, published under the same field name the four FT engines use, degrading to `null` rather than to a wrong number. Measured live: 100 × 32-d → 73,274 B; 1000 × 32-d → 724,482 B. Scope, stated honestly: the fix is correct at all eleven call sites, but the test suite covers five. `vadd_single`, `delete`, `configure`'s `DEL` and both `VINFO` sites have no guard — filed as #293, along with the underlying reason a mixed-workload test cannot see it (`update_count` counts client-side attempts, never the server's acceptance into the searched corpus). Data isolation is fixed; measurement isolation is not, because `CONFIG RESETSTAT` remains server-global. Also filed: #291 (dead `src/vectorsets/`), #294 (the sanitize-collision guard path is untested for every engine). Reviewed by seven adversarial agents. 874 unit tests (872 + 2), integration 12/12 ×3, `overhead_invariants` 9/9, fmt and clippy clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
--skip-uploadexists to reuse an already-populated corpus. Two separate paths destroyed it instead.Path 1 —
configure(). With--skip-vector-indexalso set,experiment.rsstill routed throughEngine::configure(), destructive on 14 of the 15 engines (only Vertex is not). The flag pair meaning "do not upload, do not build an index, just use what is there" deleted the corpus, then benchmarked the empty index, printed a QPS number and exited 0.Path 2 —
engine.delete(). Cleanup runs at the end of every successful experiment unless--keep-data, which defaults to false. So the plainest form of the flag reused a corpus, measured it, and then deleted it. Found in review; both original regression tests passed--keep-data, so neither covered it.Empirical matrix (established live)
--skip-vector-indexis wired for redis / valkey / mongodb only (experiment.rs:274); every other engine's config is skipped with a warning beforeconfigure()is reached, so path 1 is reachable on exactly those three — and all three destroy. Path 2 applies to every engine.--skip-upload --skip-vector-index)--skip-upload, no--keep-data)FT.DROPINDEX … DDDBSIZE400 → 0FT.DROPINDEX+SCAN/UNLINKdelete())collection.drop(), unbounded: unlike redis/valkey's namespace-scopedDD/UNLINK it removes the whole benchmark collectionpoints_count100 → collection MISSINGVCARD idx100 → 0configure()is destructive for all of them anyway)configure();delete()guarded byreuse_index_ids()A third shape survives and the new guard is what catches it: if phase 1 was a normal upload, phase 2 destroys nothing — it publishes a QPS number from an empty
<engine>-no-vectorindex while the real corpus sits untouched under the original config name.Semantics
configure()never runs on that path, in any flag combination. The removed arm was unnecessary anyway:--skip-vector-indexrewrites the config name to<engine>-no-vector, so the prior--skip-vector-index --keep-dataupload already left the schema-only index the second phase needs.Cleanup never runs either.
keep_datais forced on for--skip-upload.The corpus is verified before anything is measured.
Engine::corpus_row_count()reads the count off the live server and the runner compares it with the corpus measured on disk:Implemented for redis, valkey, dragonfly, kividb, vectorsets, qdrant, elasticsearch, opensearch, pgvector, mongodb. Chroma, Milvus and Weaviate all expose a count we have not wired up (Wire corpus_row_count() for Chroma, Milvus and Weaviate #285); only Turbopuffer and Vertex are genuinely uncountable.
Proof — server-side row counts, both binaries
Binaries confirmed distinct by a
stringsprobe for a symbol added here (did not create this corpus: absent in pre, present in post). Digests are recorded but are not the load-bearing evidence — a debug binary embeds its build path, so they cannot falsify anything on their own.Path 2, three engines with different code paths (pre =
0a1bb3f, this branch's first commit; post = HEAD):All three pre-fix runs printed
Cleanup (deleting index and data)and exited 0; the qdrant one printedReuse check — server holds 100 of 100 expected rowsfirst, then deleted the collection it had just certified. Post-fix all three printKeep data (--skip-upload did not create this corpus, so it does not delete it).Path 1, vs master (
6f8b814) — redisFT.INFO num_docs400 → 0 while printingQPS: 825.2; mongodbcountDocuments400 → 0 while printing 118.2/38.6 QPS; valkeyDBSIZE400 → 0. All 400 → 400 on this branch.Probe failures no longer fabricate an empty corpus (corpus intact in all three):
A wrong
vector_countno longer disables the guard (server holding 0 of 100,datasets.jsonunder-declaring 50):The verdict now reaches the artifact (2-config sweep,
e-m16amputated to 50,e-m32intact at 100):--exit-on-errortrue)--exit-on-error falserejected_experimentsnamese-m16— the remedy the error message and README now teach--allow-partial-corpuswaived_by_allow_partial_corpus: trueEvery result file carries
params.corpus_reuse— e.g.{"status":"verified","expected_rows":100,"actual_rows":100,"actual_is_estimate":false,"waived_by_allow_partial_corpus":false}.Engine state that
configure()used to setMaking "configure never runs here" a documented contract required auditing what depends on it:
distance_opwas empty on this path, splicing asembedding $1and failing every query after a green reuse check. Now re-derived insearch()(pure function of the dataset).hnsw_ops_classis not read in search — that half of the reported claim did not hold.distance_metricdefaulted tocosine_distance: an l2/dot dataset measured under the wrong metric, no error, plausible file. Re-derived.metric_typeempty. Re-derived.collection_idcomes from a server response, so it is not derivable; now resolved by name.commandstats_baseline: Nonemeans "RESETSTAT ran, counters are zero". Withconfigure()skipped, nothing reset them, socheck_commandstatscompared this run against zero while the counters held every failure since server start — a hard error on a run in which nothing failed. Acommandstats_primedflag disambiguates andsearch()primes exactly once. (This also restores theCONFIG RESETSTATthat master's--skip-vector-indexpath performed.)search(); untouched. The claim that it is inoperable did not hold.load_schema_typesinsearch()is kept as defensive symmetry with redis/valkey, and the comment now says so. It is not load-bearing for a pure search: the filter builders type literals off the JSON value and never read the cache.pgvector's count is an estimate on purpose — measured, not asserted
The original finding was measurement integrity:
count(*)primed the page cache immediately before the search phase, converting a cold-cache run into a warm one. "Estimates only warn" addresses the abort behaviour and not that, so here is the cache half, measured on a cold 1M-row / 768-dim table built exactly like this engine's (SERIAL PK +vector NOT NULL,STORAGE PLAIN, never VACUUMed; 3906 MB, 500000 relpages), postgres restarted between runs:SELECT count(*) FROM itemsANALYZE itemsEXPLAIN (ANALYZE, BUFFERS)confirms the plan:Parallel Seq Scan on items … Buffers: shared hit=96 read=499904.Is ANALYZE cheap on a 1M-row table? Yes, and — more importantly — it is bounded. ANALYZE samples
300 × default_statistics_target= 30,000 rows regardless of table size, so its footprint is constant whilecount(*)grows linearly with the corpus. At 1536 dims (~7.8 GB)count(*)primes all 7.8 GB; ANALYZE still touches ~30,000 blocks.But bounded is not free, and the body should not claim otherwise: ANALYZE still pulls ~234 MB — about 6% of this heap — into cache before the search phase, and it writes statistics. The perturbation is capped, not eliminated. That is stated in the shipped code comment and the README rather than left as a favourable silence.
Live behaviour:
Reuse check — server holds ≈100 of 100 expected rows, then a clean search (which failed on every query before thedistance_opfix).Merged with #263's start gate, and exercised rather than assumed
This branch is merged (not rebased — it is published, and rebasing it once already produced a non-fast-forward that must never be resolved by force-push) with master
01145d4. #263 replaced the fixed-countBarrierin all 14 engine search harnesses withstart_gate, touching the same worker-spawn and error-propagation paths this branch addscorpus_row_count()and a pre-search hard error to. The merge was textually conflict-free — which is exactly the case where a clean merge that does not compile is the risk — so it was verified, not assumed:src/start_gate.rs994 lines, 14 engines routing through it, 11corpus_row_countimpls, 8prime_commandstats_if_neededcall sites, thekeep_dataforcing intact.cargo build --bins --testsclean.exit(1). fix(harness): thread-spawn failure and worker panics deadlocked the search start barrier #263 converted an in-searchreturn Errintofatal_search_error+break 'search_phasesopending_savesis flushed first. My guard fires before the search phase and returns through?directly, so that restructuring does not intercept it. Live on the merged binary, against a half-deleted Qdrant collection:EXIT CODE = 1.--plotto chart. Same run: 0 search result files, 0 summary files, 0 chart lines, and fix(harness): thread-spawn failure and worker panics deadlocked the search start barrier #263'swrote N completed search point(s)message correctly does not fire (pending_savesis empty because nothing had run yet).Reuse check — server holds 100 of 100 expected rows, three reps, and with no--keep-datathe collection still holds 100 afterwards, withparams.corpus_reuse{"status":"verified", …}in the artifact.prime_commandstats_if_needed()?sits immediately after the signature — before every dispatch and before everyWorkerPool::new— so its early return cannot strand a gate (INV-4c's concern). NoBarrierwas added.Tests
8 live integration tests across 5 engines, all verified RED. Against master (
6f8b814):Against this branch's first commit
0a1bb3f(i.e. the review round's own regressions):Mutation probe (both directions). With the guard mutated to fire only for configs whose name contains
redis:Engine::name()returns the config name, so the rename is what makes the test discriminating.A note on KiviDB: its HNSW graph is independent of the hashes — UNLINKing 45 of 100 documents leaves
hnsw_live_countat 100 and recall at 1.0000 (measured), so the count is right and there is nothing to catch. The test amputates withFT.DROPINDEXinstead, which is what actually removes the searchable corpus.Gates, on the tree merged with current master
01145d4(#263's start gate):cargo test --lib --bins: 872 passed, 0 failed across all six binaries (126 + 0 + 0 + 0 + 2 + 744). Master baseline is 860; +12 is the new unit tests.cargo clippy --all-targets -- -D warningsandcargo fmt --checkclean.tests/overhead_invariants.rs9/9, includinginv4_no_fixed_count_start_barrier,inv4_search_harnesses_route_through_the_start_gateandinv4_bare_start_gate_users_hold_an_abort_guard.integration_cli4/4.integration_redis38/38,integration_valkey26/26,integration_qdrant27/27,integration_mongodb19/19,integration_kividb20/20 (test_kividb_flat_algorithm_worksfiltered — it hangs >40 min on this box, uses no--skip-upload, and hangs on master too).integration_redisandintegration_qdrantgain theREDIS_TEST_PORT/QDRANT_TEST_{REST,GRPC}_PORToverrides the valkey/kividb/mongodb suites already had, so a session can test its own container rather than flushing a shared one.Correction to my earlier body: I previously reported
integration_opensearchfailing 16/16 and attributed it to container contention. The suite passes 16/16 on master and on this branch; the failure was disk pressure — the shared cluster had crossed the 90% watermark, so index creation returnedindex_create_block_exception: blocked by [FORBIDDEN/10/cluster create-index blocked (api)], trippingassert!(resp.status().is_success())everywhere. Not this change, but the stated cause was wrong. Also corrected: the claim that Valkey Search rejects a vector-lessFT.CREATE— it does not on the imagedocker-compose.test.ymlpins (valkey 9.1.1); the real result is stronger, master destroys 400 keys while printing a QPS line and exiting 0.On recall as a detector: the
mean_recall: 1.0I quoted for a half-deleted Qdrant collection is deletion-pattern-dependent, not a property.random-100has 9 queries with one ground-truth neighbour each in ids 0-8, so deleting the upper half removes no ground-truth point → 1.0, and deleting the lower half → 0.0. Recall is a coin flip on which rows went; a server-side count is not. The qualification is now in the code comments and the test doc, and the new Qdrant test deletes the upper half deliberately.Follow-ups filed, deliberately not fixed here
#279 (cardinality is not identity — a sibling config's corpus certifies as this one's on the 6 single-object engines), #280 (
--skip-if-existsis a no-op without--skip-upload), #281 (--reset-between-configson a run that never uploads), #282 (--update-search-ratiomutates the reused corpus by design), #283 (--skip-vector-indexcollapses every config into<type>-no-vector, defeating #151-4; the collision guard runs on pre-rewrite names), #284 (ES/OS_countis refresh-scoped), #285 (wire the count for Chroma/Milvus/Weaviate).Also out of scope: #251 filter builders, #246 force-merge, #263 harness start gate, #264 results-JSON config recording (the reuse verdict should join its provenance block once both land); Valkey Search's own
--skip-vector-indexbehaviour; the hardcoded VectorSetsidxkey (#236).One new per-experiment cost, only on the
--skip-uploadpath: measuring the corpus is a header read for npy/hdf5, but a full-file line count for the twojsonldatasets — cheap at their size, noted in the code.Closes #238
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com