Testing¶
The layers¶
Markers, and what each is for:
| Marker | What it covers | Budget |
|---|---|---|
unit |
Fast, isolated, no I/O | < 10 s total |
property |
Hypothesis-driven | < 60 s |
contract |
Every registered backend runs the same suite | < 3 min |
integration |
A real store or embedder | < 10 min |
e2e |
The full CLI flow | < 15 min |
perf |
Asserts wall clock | excluded by default; host only |
memory |
Asserts peak allocation | excluded by default; CI gates on it |
gpu |
Needs an accelerator | server only |
slow |
Golden corpora, macro benchmarks | server only |
network |
Downloads models or datasets | opt-in |
The default pytest run executes only the fast layer. That is a decision, not
an oversight: a developer loop longer than ten seconds is a loop that stops
being run.
perf and memory are split by what a test asserts on, and the split is
load-bearing. A shared runner cannot measure wall clock — this suite has the
red Xs to prove it — but it measures tracemalloc exactly. While both wore one
marker, excluding the noisy half excluded the deterministic half with it: the
O(batch × d) invariant, the only guard against a list(iter_records()) that
fails solely on corpora nobody has in development, gated no pull request at all,
while benchmarks/README.md went on saying it gated every one. A test must
never carry both markers, because perf is what keeps it off the merge path.
just gate runs the memory layer locally; just bench runs perf.
Determinism is enforced¶
An autouse fixture pins every source of randomness before each test, and
pytest-randomly shuffles execution order. A test that leaks state therefore
fails irreproducibly, which costs more time than the state it was saving — so
the fixture has no teardown, on purpose.
The contract suite¶
tests/contract/test_vector_store.py runs against every registered backend. Two
of its tests matter more than the rest, because they cover what a backend is
most likely to get wrong quietly:
Laziness. A materialising iter_records breaks the memory invariant only on
corpora large enough that nobody notices in development.
Truthful capabilities. A store that claims more than it can do fails halfway
through a migration instead of at second zero. That now covers
can_rebuild_index as well: a backend that declares it has to actually rebuild
when asked, and one that does not has to refuse rather than silently do nothing.
Which of the two a backend is decides whether a migration's cost to the search
structure is recoverable — see what a migration does to the
index.
Performance tests¶
Three layers:
- Memory ceilings — absolute thresholds. Exceeding one blocks a PR: unlike a wall-clock comparison, a ceiling has no false-positive trade-off.
- The scaling test — peak memory measured at three corpus sizes and asserted not to track N. This is the guard on the architecture's central invariant.
- Macro benchmarks — end-to-end against the performance budgets, on the server, run by hand.
Wall-clock benchmarks never block a PR. On a shared runner a wall-clock gate needs 7% just to keep false positives at 1%, and a 7% gate hides exactly the regressions worth catching.
Adding a test that needs a real service¶
Prefer the embedded mode. Qdrant runs from a path with no server; sqlite-vec is an extension; LanceDB and Chroma are files. Every integration test in the suite runs without a container.
Skip honestly when the dependency is genuinely absent:
What a clone cannot run¶
Two markers, gpu and slow, never run from a clone. Two more, perf and
memory, are excluded from the default local loop — but only perf is off the
merge path. memory runs from a clone and gates a pull request; just gate is
how to see it before pushing.
The reason is the hardware, not the permissions. Device parity has nothing to
compare against on a single-device runner, so a parity suite there is not a
parity suite. The golden corpora and the macro benchmarks have the same problem
in a different form: their numbers only mean something on a machine whose
specification is recorded next to them. Both run on the project's own GPU host,
along with the perf layer, when the maintainer runs them. The wrapper that
drives that host carries a real instance id and a real host alias, so it stays
on the maintainer's machine and is not in a clone — and neither is the workflow
that would call it on a schedule. A schedule: trigger only fires from the
default branch, so a workflow that is not committed fires never.
This page and benchmarks/README.md both said "nightly" for several releases.
They were describing a workflow that exists on one laptop and has never run.
What is true is weaker and worth saying plainly: nothing runs this layer
unattended. CONTRIBUTING.md asks for the golden tests after any change to
adapter or metric behaviour, and that request is answered by a person, not by a
gate.
This is stated rather than papered over: a contributor cannot reproduce those
numbers, and a pull request is not expected to. Everything a review gates on —
unit, property, contract, integration, e2e, memory — runs from a
clone with no container and no service to install. If a change needs a GPU number
to justify it, say so in the pull request and it will be measured on the host.
What CI runs, and what it no longer does¶
Seven jobs in ci.yml on a pull request, one of which runs the suite. It was
ten.
| job | what it is for |
|---|---|
| lint and types | ruff, mypy, import-linter, interrogate, generated catalogues, citations |
| tests and coverage floors | the suite, plus the per-module floors |
| core install (ubuntu, macOS) | the core install works without the optional halves — and it is the one job that can carry a second operating system |
| lowest direct dependencies | the declared floors resolve and pass |
| docs build | mkdocs --strict; a dead link is a failure |
| secret scan | gitleaks |
| dependency review | whether this change adds a dependency already known-vulnerable, or one whose licence contradicts Apache-2.0 |
.github/workflows/codeql.yml runs on the same pull request from a workflow of
its own, with the security-extended query set. It is not in the table because
it is not a ci.yml job, not because it does not gate.
One more is not on the pull-request path at all. arXiv still says what we say
it says lives in its own workflow — .github/workflows/citations.yml, because
a schedule: trigger applies to every job in the workflow carrying it, and
putting one in ci.yml would run all seven of the above once a week for
nothing. It runs weekly and on demand, and asks
arXiv whether every identifier in docs/citations.toml still carries the title
recorded beside it. The offline half of the same check — that the manifest and
the committed documentation name the same set of papers — runs in lint and
types on every pull request, because it needs no network and so cannot go red
for a reason that has nothing to do with the change under review. The split is
the same one the perf layer got, for the same reason: a gate that fails on
something the author did not do is a gate people learn to ignore.
What went, and why:
A second job running the same tests. tests and coverage differed only in
their marker expression, so the suite ran twice for fourteen minutes of runner
time and two chances to go red. The wider selection stayed.
The perf layer. It asserts timing, and a shared runner cannot measure
timing: test_batching_amortises_the_per_call_cost failed twice by 1% and 2.6%,
which is noise wearing a red X. ADR
11 already says absolute
numbers belong on a fixed runner, and so do these ratios. The layer runs on the
project's own host, which is the machine its numbers describe.
macOS. It covered the storage layer, where the platforms genuinely differ —
directory fsync, os.replace, a system sqlite3 that cannot load extensions. It
also could not finish: faiss-cpu and torch each link their own OpenMP
runtime there and a process holding both aborts before either library does any
work (faiss-wheels#40,
pytorch#149201). Not
rebasis' bug, no caller-side fix, and the documented workaround is documented as
liable to produce wrong results. macOS is the maintainer's own platform and the
suite runs there; rebasis doctor reports the conflicting pair to a user.
The newest Python. Across this repository's runs it caught nothing the floor
did not, and lowest direct dependencies still pins the floor's dependency
versions — the check that has actually failed.
Every job carries a timeout-minutes, so a hang costs minutes rather than the
six hours a runner will otherwise give it, and faulthandler_timeout in
pyproject.toml makes a hang name the test rather than the percentage it
stopped at.
None of this is a claim that the removed checks were worthless. They are removed because a suite nobody can get through is a suite nobody runs. Bring one back when there is a failure it would have caught — which is also the standard for adding a new one.