feat(m12): vector retrieval G1/G2 — recall harness, ANN in RETRIEVE, index tuning

m12p1 (measurement truth): TidalDb::vector_search_items pure k-NN probe +
POST /vector_search (standalone + region node, merge-by-distance) +
tidal-stress --verify-recall (deterministic id-keyed corpus, in-RAM brute-force
cosine oracle, open-loop ramp → recall@k + true p99 + read-knee + JSON/gate exit).
Repaired fabricated p99 columns (mean-as-p99) in social-scale.md / scale.rs.
Verified real: recall@10=0.9997 at 20k/1536-D vs brute-force.

m12p2 (G1 unblock): ANN candidate-gen wired into RETRIEVE — for_you=preference
vector, related=seed embedding (similar_to), graceful scan-fallback. Cached
per-signal-type top-K (signals/ledger/hot_top_k.rs, decay-order-invariant) so
trending serves O(K). related over HTTP (FeedQuery.similar_to). Harness gains
--feed-profile / --seed-preferences. Verified: trending retrieve p99 3.5-7.7ms.

m12p3 (G2): per-query ef_search now honored (RwLock epoch-guard with_expansion,
shared guard for same-ef concurrency) + dimension-aware brute→HNSW crossover
usearch_min_vectors(dim) + memory_usage() + examples/ann_grid_search.rs.
Measured 1536-D/100k clustered: default M=16/ef_c=400/F16/ef_s=200 clears
G1+G2 (recall 0.997, p99 1.4ms); F16 -0.25% vs F32; Int8 rejected (-28%).
Recall corpus is now clustered (Gaussian mixture) in grid + harness.
This commit is contained in:
jx12n 2026-06-14 11:07:09 -06:00
parent 81093a6779
commit bb21e69ae6
38 changed files with 4348 additions and 230 deletions

View File

@ -6,6 +6,114 @@ All notable changes to tidalDB will be documented in this file.
### Added ### Added
**Index tuning + recall/memory at the production shape (m12p3) — the G2 work: per-query `ef_search` is now honored, the brute-force crossover scales with dimensionality, and the HNSW recall/latency/memory frontier is measured at 1536-D with a real exact oracle**
- **Per-query `ef_search` override — now real.** `UsearchIndex::search` /
`filtered_search` honor a per-request `ef_search` instead of silently dropping
it to the index default (pre-m12p3 behaviour: accepted for trait compliance,
logged a warning, ignored — `USearch` 2.24 has no per-call beam argument). The
override is race-free via an `RwLock` epoch guard (`with_expansion`): searches
that agree on `ef_search` run in parallel under a shared guard; only a query
that changes the live beam width takes the exclusive guard for its
`(set, search)` window — not a per-search mutex. The knob was already plumbed
end-to-end in m12p1 (`vector_search_items(.., ef_search)`, the `/vector_search`
`ef_search` field, `tidal-stress --recall-ef-search`); m12p3 makes it move
recall. `ef_search=0` selects the slot default.
- **Dimension-aware brute-force → HNSW crossover.** The exact `BruteForceIndex`
scans every vector under a read lock at `count × dim` cost, so a fixed 10,000
crossover meant a 15.4M-FMA scan at 1536-D (tens of ms, blocking writers).
`usearch_min_vectors(dim)` now keeps a brute-force scan within ~4M FMAs:
≈10,000 at/under 128-D (byte-compatible with pre-m12p3), ≈2,600 at 1536-D —
flipping high-dim mid-size slots to HNSW before the scan blows the SLA.
- **`memory_usage()`** exposed on `UsearchIndex` (the true graph + vector
footprint from `USearch`, not the `index_stats` lower bound) for pod sizing.
- **Grid-search harness.** `cargo run --release --example ann_grid_search` builds
a `UsearchIndex` + an exact `BruteForceIndex` oracle over the same
deterministic id-keyed corpus and reports, per `(M, ef_construction, ef_search,
quantization)` point, measured recall@10 vs the oracle, mean/p99 search
latency, build time, and the true footprint — the tool that produces the
documented `M`/`ef` and the F32/F16/Int8 recall+memory numbers.
- **Measured at 1536-D (100k clustered corpus, real exact oracle).** The
production default (M=16, ef_c=400, F16) **clears G1 and G2**: recall@10 0.997
at p99 ≈ 1.4 ms raw ANN; ef_search is the latency lever (recall saturates by
ef_s=128 → p99 ≈ 1.0 ms). F16 costs only 0.25% recall vs F32 for half the RAM
(≈ 5.2 GB/1M true footprint incl. graph); **Int8 rejected** at 1536-D (recall
0.715, 28%). Live `tidal-stress --verify-recall` against a real server gave
recall@10 = 1.0000 at 20k/1536-D, default beam and `--recall-ef-search 400`.
Finding: the recall corpus is now **clustered** (Gaussian mixture) in both the
grid harness and `tidal-stress` (`recall::embedding_for`) — uniform-random
high-dim vectors are pathological for recall@k (≈0.97 at 10k → ≈0.54 at 100k, a
measurement artifact, not an index regression).
Full frontier + the 1M command in
[docs/profiling/usearch-tuning.md](docs/profiling/usearch-tuning.md) and
[docs/profiling/scale-baselines.md](docs/profiling/scale-baselines.md).
`docs/specs/07-vector-retrieval.md` updated: per-query `ef_search` is
IMPLEMENTED, not deferred.
**ANN candidate generation in RETRIEVE (m12p2) — the G1 unblock: `for_you`/`related` source candidates by nearest-neighbour, `trending` by a cached per-signal-type top-K, so the feed stays relevant AND bounded as the corpus grows past the scan cap**
- **ANN in retrieve.** `CandidateStrategy::Ann` is now wired into the RETRIEVE
executor (it previously fell back to a scan with a warning). The db layer
resolves the query vector — the user's **preference vector** for `for_you`, the
**seed item's embedding** (`similar_to`) for `related` — and Stage 1 runs an
`O(ef_search)` HNSW search over the item content slot instead of scanning an
arbitrary low-id slice of the universe. `for_you` and `related` are now `Ann`
profiles. Graceful: no registry / no preference vector / no seed ⇒ it degrades
to a scan (anonymous reads and cold-start users still serve), so every
embedding-less schema and pre-m12p2 caller is unchanged.
- **Cached `SignalRanked`.** The O(N) ledger scan behind `SignalRanked` is now a
cached per-signal-type top-K (`signals/ledger/hot_top_k.rs`): O(K) on the
served path, with a bounded O(N) rebuild only when stale. Decay preserves
relative order (same λ), so a cache is valid until the next write; small
ledgers rebuild on any write (always fresh), large ledgers throttle the rebuild
off the read hot path (1s). `trending` now uses `SignalRanked(view)`, so it
ranks the actually-viewed corpus at any id — not the low-id scan slice.
- **`related` over HTTP.** `GET /feed?profile=related&similar_to=<id>` resolves
the seed's embedding and runs ANN — "more like this" on the read surface
(`similar_to` added to `FeedQuery`, threaded through all three feed handlers).
- **Harness `/feed` measurement.** `tidal-stress` gains `--feed-profile <name>`
(force every feed read to one profile, for per-profile retrieve p99) and
`--seed-preferences` (build a preference vector per user so `for_you` exercises
ANN, not the scan fallback).
- **Verified real** against a 1536-dim standalone server: **trending retrieve
p99 = 3.57.7ms** (under the 10ms G1 target) under concurrent writes, via the
cached top-K; `for_you` retrieve is ANN-backed (preference vectors built) at
p99 ≈ 24ms, dominated by the Stage-3 preference-boost recompute (per-candidate
embedding read) — flagged for m12p3's index/score tuning (the risk register's
"materialized-score layer"). ANN candidate recall is the m12p1 `/vector_search`
probe (0.9997). New engine tests prove ANN/`related`/`trending` reach the
relevant items at high ids a scan can never reach, plus cache freshness.
**Read-recall harness — measurement truth for ranking-at-scale (m12p1): recall@k + true p99 at the production shape, so G1/G2 steer on real numbers instead of absent ones**
- **Pure k-NN probe.** `TidalDb::vector_search_items(query, k, ef_search)` returns
the raw HNSW nearest neighbours over the item content embedding slot — NO
profile scoring, fusion, or diversity — so the result is the ANN index quality
in isolation (the G2 metric). Exposed as `POST /vector_search` on the standalone
router and the multi-process region node (merge-by-distance across hosted shard
groups); a dimension-mismatched query is a 400, not a 500.
- **The oracle.** `tidal-stress --verify-recall` seeds the corpus with
**deterministic, id-keyed** embeddings (reproducible with `--skip-seed`), holds
a **brute-force cosine ground truth** in RAM, and ramps `/vector_search` probes
**open-loop** (coordinated-omission corrected). It reports per-stage **true p99**
(a genuine tail, not a closed-loop mean) AND **mean recall@k**, plus the
**read-knee** — the highest sustained QPS where `p99 ≤ target AND recall@k ≥
target` both hold — with a machine-readable JSON summary and `--fail-on-knee`
PASS/FAIL exit. Knobs: `--recall-k`, `--recall-queries`, `--read-p99-target-ms`,
`--recall-target`, `--recall-ef-search`.
- **Verified end-to-end** against a real standalone server at 1536-dim:
**recall@10 = 0.9997** at 20k items (HNSW M=16/ef=400/F16 vs brute-force cosine,
far above the 0.95 G2 target); the harness also exercises the read-knee verdict
(both branches) and gate exit codes. The 100k/1M exit-gate runs use the same
harness on the k3s cluster (the brute-force oracle needs ~6 GB RAM at 1M).
- **No more mean-as-p99.** Repaired the fabricated p99 column in
`docs/profiling/social-scale.md` (the `social` bench is Criterion = mean only)
and the `scale.rs` / scale-baselines framing; every closed-loop number is now
labelled an *isolated per-op mean (regression tripwire)*, with the p99/recall
tail SLOs signed off only by the open-loop harness. Added a 1536-dim
HNSW-vs-brute `recall@10` bench (`benches/vector.rs`) and a recall-harness
section to `docs/profiling/scale-baselines.md`.
**Sharding × replication + rebalancing (m11p6) — the "replicated XOR sharded" split is over: S shard groups, each a replication group at RF with its own elected leader, leaders balanced across nodes; any gateway hash-routes** **Sharding × replication + rebalancing (m11p6) — the "replicated XOR sharded" split is over: S shard groups, each a replication group at RF with its own elected leader, leaders balanced across nodes; any gateway hash-routes**
- **One write surface.** `/items`//`/embeddings`//`/signals` now hash-route the - **One write surface.** `/items`//`/embeddings`//`/signals` now hash-route the

View File

@ -103,3 +103,175 @@ tidalDB's **isolated per-op mean cost** sits well within all three
acceptance-criteria targets at 1M items. The dominant cost is SEARCH text_only at ~29ms — driven by Tantivy posting list traversal across 1M documents. The LogMergePolicy tuning (< 20 segments at steady state) keeps this below the 100ms target with headroom. **The p99 tail SLOs themselves are signed off by the open-loop `tidal-stress` ramp, not by these closed-loop means** (see the measurement contract above). acceptance-criteria targets at 1M items. The dominant cost is SEARCH text_only at ~29ms — driven by Tantivy posting list traversal across 1M documents. The LogMergePolicy tuning (< 20 segments at steady state) keeps this below the 100ms target with headroom. **The p99 tail SLOs themselves are signed off by the open-loop `tidal-stress` ramp, not by these closed-loop means** (see the measurement contract above).
Signal writes at 82ns confirm the DashMap hot-path is not a bottleneck at this scale. The 5M-entry LRU trimming threshold (DEFAULT_MAX_SIGNAL_ENTRIES) provides ample headroom for the 100K-item signal coverage in this benchmark (~200K entries = ~218MB). Signal writes at 82ns confirm the DashMap hot-path is not a bottleneck at this scale. The 5M-entry LRU trimming threshold (DEFAULT_MAX_SIGNAL_ENTRIES) provides ample headroom for the 100K-item signal coverage in this benchmark (~200K entries = ~218MB).
## Read-recall harness — recall@k + true p99 (m12p1, open-loop)
Every number above is an *isolated per-op mean* and says nothing about **recall**
— the fraction of the true nearest neighbours an ANN query actually returns.
m12p1 adds the open-loop harness that measures recall@k AND true p99 at the
production shape, so G1 (p99 ≤ 10ms) and G2 (recall@10 ≥ 0.95) can be steered
against real numbers instead of absent ones.
### How it works
1. `tidal-stress --verify-recall` seeds the corpus with **deterministic,
id-keyed** embeddings, so the generator reconstructs the exact indexed vectors
and computes a **brute-force cosine ground truth** in RAM (the exact answer the
HNSW index approximates).
2. It ramps `POST /vector_search` probes **open-loop** (coordinated-omission
corrected) and scores each response's `recall@k` against the precomputed
ground truth, recording the **true p99** (a genuine tail, not a closed-loop
mean).
3. The verdict reports the **read-knee**: the highest sustained QPS at which
`p99 ≤ target AND recall@k ≥ target` both hold, plus a machine-readable JSON
summary and a `--fail-on-knee` PASS/FAIL exit for the soak gate.
`/vector_search` is a **pure k-NN probe** (`TidalDb::vector_search_items`): raw
HNSW nearest neighbours with NO profile scoring, fusion, or diversity, so the
number is the ANN index quality in isolation — exactly the G2 metric. It is a
single-index measurement: run it against a standalone node or a cluster at the
S=1 shape (every replica holds the full corpus). RAM for the brute-force oracle
is `corpus × dim × 4` bytes (~0.6 GB at 100k/1536D, ~6 GB at 1M/1536D).
### Measured (local, standalone, 1536-dim)
Verified end-to-end against a real standalone server (`--schema` content_vector =
1536, release build, Apple Silicon laptop):
| Corpus / dim | recall@10 (vs brute-force cosine) | per-query cost | p99 @ 100 rps |
|--------------|-----------------------------------|----------------|---------------|
| 20,000 / 1536D | **0.9997** | ~16 ms (single request) | ~37 ms |
The HNSW index (M=16, ef_construction=400, ef_search=200, F16) returns essentially
the exact neighbour set — recall@10 ≈ 0.9997, far above the 0.95 G2 target. The
~1619 ms per-query cost on a single laptop node is well over the 10 ms G1 target
and saturates past ~400 rps; G1 is the work of m12p2 (ANN candidate-gen in
RETRIEVE) + m12p3 (index tuning + per-request `ef_search`), measured by this same
harness at scale.
### Running the exit-gate shape (100k AND 1M)
```bash
# 100k corpus, 1536-dim, against a single-index node:
tidal-stress --target http://<node>:9500 --verify-recall \
--corpus 100000 --embedding-dim 1536 --recall-queries 1000 \
--ramp "200:30,500:30,1000:30,2000:30" \
--read-p99-target-ms 10 --recall-target 0.95 \
--json-summary recall-100k.json --fail-on-knee
# 1M corpus: same, --corpus 1000000 (oracle needs ~6 GB RAM on the generator).
```
The 1M run requires the k3s cluster (RAM + a seeded 1M/1536D corpus) and is
tracked as the cluster-side step of the m12p1 exit gate; the harness, the probe
endpoint, and the verdict are proven locally at 20k/1536D above.
## RETRIEVE with ANN candidate generation (m12p2)
m12p2 replaced the arbitrary low-id scan slice in candidate generation with
relevance-bounded sources: `for_you`/`related` source candidates by ANN
nearest-neighbour (over the user's preference vector / the seed item's
embedding), and `trending` by a cached per-signal-type top-K. Measured against a
real 1536-dim standalone server (release, Apple Silicon laptop):
| Profile | Candidate source | Retrieve feed p99 | G1 (≤10ms) |
|---------|------------------|-------------------|------------|
| `trending` | cached `SignalRanked(view)` top-K | **3.57.7 ms** (6001200 rps, concurrent writes) | ✅ met |
| `for_you` | ANN over the user preference vector | ~24 ms (100400 rps) | ⚠ see below |
- **`trending`** holds p99 ≤ 10ms because the cached top-K serves candidates in
O(K) (the O(N) rebuild is throttled off the read hot path), so it ranks the
actually-viewed corpus at any id while staying fast even under a concurrent
write stream.
- **`for_you`** is ANN-backed (the candidate recall is the m12p1 `/vector_search`
probe, 0.9997). Its ~24ms p99 on this single laptop node is dominated NOT by
the ANN search (~1.5ms raw at 10k/1536D — see `ann_recall_at_10_1536d` in
`benches/vector.rs`) but by the **Stage-3 preference boost**, which reads each
of the ~240 candidates' embeddings from storage and recomputes cosine to the
preference vector. That O(K) per-candidate recompute is the m12p3 target — the
risk register's "materialized-score layer" / reuse the ANN distance already
computed in candidate-gen. The absolute 10ms at 1M is an m12p3 (`ef_search`
tuning) + production-hardware result; m12p2 delivered the algorithmic change
(relevance-bounded candidate generation) and surfaced the remaining bottleneck.
Reproduce the per-profile retrieve p99 with the harness:
```bash
# for_you (ANN): build preference vectors, force the profile, gate at 10ms.
tidal-stress --target http://<node> --mix "feed=1" \
--feed-profile for_you --seed-preferences \
--corpus 100000 --embedding-dim 1536 --users 10000 --max-p99-ms 10
# trending (cached SignalRanked): peach mix writes views; force the profile.
tidal-stress --target http://<node> --mix peach --feed-profile trending --skip-seed \
--corpus 100000 --embedding-dim 1536 --max-p99-ms 10
```
## Index tuning + recall/memory frontier at the production shape (m12p3)
m12p3 tuned the HNSW at the **production shape (1536-D)** — earlier tuning was
only validated at 128-D — and characterized the recall/latency/memory frontier
with a real exact oracle. The full method, the chosen `M`/`ef`, and the
reproduce commands live in [usearch-tuning.md](usearch-tuning.md); the headline
recall + per-1M memory sizing is below.
The numbers come from `cargo run --release --example ann_grid_search`: a
deterministic id-keyed corpus, an exact `BruteForceIndex` (F32) oracle, recall@10
scored per query, and the index's true `memory_usage()` extrapolated to 1M.
The corpus is a **Gaussian mixture** (clustered), not uniform-random: uniform
high-dimensional vectors are pathological for recall@k (every pair ≈ orthogonal,
so top-k is an arbitrary draw from an equidistant shell — recall@10 measured
~0.97 at 10k but ~0.54 at 100k, a corpus-size artifact, not an index regression).
Real embeddings cluster on a manifold; the clustered corpus reflects that, so
recall@k is meaningful and scale-stable. Details in
[usearch-tuning.md](usearch-tuning.md).
### Recall@10 at 1536-D (measured, 100k clustered corpus, vs exact brute-force cosine)
| Config (F16) | recall@10 | p99 (raw ANN) | G2 (≥0.95) | G1 (≤10ms) |
|---|---|---|---|---|
| M=16, ef_c=400, ef_s=128 | 0.9970 | 1.0 ms | ✅ | ✅ |
| **M=16, ef_c=400, ef_s=200 (default)** | **0.9970** | **1.4 ms** | ✅ | ✅ |
| M=24, ef_c=400, ef_s=200 | 0.9985 | 2.2 ms | ✅ | ✅ |
| M=32, ef_c=400, ef_s=200 | 0.9975 | 2.9 ms | ✅ | ✅ |
The production default (M=16, ef_c=400, F16) **clears both G1 and G2 at
100k/1536-D** with wide margin. `ef_search` is the latency lever and recall
saturates by ef_s=128, so the per-query override (m12p3) lets a latency-sensitive
read drop to ef_s=128 (p99 ≈ 1.0 ms) without losing recall. **Live cross-check:**
`tidal-stress --verify-recall` against a real server returned recall@10 = 1.0000
at 20k/1536-D (clustered), at the default beam and at `--recall-ef-search 400`.
### Per-1M memory sizing by quantization (true measured footprint, extrapolated from 100k)
| Quantization | recall@10 (M=24, ef_c=400, ef_s=400) | mem/1M (GB) | Verdict |
|---|---|---|---|
| F32 | 0.9985 | 10.53 | exact, 2× RAM |
| **F16 (default)** | **0.9960** | **5.53** | 0.25% recall, half RAM — **chosen** |
| Int8 | 0.7150 | 3.03 | **rejected**: 28% recall at 1536-D |
The footprint is `USearch`'s true `memory_usage()` (graph links + quantized
vectors), so F16 is **≈ 5.25.5 GB/1M**, not the ~3.4 GB a vectors-only estimate
(`1536 × 2 B`) gives — the ~1.8 GB difference is the HNSW graph. Size pods at
≈ 5.5 GB/1M for F16. **Int8's 1.8 GB option is rejected**: it loses a quarter of
the neighbours at 1536-D and would need quantization-aware scaling first.
### Per-query `ef_search` is now honored
The `ef_search` column in the frontier table is a **per-request** knob as of
m12p3 (it was silently ignored before). A low-latency surface can request a
narrow beam and a high-recall surface a wide one against the same shared index,
concurrently — see [usearch-tuning.md](usearch-tuning.md#per-query-ef_search-m12p3-now-honored).
`tidal-stress --verify-recall --recall-ef-search <N>` sweeps it open-loop.
### The 1M run
The 100k numbers above fit one laptop (the F32 oracle is ≈ 0.6 GB). The 1M
exit-gate run needs ≈ 6 GB for the oracle plus the HNSW, so it runs on the k3s
node:
```bash
cargo run --release --example ann_grid_search -- \
--corpus 1000000 --dim 1536 --queries 200 --k 10
```

View File

@ -38,12 +38,21 @@ positive engagements — approximately once per active user session.
### Benchmark Results ### Benchmark Results
| Depth | Mean Latency | p99 Latency | Notes | > **Measurement contract:** the `social` benchmark is Criterion — it reports an
|-------|-------------|-------------|-------| > **isolated per-op cost (mean, single-threaded, closed loop)**, NOT a latency
| Depth-1 (followed creator items) | ~3ms | ~8ms | Direct bitmap union over 100 creators | > distribution. A closed loop cannot observe the tail it hides (it stops sending
| Depth-2 (co-followers' seen items) | ~25ms | ~45ms | Fan-out: 100 creators × 500 followers | > when the system stalls), so these means are **regression tripwires**, not p99
> evidence. The depth-2 < 50ms TAIL SLO is signed off only by the open-loop
> `tidal-stress` ramp — see [`scale-baselines.md`](scale-baselines.md).
**Target: p99 < 50ms achieved** | Depth | Isolated per-op cost (mean, closed-loop) | Notes |
|-------|------------------------------------------|-------|
| Depth-1 (followed creator items) | ~3ms | Direct bitmap union over 100 creators |
| Depth-2 (co-followers' seen items) | ~25ms | Fan-out: 100 creators × 500 followers |
**Tail SLO: depth-2 p99 < 50ms** the ~25ms *mean* clears the budget with
headroom (a necessary, not sufficient, condition); the p99 itself is validated
open-loop, never by this closed-loop mean.
_Run: `cargo bench --manifest-path tidal/Cargo.toml --bench social -- social_graph_1m`_ _Run: `cargo bench --manifest-path tidal/Cargo.toml --bench social -- social_graph_1m`_

View File

@ -2,81 +2,185 @@
## Summary ## Summary
Grid search result: **M=16, ef_construction=400** is the optimal default for tidalDB. The production default (`VectorIndexConfig::default()`) is **M=16,
ef_construction=400, ef_search=200, F16**. m12p3 re-ran the grid search at the
**production shape (1536-D)** — not the historical 128-D — with a real exact
brute-force oracle, and validated the F16/Int8 recall and memory frontier there.
The production default (`VectorIndexConfig::default()`) was updated to `ef_construction=400` > **What changed in m12p3.** The earlier version of this doc reported an
from `ef_construction=200`. The improvement in recall@10 (~1.5%) justifies the ~2× build overhead > *extrapolated* 128-D table (values from published ANN-Benchmarks, not measured
for a write-rarely, read-frequently index. > here). That is replaced below by **measured** 1536-D numbers from
> `cargo run --release --example ann_grid_search`. The example builds a
> `UsearchIndex` and a `BruteForceIndex` oracle over the same deterministic,
> id-keyed corpus and reports recall@10 vs the exact oracle, single-thread
> mean/p99 search latency, build time, and the true `memory_usage()` footprint.
## Grid Search Setup ## Grid Search Setup (m12p3, measured)
| Parameter | Values | | Parameter | Values |
|-----------|--------| |-----------|--------|
| M (connectivity) | 8, 16, 32 | | M (connectivity) | 16, 24, 32 |
| ef_construction | 100, 200, 400 | | ef_construction | 400 |
| ef_search (fixed) | 200 | | ef_search (per-query) | 128, 200, 400, 600 |
| Dataset size | 100K vectors | | Quantization | F32, F16, Int8 |
| Dimensionality | 128D | | Dataset size | 100,000 vectors (and 1M on the k3s node) |
| Dimensionality | **1536D** (production embedding width) |
| Corpus shape | **clustered (Gaussian mixture)** — see below for why not uniform-random |
| Distance metric | L2 (L2-normalized → equivalent to cosine) | | Distance metric | L2 (L2-normalized → equivalent to cosine) |
| Recall metric | recall@10 (100 query average) | | Recall metric | recall@10 vs exact brute-force cosine (200-query average) |
| Hardware | Apple Silicon laptop (release build) |
## Method ## Method
1. Build `UsearchIndex` for each of the 9 `(M, ef_construction)` configurations 1. Build a deterministic, id-keyed **clustered** corpus (SplitMix64 Gaussian
2. Build `BruteForceIndex` as ground truth mixture — see below) so the run is reproducible, the recall numbers are
3. Run 100 random unit vector queries, compute `recall@K = |HNSW∩Brute| / K` comparable across machines, and recall@k is a meaningful metric at scale.
4. Record: recall@10, mean search latency (µs), p99 latency, build time (s) 2. Build a `BruteForceIndex` (F32) over the same vectors and compute the exact
top-10 for every query **once** — the ground truth the HNSW approximates.
3. For each `(M, ef_construction)` build the graph once and sweep `ef_search`
(a per-query knob — no rebuild). For quantization, build F32/F16/Int8 at the
recall-frontier graph.
4. Record recall@10, mean + p99 search latency (µs), build time (s), and the true
in-memory footprint, extrapolated to 1M vectors.
## Results ### The corpus must be clustered, not uniform-random
Results below are representative based on published HNSW benchmarks (ANN-Benchmarks, A subtle but decisive measurement point: **uniform-random high-dimensional
Malkov & Yashunin, 2018) for 128D random unit vectors at 100K scale. vectors are a pathological ANN benchmark.** By concentration of measure, every
pair of random unit vectors in 1536-D sits at cosine ≈ 0, so beyond a tiny
perturbation a query's true top-10 is an arbitrary draw from a thick equidistant
shell — recall@10 then measures impossible tie-breaking, not index quality, and
it gets **worse as the corpus grows** (the shell thickens). Measured on
uniform-random data, recall@10 fell from ~0.97 at 10k to **~0.54 at 100k** — a
corpus-size artifact, not an index regression.
> **Note on data source:** The recall and latency values in this table are estimates Real text/image embeddings instead live on a low-dimensional manifold with
> extrapolated from published ANN-Benchmarks results (Malkov & Yashunin, 2018) for clusters: a point's neighbours are its cluster-mates, distinctly closer than the
> 128D random unit vectors. They are provided as reference, not as measured values bulk. The harness therefore builds a **Gaussian mixture** (`--clusters`,
> from this codebase. `--spread-milli`; default ~100 points/cluster, spread 0.5 ⇒ intra-cluster cosine
> ≈ 0.89, inter ≈ 0) — the shape `related`/`for_you` queries actually run against,
> **The authoritative quality guard** is the regression test in where recall@10 is a meaningful, scale-stable metric. All numbers below use it.
> `tidal/tests/vector_usearch.rs` (`recall_at_10_above_threshold`), which verifies
> recall@10 > 0.95 for the default config (M=16, ef_construction=400) on every CI run.
| M | ef_construction | recall@10 | mean latency (µs) | p99 latency (µs) | build time (s) | ## Results — HNSW parameter sweep (1536D, F16, 100k clustered corpus, measured)
|---|----------------|-----------|-------------------|------------------|----------------|
| 8 | 100 | ~0.942 | ~85 | ~140 | ~2.1 |
| 8 | 200 | ~0.967 | ~88 | ~145 | ~3.8 |
| 8 | 400 | ~0.975 | ~90 | ~148 | ~7.2 |
| **16** | **100** | **~0.966** | **~95** | **~160** | **~4.3** |
| **16** | **200** | **~0.978** | **~98** | **~165** | **~8.1** |
| **16** | **400** | **~0.993** | **~101** | **~170** | **~15.2** |
| 32 | 100 | ~0.975 | ~115 | ~195 | ~9.8 |
| 32 | 200 | ~0.985 | ~118 | ~200 | ~18.5 |
| 32 | 400 | ~0.995 | ~122 | ~205 | ~35.1 |
_Run `cargo bench --manifest-path tidal/Cargo.toml --bench vector` to collect actual | M / ef_c / ef_s | recall@10 | mean (µs) | p99 (µs) | build (s) | mem/1M (GB) |
measurements on target hardware._ |---|---|---|---|---|---|
| **16 / 400 / 128** | **0.9970** | **670** | **1018** | 19.7 | 5.21 |
| 16 / 400 / 200 | 0.9970 | 1129 | 1394 | 19.7 | 5.21 |
| 16 / 400 / 400 | 0.9970 | 2292 | 2719 | 19.7 | 5.21 |
| 16 / 400 / 600 | 0.9970 | 3477 | 4255 | 19.7 | 5.21 |
| 24 / 400 / 128 | 0.9985 | 1074 | 1299 | 39.0 | 5.53 |
| 24 / 400 / 200 | 0.9985 | 1812 | 2239 | 39.0 | 5.53 |
| 24 / 400 / 400 | 0.9985 | 3536 | 4088 | 39.0 | 5.53 |
| 32 / 400 / 200 | 0.9975 | 2331 | 2857 | 42.5 | 5.53 |
Every point clears recall@10 ≥ 0.95 **and** p99 ≤ 10ms (raw single-thread ANN
cost). Two readings:
- **ef_search is the latency lever, and recall saturates early.** At M=16, recall
is already 0.997 at ef_s=128 (**p99 1.0ms**) and does not improve with a wider
beam — only latency grows (ef_s=600 → p99 4.3ms). So the cheap operating point
is ef_s≈128; the [per-query override](#per-query-ef_search-m12p3-now-honored)
lets a latency-sensitive caller pick it without rebuilding.
- **M=24 buys ~0.15% recall for ~1.6× latency and 2× build** — worth it only when
the last fraction of recall matters. M=32 is not better than M=24 here.
## Results — quantization sweep (1536D, M=24, ef_c=400, ef_s=400, 100k, measured)
| quantization | recall@10 | mean (µs) | p99 (µs) | mem/1M (GB) |
|---|---|---|---|---|
| F32 | 0.9985 | 3642 | 5126 | 10.53 |
| **F16** | **0.9960** | 3569 | 4680 | **5.53** |
| Int8 | 0.7150 | 1389 | 1678 | 3.03 |
- **F16 is the right default:** it costs only **0.25%** recall vs F32 (0.9960 vs
0.9985) for **half the memory** — validating the "<1% recall loss" claim *at
1536-D*, not just the 128-D it was previously checked at.
- **Int8 is NOT viable at 1536-D as a drop-in:** recall collapses to **0.715**
(a 28% loss). The memory saving (3.0 vs 5.5 GB/1M) is real but does not justify
losing a quarter of the neighbours; Int8 would need quantization-aware scaling
before it is usable. The roadmap's "Int8 ≈ 1.8 GB/1M as a RAM fallback" option
is therefore **rejected** at this dim on accuracy grounds.
> **Memory note.** The per-1M figures are the *true* `USearch` footprint
> (`memory_usage()`, graph links + quantized vectors), not a vectors-only
> estimate. F16 ≈ **5.2 GB/1M**, not the ~3.4 GB the roadmap projected from
> `1536 × 2 bytes` alone — the ~1.8 GB difference is the HNSW proximity graph
> (M=16). Size pods at ≈ 5.5 GB/1M for F16, ≈ 10.5 GB/1M for F32.
## Live cross-check (real engine, HTTP)
The standalone grid above measures the index in isolation. The authoritative
end-to-end number is `tidal-stress --verify-recall` against a real server: at
20k/1536-D (clustered), the engine's `/vector_search` returned **recall@10 =
1.0000** vs the brute-force cosine oracle at the default beam and at
`--recall-ef-search 400` — confirming both the index quality and that the
per-query `ef_search` is honored over the wire. (The harness's end-to-end p99 of
~19 ms there is HTTP round-trip + 1536-float (de)serialization on one laptop, not
ANN — the raw search is ~1 ms per the grid; it is a single-node wire cost, not an
index limit.)
## Decision ## Decision
**Chosen: M=16, ef_construction=400** **Default stays M=16, ef_construction=400, ef_search=200, F16** — the grid
*validates* it at the production shape: recall@10 0.997, p99 ≈ 1.4 ms raw ANN at
100k/1536-D clustered, 5.2 GB/1M. No change is warranted.
Rationale: - **Latency-sensitive reads** can drop to `ef_search=128` per request (recall
- M=16 provides the best recall/memory trade-off (standard recommendation from Malkov & Yashunin) still 0.997, p99 ≈ 1.0 ms) via the per-query override.
- ef_construction=400 achieves recall@10 ≈ 0.993, well above the 0.95 acceptance threshold - **Maximum recall** (e.g. an offline eval) can use M=24, recall 0.9985, at ~1.6×
- Build overhead vs. ef=200: ~2× slower build, negligible impact for tidalDB's write-rarely pattern latency — but M=24 is not the default because M=16 already clears G2 (≥0.95) and
- M=32 adds ~1-3% additional recall but doubles graph memory — not worth the trade-off at 1M items G1 (≤10 ms) with wide margin.
- **F32** only if an application cannot tolerate the 0.25% F16 gap; doubles RAM.
- **Int8 rejected** at 1536-D (recall 0.715).
**Rejected: M=32, ef_construction=400** ## Per-query `ef_search` (m12p3: now honored)
Reason: ~4× memory overhead vs M=16 with only ~0.2% additional recall.
`M` and `ef_construction` are graph properties fixed at build time. `ef_search` is
a **per-query** knob: a low-latency query can pass `ef_search=64` and a
high-recall query `ef_search=400` against the *same shared index*, concurrently.
Before m12p3, `UsearchIndex` accepted `ef_search` for trait compliance but
**ignored it** (logging a warning) because `USearch` 2.24 has no per-call beam
argument — the only knob is the index-global `change_expansion_search`. m12p3
makes the override real and race-free via an `RwLock` epoch guard
(`UsearchIndex::with_expansion`): searches that agree on `ef_search` run in
parallel under a shared guard; only a query that changes the live beam width
takes the exclusive guard for its `(set, search)` window. The override reaches the
wire through `vector_search_items(.., ef_search)` and the `/vector_search`
`ef_search` field, and `tidal-stress --verify-recall --recall-ef-search` sweeps it
open-loop.
The `ef_search` axis in the table above is the recall/latency trade this knob now
controls per request — higher `ef_search` recovers more true neighbours at a
latency cost, exactly as the HNSW theory predicts (spec
[07-vector-retrieval.md](../specs/07-vector-retrieval.md)).
## Brute-force → HNSW crossover (m12p3: dimension-aware)
The brute-force backend (exact, used for small slots) scans every vector under a
read lock, at `count × dim` cost. A single fixed crossover is wrong: 10,000
vectors is a 1.3M-FMA scan at 128-D (sub-ms) but a 15.4M-FMA scan at 1536-D (tens
of ms, under a lock that also blocks writers). `usearch_min_vectors(dim)` now
scales the crossover so a brute-force scan stays within ~4M FMAs: ≈10,000 at/under
128-D (unchanged), ≈2,600 at 1536-D. Above the crossover a slot is the production
HNSW; below it stays exact. See `storage/vector/registry.rs`.
## Regression Guard ## Regression Guard
The `recall_at_10_above_threshold` test in `tidal/tests/vector_usearch.rs` verifies: `tidal/tests/vector_usearch.rs`:
- Default config (M=16, ef_construction=400) achieves recall@10 > 0.95 at 1K vectors / 128D - `recall_at_10_above_threshold` — default config (M=16, ef_c=400) recall@10 > 0.95.
- Runs on every CI push to catch parameter regressions - `usearch_per_query_ef_search_is_honored` — a wide per-query `ef_search` recovers
strictly more true neighbours than a starved beam (proves the override is live).
## ef_search Note ## Reproduce
`ef_search=200` (fixed during grid search) is the default search-time beam width. ```bash
Increasing ef_search improves recall at query time at the cost of latency. # Production shape on one laptop (oracle ≈ 0.6 GB at 100k/1536D):
For tidalDB's p99 < 50ms RETRIEVE target, ef_search=200 is appropriate. cargo run --release --example ann_grid_search -- \
--corpus 100000 --dim 1536 --queries 200 --k 10
# 1M shape (oracle ≈ 6 GB; run on the k3s node):
cargo run --release --example ann_grid_search -- \
--corpus 1000000 --dim 1536 --queries 200 --k 10
```

View File

@ -94,6 +94,19 @@ The key insight: upper layers provide logarithmic navigation to the right neighb
| **Low-latency (autocomplete, typeahead)** | 16 | 200 | 100 | ef_search=100 halves query time with ~2% recall loss. Acceptable for suggestion candidates that are re-ranked anyway. | | **Low-latency (autocomplete, typeahead)** | 16 | 200 | 100 | ef_search=100 halves query time with ~2% recall loss. Acceptable for suggestion candidates that are re-ranked anyway. |
| **Bulk rebuild (compaction, recovery)** | 16 | 128 | -- | Lower ef_construction for faster rebuilds during compaction. Graph quality is slightly lower but rebuilt indexes serve queries immediately; a background process can rebuild with ef_construction=200 later. | | **Bulk rebuild (compaction, recovery)** | 16 | 128 | -- | Lower ef_construction for faster rebuilds during compaction. Graph quality is slightly lower but rebuilt indexes serve queries immediately; a background process can rebuild with ef_construction=200 later. |
> **Per-query `ef_search` override — IMPLEMENTED (m12p3).** `M` and
> `ef_construction` are graph properties fixed at build time, but `ef_search` is a
> per-request knob: the same shared index can serve a low-latency query at
> `ef_search=100` and a high-recall query at `ef_search=400` concurrently.
> `UsearchIndex` honors a per-query `ef_search` race-free via an `RwLock` epoch
> guard (`UsearchIndex::with_expansion`): same-`ef` searches run in parallel under
> a shared guard; only a query that changes the live beam width briefly takes the
> exclusive guard. `0` selects the slot's construction default. This reaches the
> wire through `vector_search_items(.., ef_search)` and the `/vector_search`
> `ef_search` field. (Before m12p3, `UsearchIndex` accepted `ef_search` for trait
> compliance but ignored it with a warning — `USearch` 2.24 has no per-call beam
> argument, only the index-global `change_expansion_search`.)
### Distance Metrics ### Distance Metrics
tidalDB uses **L2 distance over L2-normalized vectors** as the universal distance metric. This is mathematically equivalent to cosine distance for unit vectors: tidalDB uses **L2 distance over L2-normalized vectors** as the universal distance metric. This is mathematically equivalent to cosine distance for unit vectors:

View File

@ -99,7 +99,8 @@ use super::{
use crate::{ use crate::{
dto::{ dto::{
EmbeddingRequest, FeedQuery, FeedResponse, ItemRequest, SearchQueryParams, SearchResponse, EmbeddingRequest, FeedQuery, FeedResponse, ItemRequest, SearchQueryParams, SearchResponse,
SignalRequest, feed_items, search_items, SignalRequest, VectorSearchRequest, VectorSearchResponse, feed_items, search_items,
vector_matches,
}, },
error::{Result, ServerError}, error::{Result, ServerError},
offload::{ClusterWritePool, offload_read}, offload::{ClusterWritePool, offload_read},
@ -4074,6 +4075,7 @@ pub fn build_region_router(
.route("/hardnegs", post(write_hardneg)) .route("/hardnegs", post(write_hardneg))
.route("/feed", get(feed)) .route("/feed", get(feed))
.route("/search", get(search)) .route("/search", get(search))
.route("/vector_search", post(vector_search))
.route("/cluster/promote", post(cluster_promote)) .route("/cluster/promote", post(cluster_promote))
.route("/cluster/partition", post(cluster_partition)) .route("/cluster/partition", post(cluster_partition))
.route("/cluster/heal", post(cluster_heal)) .route("/cluster/heal", post(cluster_heal))
@ -6191,6 +6193,10 @@ pub async fn feed(
if let Some(user_id) = query.user_id { if let Some(user_id) = query.user_id {
builder = builder.for_user(user_id); builder = builder.for_user(user_id);
} }
// m12p2: "more like this" seed for `profile=related` ANN candidate-gen.
if let Some(seed) = query.similar_to {
builder = builder.similar_to(EntityId::new(seed));
}
let retrieve = builder let retrieve = builder
.build() .build()
.map_err(|e| ClusterAppError(ServerError::Tidal(e.into())))?; .map_err(|e| ClusterAppError(ServerError::Tidal(e.into())))?;
@ -6294,6 +6300,63 @@ pub async fn search(
.into_response()) .into_response())
} }
/// Pure k-NN vector search (the m12p1 recall probe). Serves LOCALLY from this
/// node's hosted shard groups — no `?region=` forwarding: it is a measurement
/// surface, and at the S=1 exit-gate shape every region replica holds the full
/// corpus, so any node answers the whole-corpus nearest set. With S>1 the probe
/// merges each hosted group's local nearest by distance; whole-corpus recall is
/// then bounded by the cross-shard merge (the m12p4 cross-shard read follow-up).
#[utoipa::path(
post,
path = "/vector_search",
tag = "data",
request_body = VectorSearchRequest,
responses(
(status = 200, description = "Nearest items by vector distance, closest-first", body = VectorSearchResponse),
(status = 400, description = "Empty/dimension-mismatched query vector, or no embedding slot"),
(status = 401, description = "Missing or invalid API key"),
),
security(("bearerAuth" = [])),
)]
pub async fn vector_search(
State(node): State<Arc<ClusterNode>>,
Json(req): Json<VectorSearchRequest>,
) -> std::result::Result<Response, ClusterAppError> {
if req.vector.is_empty() {
return Err(ClusterAppError(ServerError::BadRequest(
"vector_search requires a non-empty query vector".into(),
)));
}
let k = req.clamped_k();
let ef_search = req.ef_search();
let vector = req.vector;
let dbs = node.hosted_dbs();
let (items, _total) = offload_region_read(move || {
scatter_merge(
&dbs,
k,
// Distance is "lower = better"; scatter_merge ranks by "higher =
// better", so the merge key is the negated distance.
|r: &tidaldb::storage::vector::VectorSearchResult| -f64::from(r.distance),
|db| {
let r = db
.vector_search_items(&vector, k, ef_search)
.map_err(ServerError::Tidal)?;
let n = r.len();
Ok((r, n))
},
)
})
.await?;
Ok(Json(VectorSearchResponse {
items: vector_matches(&items),
region: None,
})
.into_response())
}
/// If `region` names a DIFFERENT region than this node owns, forward the read /// If `region` names a DIFFERENT region than this node owns, forward the read
/// (verbatim query string, marker set, auth passed through) to that region's /// (verbatim query string, marker set, auth passed through) to that region's
/// process and relay its response. Returns `Ok(None)` when the read should be /// process and relay its response. Returns `Ok(None)` when the read should be

View File

@ -448,6 +448,10 @@ pub async fn feed(
if let Some(user_id) = query.user_id { if let Some(user_id) = query.user_id {
builder = builder.for_user(user_id); builder = builder.for_user(user_id);
} }
// m12p2: "more like this" seed for `profile=related` ANN candidate-gen.
if let Some(seed) = query.similar_to {
builder = builder.similar_to(EntityId::new(seed));
}
let retrieve = builder let retrieve = builder
.build() .build()
.map_err(|e| ClusterAppError(ServerError::Tidal(e.into())))?; .map_err(|e| ClusterAppError(ServerError::Tidal(e.into())))?;

View File

@ -11,7 +11,10 @@
use std::collections::HashMap; use std::collections::HashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tidaldb::query::{retrieve::RetrieveResult, search::SearchResultItem}; use tidaldb::{
query::{retrieve::RetrieveResult, search::SearchResultItem},
storage::vector::VectorSearchResult,
};
use utoipa::ToSchema; use utoipa::ToSchema;
// ── Request DTOs ───────────────────────────────────────────────────────────── // ── Request DTOs ─────────────────────────────────────────────────────────────
@ -65,6 +68,58 @@ pub struct SignalRequest {
pub creator_id: Option<u64>, pub creator_id: Option<u64>,
} }
/// `POST /vector_search` body — the m12p1 recall-measurement probe.
///
/// A raw query vector goes in the body (1536 floats do not belong in a GET query
/// string), and the engine returns the `k` items whose stored content embedding
/// is nearest by the slot's distance metric — **no** profile scoring, fusion, or
/// diversity. Comparing this against a brute-force cosine ground truth yields the
/// ANN recall@k the harness reports.
///
/// `Serialize` so the cluster region node can forward the verbatim body to a peer
/// region on a `?region=` read, exactly like the other write/read DTOs.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct VectorSearchRequest {
/// Dense query vector. Must match the item content slot's dimensionality.
pub vector: Vec<f32>,
/// Number of nearest neighbors to return; clamped to `MAX_LIMIT` (1000) at
/// the trust boundary. Defaults to 10 (the recall@10 target's `k`).
#[serde(default = "default_k")]
#[schema(example = 10)]
pub k: u32,
/// Optional per-request HNSW beam width override (m12p3 makes this the
/// recall/latency knob). Omitted = the slot's configured default.
#[serde(default)]
pub ef_search: Option<u32>,
}
impl VectorSearchRequest {
/// The requested `k` clamped to [`MAX_LIMIT`] — the single enforcement point
/// for the network trust boundary, mirroring the `/feed` and `/search`
/// `clamped_limit`. An oversized `k` can never size the engine's result
/// buffer.
#[must_use]
pub const fn clamped_k(&self) -> usize {
(if self.k > MAX_LIMIT {
MAX_LIMIT
} else {
self.k
}) as usize
}
/// The `ef_search` override as a `usize`, if any.
#[must_use]
pub fn ef_search(&self) -> Option<usize> {
self.ef_search.map(|v| v as usize)
}
}
/// Default `k` (nearest-neighbor count) when `k` is omitted: the recall@10 `k`.
#[must_use]
pub const fn default_k() -> u32 {
10
}
/// Maximum page size a client may request via `?limit=`. /// Maximum page size a client may request via `?limit=`.
/// ///
/// `limit` arrives across the network trust boundary and feeds the engine's /// `limit` arrives across the network trust boundary and feeds the engine's
@ -97,6 +152,12 @@ pub struct FeedQuery {
/// Target region (cluster mode only; rejected with 400 standalone). /// Target region (cluster mode only; rejected with 400 standalone).
#[serde(default)] #[serde(default)]
pub region: Option<String>, pub region: Option<String>,
/// Seed item for "more like this" (m12p2): with `profile=related`, the
/// engine resolves this item's embedding and sources candidates by ANN
/// nearest-neighbour over it. Ignored by profiles that do not use it.
#[serde(default)]
#[param(example = 42)]
pub similar_to: Option<u64>,
} }
impl FeedQuery { impl FeedQuery {
@ -226,6 +287,41 @@ pub struct SearchItem {
pub semantic_score: Option<f64>, pub semantic_score: Option<f64>,
} }
/// `POST /vector_search` response body — the raw ANN result, closest-first.
#[derive(Debug, Serialize, ToSchema)]
pub struct VectorSearchResponse {
/// Nearest items, ordered by ascending distance (closest first).
pub items: Vec<VectorMatch>,
/// Region the search was served from (cluster mode); `null` standalone.
pub region: Option<String>,
}
/// One nearest-neighbor match: the entity and its distance from the query.
#[derive(Debug, Serialize, ToSchema)]
pub struct VectorMatch {
/// Entity ID of the matched item.
pub entity_id: u64,
/// L2-squared distance from the query vector (lower = more similar). For the
/// L2-normalized vectors tidalDB stores, this lies in `[0.0, 4.0]` and is
/// monotonic with cosine distance.
pub distance: f32,
}
/// Map one engine [`VectorSearchResult`] into a wire [`VectorMatch`].
#[must_use]
pub const fn vector_match(r: &VectorSearchResult) -> VectorMatch {
VectorMatch {
entity_id: r.id,
distance: r.distance,
}
}
/// Map a slice of engine [`VectorSearchResult`]s into wire [`VectorMatch`]es.
#[must_use]
pub fn vector_matches(items: &[VectorSearchResult]) -> Vec<VectorMatch> {
items.iter().map(vector_match).collect()
}
// ── Engine-result → DTO mapping ────────────────────────────────────────────── // ── Engine-result → DTO mapping ──────────────────────────────────────────────
/// Map one engine [`RetrieveResult`] into a wire [`FeedItem`]. /// Map one engine [`RetrieveResult`] into a wire [`FeedItem`].
@ -289,6 +385,7 @@ mod tests {
profile: default_profile(), profile: default_profile(),
limit, limit,
region: None, region: None,
similar_to: None,
} }
} }

View File

@ -90,6 +90,7 @@ impl Modify for SecurityAddon {
crate::router::write_signal, crate::router::write_signal,
crate::router::feed, crate::router::feed,
crate::router::search, crate::router::search,
crate::router::vector_search,
), ),
components(schemas( components(schemas(
crate::dto::ItemRequest, crate::dto::ItemRequest,
@ -100,6 +101,9 @@ impl Modify for SecurityAddon {
crate::dto::SignalValue, crate::dto::SignalValue,
crate::dto::SearchResponse, crate::dto::SearchResponse,
crate::dto::SearchItem, crate::dto::SearchItem,
crate::dto::VectorSearchRequest,
crate::dto::VectorSearchResponse,
crate::dto::VectorMatch,
)), )),
modifiers(&SecurityAddon), modifiers(&SecurityAddon),
tags( tags(
@ -209,6 +213,7 @@ pub struct ClusterApiDoc;
crate::cluster::node::write_hardneg, crate::cluster::node::write_hardneg,
crate::cluster::node::feed, crate::cluster::node::feed,
crate::cluster::node::search, crate::cluster::node::search,
crate::cluster::node::vector_search,
crate::cluster::node::sharded_create_item, crate::cluster::node::sharded_create_item,
crate::cluster::node::sharded_write_embedding, crate::cluster::node::sharded_write_embedding,
crate::cluster::node::sharded_write_signal, crate::cluster::node::sharded_write_signal,
@ -224,6 +229,9 @@ pub struct ClusterApiDoc;
crate::dto::SignalValue, crate::dto::SignalValue,
crate::dto::SearchResponse, crate::dto::SearchResponse,
crate::dto::SearchItem, crate::dto::SearchItem,
crate::dto::VectorSearchRequest,
crate::dto::VectorSearchResponse,
crate::dto::VectorMatch,
crate::cluster::node::LocalStatusResponse, crate::cluster::node::LocalStatusResponse,
crate::cluster::node::AggregatedStatusResponse, crate::cluster::node::AggregatedStatusResponse,
crate::cluster::node::AggregatedRegionStatus, crate::cluster::node::AggregatedRegionStatus,
@ -263,6 +271,7 @@ mod tests {
"/signals", "/signals",
"/feed", "/feed",
"/search", "/search",
"/vector_search",
] { ] {
assert!(paths.contains_key(p), "standalone doc missing path {p}"); assert!(paths.contains_key(p), "standalone doc missing path {p}");
} }
@ -316,6 +325,7 @@ mod tests {
"/hardnegs", "/hardnegs",
"/feed", "/feed",
"/search", "/search",
"/vector_search",
"/cluster/status/local", "/cluster/status/local",
"/cluster/status", "/cluster/status",
"/cluster/promote", "/cluster/promote",

View File

@ -30,7 +30,8 @@ use tower_http::{
use crate::{ use crate::{
dto::{ dto::{
EmbeddingRequest, FeedQuery, FeedResponse, ItemRequest, SearchQueryParams, SearchResponse, EmbeddingRequest, FeedQuery, FeedResponse, ItemRequest, SearchQueryParams, SearchResponse,
SignalRequest, feed_items, search_items, SignalRequest, VectorSearchRequest, VectorSearchResponse, feed_items, search_items,
vector_matches,
}, },
error::{Result, ServerError}, error::{Result, ServerError},
state::ServerState, state::ServerState,
@ -116,6 +117,7 @@ pub fn build_router(
.route("/signals", post(write_signal)) .route("/signals", post(write_signal))
.route("/feed", get(feed)) .route("/feed", get(feed))
.route("/search", get(search)) .route("/search", get(search))
.route("/vector_search", post(vector_search))
.layer(axum::extract::DefaultBodyLimit::max(BODY_LIMIT_BYTES)) .layer(axum::extract::DefaultBodyLimit::max(BODY_LIMIT_BYTES))
.with_state(state); .with_state(state);
@ -359,6 +361,11 @@ pub(crate) async fn feed(
if let Some(user_id) = query.user_id { if let Some(user_id) = query.user_id {
builder = builder.for_user(user_id); builder = builder.for_user(user_id);
} }
// m12p2: seed for "more like this" — `profile=related` resolves this item's
// embedding and sources candidates by ANN over it.
if let Some(seed) = query.similar_to {
builder = builder.similar_to(EntityId::new(seed));
}
let retrieve = builder.build().map_err(|e| TidalErrorWrapper(e.into()))?; let retrieve = builder.build().map_err(|e| TidalErrorWrapper(e.into()))?;
// `retrieve` is a synchronous, CPU-bound engine call (candidate scan + // `retrieve` is a synchronous, CPU-bound engine call (candidate scan +
@ -426,6 +433,49 @@ pub(crate) async fn search(
})) }))
} }
#[utoipa::path(
post,
path = "/vector_search",
tag = "data",
request_body = VectorSearchRequest,
responses(
(status = 200, description = "Nearest items by vector distance, closest-first", body = VectorSearchResponse),
(status = 400, description = "Empty/dimension-mismatched query vector, or no embedding slot"),
(status = 401, description = "Missing or invalid API key"),
),
security(("bearerAuth" = [])),
)]
pub(crate) async fn vector_search(
State(state): State<Arc<ServerState>>,
Json(req): Json<VectorSearchRequest>,
) -> Result<Json<VectorSearchResponse>, AppError> {
// Reject an empty vector at the boundary with a clear 400 rather than letting
// it reach the engine as a dimension mismatch.
if req.vector.is_empty() {
return Err(AppError(ServerError::BadRequest(
"vector_search requires a non-empty query vector".into(),
)));
}
let k = req.clamped_k();
let ef_search = req.ef_search();
let vector = req.vector;
// Pure k-NN is a synchronous, CPU-bound index search; offload to the blocking
// pool so a burst of recall probes cannot pin a reactor worker — the same
// treatment `/feed` and `/search` give their engine calls.
let offload_state = Arc::clone(&state);
let result = crate::offload::offload_read(move || {
offload_state.vector_search(None, &vector, k, ef_search)
})
.await
.map_err(AppError)?;
Ok(Json(VectorSearchResponse {
items: vector_matches(&result),
region: None,
}))
}
/// Readiness probe: 200 when ready, 503 when shutting down. /// Readiness probe: 200 when ready, 503 when shutting down.
#[utoipa::path( #[utoipa::path(
get, get,

View File

@ -171,6 +171,28 @@ impl ServerState {
self.db.search(query).map_err(ServerError::from) self.db.search(query).map_err(ServerError::from)
} }
/// Pure k-nearest-neighbor vector search (the m12p1 recall probe) within the
/// (standalone) region. Returns the raw ANN result — no profile scoring or
/// diversity — for recall@k measurement against a brute-force ground truth.
///
/// # Errors
///
/// Returns [`ServerError`] if a region is specified (standalone mode is
/// single-region) or the underlying DB vector search fails (no slot, lock
/// poisoned, or a query-vector dimension mismatch).
pub fn vector_search(
&self,
region_name: Option<&str>,
vector: &[f32],
k: usize,
ef_search: Option<usize>,
) -> Result<Vec<tidaldb::storage::vector::VectorSearchResult>> {
ensure_standalone(region_name)?;
self.db
.vector_search_items(vector, k, ef_search)
.map_err(ServerError::from)
}
/// Count items in the (standalone) region. /// Count items in the (standalone) region.
/// ///
/// # Errors /// # Errors

View File

@ -59,6 +59,46 @@ fn region_schema() -> Schema {
builder.build().unwrap() builder.build().unwrap()
} }
/// The `region_schema` plus an 8-dim Item `content` embedding slot, so the
/// `/vector_search` recall probe (m12p1) resolves a slot and the replicated
/// embeddings get indexed on the follower.
fn region_schema_emb() -> Schema {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::OneHour])
.velocity(false)
.add();
builder.embedding_slot("content", EntityKind::Item, 8);
builder.build().unwrap()
}
/// Build a region node whose schema carries the `content` embedding slot. Mirrors
/// [`build_region`] (off-reactor gRPC construction) with the embeddings schema.
fn build_region_emb(topology: TopologySpec, region: &str, dir: &tempfile::TempDir) -> ClusterNode {
let region = region.to_string();
let data_dir = dir.path().to_path_buf();
std::thread::spawn(move || {
ClusterNode::new(
&topology,
&region,
region_schema_emb(),
Vec::new(),
Some(data_dir),
0,
)
})
.join()
.unwrap()
.expect("region node builds with real gRPC transport")
}
/// Reserve a free loopback port and return its address. /// Reserve a free loopback port and return its address.
fn free_addr() -> SocketAddr { fn free_addr() -> SocketAddr {
TcpListener::bind("127.0.0.1:0") TcpListener::bind("127.0.0.1:0")
@ -316,6 +356,108 @@ fn region_node_replicates_over_grpc() {
rt.shutdown_timeout(Duration::from_secs(2)); rt.shutdown_timeout(Duration::from_secs(2));
} }
/// m12p1: the region node serves `POST /vector_search` over a corpus whose
/// embeddings replicated to the follower over real gRPC. Embeddings written to
/// the leader (forwarded + WAL-replicated, kind-2) are INDEXED on the follower,
/// and a k-NN query on the FOLLOWER returns the nearest item closest-first.
#[test]
fn region_node_serves_vector_search_over_replicated_corpus() {
let pair = Pair::new();
let leader_dir = region_dir();
let leader = build_region_emb(pair.topology(), &pair.leader_name, &leader_dir);
let follower_dir = region_dir();
let follower = build_region_emb(pair.topology(), &pair.follower_name, &follower_dir);
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap();
serve(
&rt,
build_region_router(Arc::new(leader), mk_test_creds()),
pair.leader_http,
);
serve(
&rt,
build_region_router(Arc::new(follower), mk_test_creds()),
pair.follower_http,
);
let client = reqwest::blocking::Client::new();
let leader_base = format!("http://{}", pair.leader_http);
let follower_base = format!("http://{}", pair.follower_http);
// 8 items on distinct axes (item i → unit vector on axis i-1). Items are not
// WAL-replicated in this mode, so broadcast them to both nodes; embeddings ARE
// WAL-replicated, so write them only on the leader and let them flow to the
// follower.
let axis_vec = |axis: usize| -> Vec<f32> {
let mut v = vec![0.0_f32; 8];
v[axis] = 1.0;
v
};
for i in 1..=8u64 {
for base in [&leader_base, &follower_base] {
let resp = client
.post(format!("{base}/items"))
.json(&serde_json::json!({
"entity_id": i,
"metadata": { "title": format!("item {i}") }
}))
.send()
.unwrap();
assert!(resp.status().is_success(), "POST /items: {}", resp.status());
}
let resp = client
.post(format!("{leader_base}/embeddings"))
.json(&serde_json::json!({ "entity_id": i, "values": axis_vec((i - 1) as usize) }))
.send()
.unwrap();
assert!(
resp.status().is_success(),
"POST /embeddings on leader: {}",
resp.status()
);
}
// Follower converges: all 8 embedding events applied, lag back to 0.
poll_status(&client, &follower_base, |applied, lag| {
applied >= 8 && lag == 0
});
// k-NN on the FOLLOWER: an axis-0 query must return item 1 first (its exact
// vector), with k honored and distances ascending.
let resp = client
.post(format!("{follower_base}/vector_search"))
.json(&serde_json::json!({ "vector": axis_vec(0), "k": 3 }))
.send()
.unwrap();
assert_eq!(
resp.status(),
reqwest::StatusCode::OK,
"POST /vector_search on follower"
);
let body: serde_json::Value = resp.json().unwrap();
let items = body["items"].as_array().expect("items array");
assert_eq!(items.len(), 3, "k=3 nearest from the follower");
assert_eq!(
items[0]["entity_id"].as_u64(),
Some(1),
"the axis-0 item is nearest on the replicated follower; got {body}"
);
let dists: Vec<f64> = items
.iter()
.map(|it| it["distance"].as_f64().unwrap())
.collect();
for w in dists.windows(2) {
assert!(w[0] <= w[1], "distances must ascend: {dists:?}");
}
rt.shutdown_timeout(Duration::from_secs(2));
}
/// m11p6 sharding × replication (in-process): 2 nodes × 2 shards × RF=2, with /// m11p6 sharding × replication (in-process): 2 nodes × 2 shards × RF=2, with
/// shard 0 led by node A and shard 1 led by node B (balanced leaders). Every /// shard 0 led by node A and shard 1 led by node B (balanced leaders). Every
/// node hosts a replica of BOTH groups. A write routes by entity hash to its /// node hosts a replica of BOTH groups. A write routes by entity hash to its

View File

@ -0,0 +1,188 @@
// Integration-test exemption (same posture as the other tidal-server tests).
#![allow(clippy::unwrap_used, clippy::cast_possible_truncation)]
//! End-to-end coverage for the m12p1 `POST /vector_search` recall probe.
//!
//! Drives the real standalone handler in-process via `tower::ServiceExt::oneshot`
//! (no TCP bind): seed items + embeddings, then POST a query vector and assert
//! the raw k-NN result — closest-first ordering, `k` honored — plus the boundary
//! 400s (empty vector, dimension mismatch). This pins the surface the
//! `tidal-stress --verify-recall` harness measures against.
use std::sync::Arc;
use axum::{
body::Body,
http::{Method, Request, StatusCode},
};
use tidal_server::{router::build_router, state::ServerState};
use tidaldb::TidalDb;
use tower::ServiceExt;
/// Dimensionality of the default schema's `content_vector` slot.
const DIM: usize = 128;
fn make_app() -> axum::Router {
let (schema, profiles) = tidal_server::config::load_schema(None).unwrap();
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema)
.with_profiles(profiles)
.open()
.unwrap();
let state = Arc::new(ServerState::new(db));
build_router(
state,
Arc::new(tidal_server::cluster::security::ClusterCreds::unauthenticated()),
)
}
/// A 128-dim one-hot-ish vector: component `axis` set to `mag`, rest 0 — except
/// we nudge a second axis a hair so no vector is exactly zero-norm.
fn axis_vector(axis: usize, mag: f32) -> Vec<f32> {
let mut v = vec![0.0_f32; DIM];
v[axis] = mag;
v[(axis + 1) % DIM] = 0.01;
v
}
async fn post_json(app: &axum::Router, uri: &str, body: serde_json::Value) -> StatusCode {
app.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(uri)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap()
.status()
}
async fn post_json_full(
app: &axum::Router,
uri: &str,
body: serde_json::Value,
) -> (StatusCode, serde_json::Value) {
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(uri)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
(status, json)
}
async fn seed(app: &axum::Router) {
// Items on distinct axes: item 1 ~ axis 0, item 3 ~ axis 0 (close to item 1),
// item 2 ~ axis 64 (far). A query on axis 0 must rank 1 and 3 above 2.
for (id, v) in [
(1u64, axis_vector(0, 1.0)),
(2u64, axis_vector(64, 1.0)),
(3u64, axis_vector(0, 0.8)),
] {
let s = post_json(
app,
"/items",
serde_json::json!({ "entity_id": id, "metadata": {} }),
)
.await;
assert_eq!(s, StatusCode::CREATED, "item {id}");
let s = post_json(
app,
"/embeddings",
serde_json::json!({ "entity_id": id, "values": v }),
)
.await;
assert_eq!(s, StatusCode::NO_CONTENT, "embedding {id}");
}
}
#[tokio::test]
async fn vector_search_returns_nearest_closest_first() {
let app = make_app();
seed(&app).await;
let (status, body) = post_json_full(
&app,
"/vector_search",
serde_json::json!({ "vector": axis_vector(0, 1.0), "k": 3 }),
)
.await;
assert_eq!(status, StatusCode::OK, "body: {body}");
let items = body["items"].as_array().expect("items array");
assert_eq!(items.len(), 3, "k=3 nearest");
// Closest-first: an axis-0 query ranks the two axis-0 items (1, 3) above the
// far axis-64 item (2).
let ids: Vec<u64> = items
.iter()
.map(|it| it["entity_id"].as_u64().unwrap())
.collect();
assert_eq!(ids[0], 1, "the exact-axis item is nearest");
assert!(
ids[..2].contains(&3),
"the near-axis item ranks above the far one; got {ids:?}"
);
assert_eq!(ids[2], 2, "the far axis-64 item is last");
// Distances are present and ascending.
let dists: Vec<f64> = items
.iter()
.map(|it| it["distance"].as_f64().unwrap())
.collect();
for w in dists.windows(2) {
assert!(w[0] <= w[1], "distances must ascend: {dists:?}");
}
}
#[tokio::test]
async fn vector_search_k_defaults_to_ten_and_clamps_to_corpus() {
let app = make_app();
seed(&app).await;
// k omitted → defaults to 10, but only 3 items exist, so 3 come back.
let (status, body) = post_json_full(
&app,
"/vector_search",
serde_json::json!({ "vector": axis_vector(0, 1.0) }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["items"].as_array().unwrap().len(), 3);
}
#[tokio::test]
async fn vector_search_empty_vector_is_400() {
let app = make_app();
seed(&app).await;
let v: Vec<f32> = vec![];
let status = post_json(&app, "/vector_search", serde_json::json!({ "vector": v })).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn vector_search_dimension_mismatch_is_400() {
let app = make_app();
seed(&app).await;
// 4-dim query against a 128-dim slot → a client error, not a 500.
let status = post_json(
&app,
"/vector_search",
serde_json::json!({ "vector": vec![0.1f32, 0.2, 0.3, 0.4] }),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}

View File

@ -32,6 +32,7 @@ fn make_workload(mix: &str) -> Workload {
1.3, // hot_skew 1.3, // hot_skew
24, // feed_limit 24, // feed_limit
128, // embedding_dim 128, // embedding_dim
None, // forced_profile (use the weighted feed-profile mix)
) )
} }

View File

@ -12,12 +12,46 @@ use std::time::Duration;
use rand::Rng; use rand::Rng;
use reqwest::header::{AUTHORIZATION, HeaderValue}; use reqwest::header::{AUTHORIZATION, HeaderValue};
use serde::{Deserialize, Serialize};
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use crate::error::{Result, StressError}; use crate::error::{Result, StressError};
use crate::metrics::StatusClass; use crate::metrics::StatusClass;
use crate::workload::{Body, HttpMethod, ItemMetadata, Plan}; use crate::workload::{Body, HttpMethod, ItemMetadata, Plan};
/// `POST /vector_search` request body for the recall probe — serialized straight
/// to the wire (borrows the query vector; no owned copy).
#[derive(Serialize)]
struct VectorQuery<'a> {
vector: &'a [f32],
k: usize,
#[serde(skip_serializing_if = "Option::is_none")]
ef_search: Option<usize>,
}
/// A `POST /signals` body carrying `user_id`, used to BUILD a user's preference
/// vector (a positive-engagement `like` on an embedded item). The standalone
/// surface reads `user_id`; the regular load `Body::Signal` omits it.
#[derive(Serialize)]
struct PreferenceLike {
entity_id: u64,
signal: &'static str,
weight: f64,
user_id: u64,
}
/// The parts of the `/vector_search` response the recall oracle reads: the ranked
/// entity IDs (distances are ignored — recall@k is a set-overlap metric).
#[derive(Deserialize)]
struct VectorSearchResponseBody {
items: Vec<VectorSearchResponseItem>,
}
#[derive(Deserialize)]
struct VectorSearchResponseItem {
entity_id: u64,
}
/// Async client with optional bearer auth. Cheap to clone (Arc inside reqwest). /// Async client with optional bearer auth. Cheap to clone (Arc inside reqwest).
#[derive(Clone)] #[derive(Clone)]
pub struct HttpClient { pub struct HttpClient {
@ -127,6 +161,50 @@ impl HttpClient {
} }
} }
/// Issue a `POST {base}/vector_search` recall probe (m12p1) and return both
/// the capacity [`StatusClass`] and, on a 2xx, the returned entity IDs in
/// rank order (closest-first). The IDs are what the recall oracle compares
/// against its brute-force ground truth; a non-2xx or a body it cannot parse
/// yields `None` for the IDs (the status still classifies the outcome).
///
/// Latency is measured by the caller around this call (from the request's
/// intended send time), preserving the coordinated-omission correction.
pub async fn vector_search_ids(
&self,
base: &str,
vector: &[f32],
k: usize,
ef_search: Option<usize>,
) -> (StatusClass, Option<Vec<u64>>) {
let url = format!("{base}/vector_search");
let body = VectorQuery {
vector,
k,
ef_search,
};
let rb = self.inner.post(&url).json(&body);
match self.apply_auth(rb).send().await {
Ok(resp) => {
let class = StatusClass::from_status(resp.status().as_u16());
if class != StatusClass::Ok {
// Drain so the connection returns to the pool.
let _ = resp.bytes().await;
return (class, None);
}
// The recall oracle needs the ranked IDs; parse them out. A parse
// failure on a 2xx is reported as Ok-without-ids (a recall miss is
// not a transport error) so the latency sample is still honest.
let ids = resp
.json::<VectorSearchResponseBody>()
.await
.ok()
.map(|b| b.items.into_iter().map(|i| i.entity_id).collect());
(class, ids)
}
Err(_) => (StatusClass::Transport, None),
}
}
/// Fetch `GET {base}/cluster/status` (unauthenticated) and return the leader /// Fetch `GET {base}/cluster/status` (unauthenticated) and return the leader
/// name and the worst replication lag across regions — used to watch whether /// name and the worst replication lag across regions — used to watch whether
/// follower lag grows under write load between ramp stages. `None` on any fault. /// follower lag grows under write load between ramp stages. `None` on any fault.
@ -145,6 +223,36 @@ impl HttpClient {
Some((leader, max_lag)) Some((leader, max_lag))
} }
/// POST a preference-building `like` (with `user_id`), retrying 429 like the
/// corpus seeder. Returns whether it ultimately succeeded.
async fn post_preference(&self, url: &str, body: &PreferenceLike) -> bool {
const MAX_ATTEMPTS: u32 = 8;
for attempt in 0..MAX_ATTEMPTS {
let rb = self.inner.post(url).json(body);
match self.apply_auth(rb).send().await {
Ok(resp) => {
let code = resp.status().as_u16();
let _ = resp.bytes().await;
if (200..=299).contains(&code) {
return true;
}
if code == 429 {
tokio::time::sleep(Duration::from_millis(40 + u64::from(attempt) * 20))
.await;
continue;
}
if code == 503 {
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
return false;
}
Err(_) => return false,
}
}
false
}
/// One-off POST returning the raw status, for the seeder (which needs to know /// One-off POST returning the raw status, for the seeder (which needs to know
/// success vs 429-retry rather than a capacity class). /// success vs 429-retry rather than a capacity class).
async fn post_json(&self, url: &str, body: &Body) -> Option<u16> { async fn post_json(&self, url: &str, body: &Body) -> Option<u16> {
@ -161,12 +269,17 @@ impl HttpClient {
} }
/// Register `count` items (id 1..=count) plus a `dim`-wide content embedding for /// Register `count` items (id 1..=count) plus a `dim`-wide content embedding for
/// each, against `base`. /// each, against `base`, using RANDOM embedding values.
/// ///
/// Use the LEADER url so items broadcast to every region and `/feed` on any /// Use the LEADER url so items broadcast to every region and `/feed` on any
/// region can rank them. Bounded-concurrency, retries 429. Returns the number of /// region can rank them. Bounded-concurrency, retries 429. Returns the number of
/// items confirmed registered. /// items confirmed registered.
/// ///
/// The ramp mode seeds random embeddings because their exact values are
/// irrelevant to the write-path cost it measures. The recall mode instead calls
/// [`seed_corpus_with`] with a DETERMINISTIC id-keyed generator so the
/// generator's brute-force ground truth matches what the engine indexed.
///
/// # Errors /// # Errors
/// ///
/// Returns [`StressError::Seed`] if the bounded-concurrency semaphore is closed /// Returns [`StressError::Seed`] if the bounded-concurrency semaphore is closed
@ -178,6 +291,38 @@ pub async fn seed_corpus(
dim: usize, dim: usize,
concurrency: usize, concurrency: usize,
) -> Result<u64> { ) -> Result<u64> {
seed_corpus_with(client, base, count, concurrency, move |_id| {
// A fresh ThreadRng per call (it is !Send, so it cannot cross the await
// inside the task; building the Vec up front keeps it off the await path).
let mut rng = rand::rng();
(0..dim).map(|_| rng.random::<f32>() - 0.5).collect()
})
.await
}
/// Register `count` items (id 1..=count) plus a content embedding for each, where
/// each item's embedding values come from `embedding(id)`.
///
/// This is the seam the recall harness uses: passing a deterministic id-keyed
/// generator makes the seeded corpus exactly reproducible, so the in-RAM
/// brute-force ground truth the oracle computes is bit-for-bit the corpus the
/// engine indexed. Same bounded-concurrency + 429-retry contract as
/// [`seed_corpus`].
///
/// # Errors
///
/// Returns [`StressError::Seed`] if the bounded-concurrency semaphore is closed
/// while acquiring a permit.
pub async fn seed_corpus_with<F>(
client: &HttpClient,
base: &str,
count: u64,
concurrency: usize,
embedding: F,
) -> Result<u64>
where
F: Fn(u64) -> Vec<f32> + Send + Sync + Clone + 'static,
{
let sem = Arc::new(Semaphore::new(concurrency.max(1))); let sem = Arc::new(Semaphore::new(concurrency.max(1)));
let done = Arc::new(AtomicU64::new(0)); let done = Arc::new(AtomicU64::new(0));
let items_url = format!("{base}/items"); let items_url = format!("{base}/items");
@ -194,13 +339,10 @@ pub async fn seed_corpus(
let items_url = items_url.clone(); let items_url = items_url.clone();
let emb_url = emb_url.clone(); let emb_url = emb_url.clone();
let done = done.clone(); let done = done.clone();
let embedding = embedding.clone();
handles.push(tokio::spawn(async move { handles.push(tokio::spawn(async move {
let _permit = permit; let _permit = permit;
let category = SEED_CATEGORIES[(id as usize) % SEED_CATEGORIES.len()]; let category = SEED_CATEGORIES[(id as usize) % SEED_CATEGORIES.len()];
// Build bodies and DROP the RNG before the awaits (ThreadRng is !Send).
let (item, emb) = {
let mut rng = rand::rng();
let values: Vec<f32> = (0..dim).map(|_| rng.random::<f32>() - 0.5).collect();
let item = Body::Item { let item = Body::Item {
entity_id: id, entity_id: id,
metadata: ItemMetadata { metadata: ItemMetadata {
@ -210,9 +352,7 @@ pub async fn seed_corpus(
}; };
let emb = Body::Embedding { let emb = Body::Embedding {
entity_id: id, entity_id: id,
values, values: embedding(id),
};
(item, emb)
}; };
// The item and embedding writes are independent — the engine keys the // The item and embedding writes are independent — the engine keys the
@ -235,6 +375,61 @@ pub async fn seed_corpus(
Ok(done.load(Ordering::Relaxed)) Ok(done.load(Ordering::Relaxed))
} }
/// Build a preference vector for each user `1..=users` (m12p2).
///
/// Each user sends one positive `like` signal (carrying `user_id`) on a
/// deterministic embedded item, so the `for_you` profile's ANN candidate
/// generation engages for those users instead of degrading to a scan.
///
/// Each user `u` likes item `((u - 1) % corpus) + 1`, whose embedding becomes (a
/// scaled copy of) the user's preference vector. Bounded concurrency; retries
/// 429. Returns the number of preference likes confirmed.
///
/// # Errors
///
/// Returns [`StressError::Seed`] if the bounded-concurrency semaphore is closed.
pub async fn seed_preferences(
client: &HttpClient,
base: &str,
users: u64,
corpus: u64,
concurrency: usize,
) -> Result<u64> {
let sem = Arc::new(Semaphore::new(concurrency.max(1)));
let done = Arc::new(AtomicU64::new(0));
let url = format!("{base}/signals");
let corpus = corpus.max(1);
let mut handles = Vec::new();
for u in 1..=users {
let permit = sem
.clone()
.acquire_owned()
.await
.map_err(|e| StressError::Seed(format!("semaphore closed: {e}")))?;
let client = client.clone();
let url = url.clone();
let done = done.clone();
handles.push(tokio::spawn(async move {
let _permit = permit;
let item = ((u - 1) % corpus) + 1;
let body = PreferenceLike {
entity_id: item,
signal: "like",
weight: 1.0,
user_id: u,
};
if client.post_preference(&url, &body).await {
done.fetch_add(1, Ordering::Relaxed);
}
}));
}
for h in handles {
let _ = h.await;
}
Ok(done.load(Ordering::Relaxed))
}
const SEED_CATEGORIES: [&str; 12] = [ const SEED_CATEGORIES: [&str; 12] = [
"anime", "anime",
"gaming", "gaming",

View File

@ -11,6 +11,7 @@
pub mod client; pub mod client;
pub mod error; pub mod error;
pub mod metrics; pub mod metrics;
pub mod recall;
pub mod scheduler; pub mod scheduler;
pub mod summary; pub mod summary;
pub mod workload; pub mod workload;

View File

@ -16,11 +16,14 @@ use std::time::Duration;
use clap::Parser; use clap::Parser;
use tidal_stress::client::{HttpClient, seed_corpus}; use tidal_stress::client::{HttpClient, seed_corpus, seed_corpus_with, seed_preferences};
use tidal_stress::error::{Result, StressError}; use tidal_stress::error::{Result, StressError};
use tidal_stress::metrics::{self, StageStats}; use tidal_stress::metrics::{self, StageStats};
use tidal_stress::recall::{GroundTruth, QueryPool, embedding_for, run_recall_stage};
use tidal_stress::scheduler::{Stage, parse_ramp, run_stage}; use tidal_stress::scheduler::{Stage, parse_ramp, run_stage};
use tidal_stress::summary::{GateConfig, RunSummary, StageSummary}; use tidal_stress::summary::{
GateConfig, RecallRunSummary, RecallStageSummary, RunSummary, StageSummary,
};
use tidal_stress::workload::{OpKind, Workload, parse_mix}; use tidal_stress::workload::{OpKind, Workload, parse_mix};
#[derive(Parser)] #[derive(Parser)]
@ -109,6 +112,20 @@ struct Cli {
#[arg(long, default_value_t = 24)] #[arg(long, default_value_t = 24)]
feed_limit: u32, feed_limit: u32,
/// Force EVERY feed read to this ranking profile instead of the default
/// for_you/trending/hot/new mix, so a `--mix reads` ramp measures one
/// profile's retrieve p99 in isolation (m12p2 G1 check). E.g. for_you,
/// trending, related.
#[arg(long)]
feed_profile: Option<String>,
/// Before the ramp, build a preference vector for every user (one positive
/// `like` per user on an embedded item) so `for_you` exercises ANN candidate
/// generation rather than degrading to a scan (m12p2). Standalone only (the
/// signal user_id is read there); needs the corpus to carry embeddings.
#[arg(long, default_value_t = false)]
seed_preferences: bool,
/// Embedding width. MUST match the deployed schema's content_vector /// Embedding width. MUST match the deployed schema's content_vector
/// dimensions. thepeach production is 1536 (text-embedding-3-small); the /// dimensions. thepeach production is 1536 (text-embedding-3-small); the
/// 128 default is the legacy smoke width. A mismatch fails the seed. /// 128 default is the legacy smoke width. A mismatch fails the seed.
@ -152,6 +169,47 @@ struct Cli {
/// (the capacity knee). The nightly soak's PASS/FAIL signal. /// (the capacity knee). The nightly soak's PASS/FAIL signal.
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
fail_on_knee: bool, fail_on_knee: bool,
// ── m12p1 read-recall harness ────────────────────────────────────────────
/// Run the READ-RECALL harness instead of the signal ramp: seed a corpus with
/// deterministic embeddings, then ramp `/vector_search` probes open-loop,
/// reporting true p99 + recall@k vs a brute-force cosine ground truth and the
/// read-knee (highest QPS where p99 ≤ target AND recall ≥ target). Requires a
/// single-index deployment (standalone, or a cluster at the S=1 shape where
/// every replica holds the full corpus).
#[arg(long, default_value_t = false)]
verify_recall: bool,
/// `k` for recall@k and the nearest-neighbor query (the G2 metric uses 10).
#[arg(long, default_value_t = 10)]
recall_k: usize,
/// Size of the precomputed query pool (each gets a brute-force ground-truth
/// top-k up front; the ramp samples from it). More queries = a steadier recall
/// estimate but a longer precompute.
#[arg(long, default_value_t = 1000)]
recall_queries: usize,
/// Read-knee latency target (ms): the p99 a stage must hold to count toward the
/// knee. Defaults to the G1 target (10ms).
#[arg(long, default_value_t = 10.0)]
read_p99_target_ms: f64,
/// Read-knee recall target: the mean recall@k a stage must hold to count toward
/// the knee. Defaults to the G2 target (0.95).
#[arg(long, default_value_t = 0.95)]
recall_target: f64,
/// Per-request HNSW beam width (`ef_search`) override for the recall probe
/// (m12p3 makes this the recall/latency knob). Omitted = the slot default.
#[arg(long)]
recall_ef_search: Option<u32>,
/// Noise added to each corpus base point when building a query vector. Small
/// noise keeps a well-defined nearest cluster (a realistic preference read);
/// 0 makes queries exact corpus points (recall trivially saturates).
#[arg(long, default_value_t = 0.1)]
recall_query_noise: f64,
} }
// SLO thresholds for the per-stage verdict. tidalDB's own RETRIEVE SLA is p99 // SLO thresholds for the per-stage verdict. tidalDB's own RETRIEVE SLA is p99
@ -232,6 +290,13 @@ async fn run() -> Result<()> {
.clone() .clone()
.map_or_else(|| cli.targets.clone(), |url| vec![url]); .map_or_else(|| cli.targets.clone(), |url| vec![url]);
// m12p1: the read-recall harness is a distinct flow (deterministic seed →
// brute-force ground truth → open-loop /vector_search ramp). Branch here,
// before the signal-ramp seeding/workload, and return its verdict.
if cli.verify_recall {
return run_verify_recall(&cli, client, &leader_url, read_bases, &stages).await;
}
println!("tidal-stress — thepeach feed workload"); println!("tidal-stress — thepeach feed workload");
println!(" targets : {}", cli.targets.join(", ")); println!(" targets : {}", cli.targets.join(", "));
println!( println!(
@ -293,6 +358,33 @@ async fn run() -> Result<()> {
} }
} }
// m12p2: build preference vectors so `for_you` exercises ANN candidate
// generation (not a scan fallback) during the read ramp.
if cli.seed_preferences {
println!(
"preferences: building {} user preference vectors (one like each) via {} ...",
cli.users, leader_url
);
let t0 = std::time::Instant::now();
let built = seed_preferences(
&client,
&leader_url,
cli.users,
cli.corpus,
cli.seed_concurrency,
)
.await?;
println!(
"preferences: {built}/{} users in {:.1}s (for_you now ANN-backed)\n",
cli.users,
t0.elapsed().as_secs_f64()
);
}
if let Some(profile) = &cli.feed_profile {
println!(" feed profile : forced to '{profile}' (per-profile retrieve p99)\n");
}
let workload = Arc::new(Workload::new( let workload = Arc::new(Workload::new(
read_bases, read_bases,
write_bases, write_bases,
@ -302,6 +394,7 @@ async fn run() -> Result<()> {
cli.hot_skew, cli.hot_skew,
cli.feed_limit, cli.feed_limit,
cli.embedding_dim, cli.embedding_dim,
cli.feed_profile.clone(),
)); ));
let _ = std::io::stdout().flush(); let _ = std::io::stdout().flush();
@ -484,6 +577,338 @@ fn print_verdict(
println!("═══════════════════════════════════════════════════════════════"); println!("═══════════════════════════════════════════════════════════════");
} }
// ── m12p1 read-recall harness ────────────────────────────────────────────────
/// Drive the read-recall harness: deterministic seed → brute-force ground truth
/// → open-loop `/vector_search` ramp → per-stage true-p99 + recall@k + read-knee.
#[allow(clippy::too_many_lines)]
async fn run_verify_recall(
cli: &Cli,
client: Arc<HttpClient>,
seed_url: &str,
read_bases: Vec<String>,
stages: &[Stage],
) -> Result<()> {
let dim = cli.embedding_dim;
if cli.recall_k == 0 || cli.recall_k > 1000 {
return Err(StressError::BadGate(format!(
"--recall-k must be in 1..=1000, got {}",
cli.recall_k
)));
}
if cli.recall_queries == 0 {
return Err(StressError::BadGate("--recall-queries must be > 0".into()));
}
for (flag, v) in [
("--read-p99-target-ms", cli.read_p99_target_ms),
("--recall-target", cli.recall_target),
("--recall-query-noise", cli.recall_query_noise),
] {
if !v.is_finite() || v < 0.0 {
return Err(StressError::BadGate(format!(
"{flag} must be finite and non-negative, got {v}"
)));
}
}
println!("tidal-stress — read-recall harness (m12p1)");
println!(" targets : {}", read_bases.join(", "));
println!(
" corpus / dim : {} items / {}-dim{}",
cli.corpus,
dim,
if dim == 1536 {
" (thepeach production width)"
} else {
""
}
);
println!(
" recall : recall@{} vs brute-force cosine, {} query pool",
cli.recall_k, cli.recall_queries
);
println!(
" read-knee : p99 ≤ {:.1}ms AND recall@{} ≥ {:.3}\n",
cli.read_p99_target_ms, cli.recall_k, cli.recall_target
);
// ── Seed (deterministic) ────────────────────────────────────────────────
if cli.skip_seed {
println!("seed: skipped (--skip-seed) — assuming a prior DETERMINISTIC recall seed\n");
} else {
println!(
"seed: registering {} items + {}-dim deterministic embeddings via {} ...",
cli.corpus, dim, seed_url
);
let t0 = std::time::Instant::now();
let seeded = seed_corpus_with(&client, seed_url, cli.corpus, cli.seed_concurrency, {
move |id| embedding_for(id, dim)
})
.await?;
println!(
"seed: {seeded}/{} items in {:.1}s\n",
cli.corpus,
t0.elapsed().as_secs_f64()
);
// Recall ground truth assumes EVERY id 1..=corpus is indexed; a missing id
// can never be returned, depressing recall artificially. Refuse to report a
// recall number computed against a corpus the engine does not fully hold.
if seeded < cli.corpus {
return Err(StressError::Seed(format!(
"only {seeded}/{} items registered — recall@k would be understated; \
fix the seed (auth/schema/dim) before measuring",
cli.corpus
)));
}
}
// ── Ground truth + query pool ───────────────────────────────────────────
let ram_gb = (cli.corpus as f64 * dim as f64 * 4.0) / 1e9;
println!(
"ground truth: building brute-force oracle ({} items × {}-dim ≈ {:.1} GB RAM) ...",
cli.corpus, dim, ram_gb
);
let t1 = std::time::Instant::now();
let gt = GroundTruth::build(cli.corpus, dim);
println!("ground truth: built in {:.1}s", t1.elapsed().as_secs_f64());
println!(
"ground truth: precomputing {} query top-{} (brute force, parallel) ...",
cli.recall_queries, cli.recall_k
);
let t2 = std::time::Instant::now();
let pool = Arc::new(QueryPool::build(
&gt,
cli.recall_queries,
cli.recall_k,
cli.recall_query_noise as f32,
));
println!(
"ground truth: {} queries ready in {:.1}s\n",
pool.len(),
t2.elapsed().as_secs_f64()
);
// Free the big corpus buffer before the ramp — the pool holds everything the
// hot path needs (queries + their ground-truth id lists).
drop(gt);
let _ = std::io::stdout().flush();
// ── Ramp ────────────────────────────────────────────────────────────────
let bases = Arc::new(read_bases);
let ef_search = cli.recall_ef_search.map(|v| v as usize);
let max_error_rate = cli.max_error_pct.map_or(SLO_ERROR_RATE, |p| p / 100.0);
let mut summaries: Vec<RecallStageSummary> = Vec::new();
for (i, stage) in stages.iter().enumerate() {
let label = format!("{}/{}", i + 1, stages.len());
let stats = run_recall_stage(
pool.clone(),
bases.clone(),
client.clone(),
stage,
cli.recall_k,
ef_search,
cli.max_inflight,
)
.await;
print!(
"{}",
render_recall_stage(&label, stage.target_rps, &stats, cli.recall_k)
);
let _ = std::io::stdout().flush();
let ok_per_sec = stats.ok as f64 / stats.elapsed.as_secs_f64().max(1e-9);
summaries.push(RecallStageSummary {
target_rps: stage.target_rps,
achieved_rps: stats.achieved_rps(),
ok_per_sec,
error_rate: stats.error_rate(),
client_shed: stats.client_shed,
p99_ms: ms(stats.p99()),
p50_ms: ms(stats.hist.percentile(0.50)),
max_ms: ms(stats.hist.max()),
mean_recall: stats.mean_recall(),
});
}
let run = RecallRunSummary {
stages: summaries,
k: cli.recall_k,
corpus: cli.corpus,
embedding_dim: dim,
p99_target_ms: cli.read_p99_target_ms,
recall_target: cli.recall_target,
max_error_rate,
};
print_recall_verdict(&run);
if let Some(path) = &cli.json_summary {
std::fs::write(path, run.to_json()).map_err(|source| StressError::Summary {
path: path.clone(),
source,
})?;
println!("\nwrote JSON summary → {path}");
}
// ── Gates ────────────────────────────────────────────────────────────────
let armed = cli.fail_on_knee || cli.max_p99_ms.is_some() || cli.max_error_pct.is_some();
if armed {
let mut breaches: Vec<String> = Vec::new();
if cli.fail_on_knee && run.read_knee().is_none() {
breaches.push(format!(
"no read-knee: no stage held p99 ≤ {:.1}ms AND recall@{} ≥ {:.3} [--fail-on-knee]",
cli.read_p99_target_ms, cli.recall_k, cli.recall_target
));
}
if let Some(max) = cli.max_p99_ms {
for (i, s) in run.stages.iter().enumerate() {
if s.p99_ms > max {
breaches.push(format!(
"stage {} p99 {:.2}ms exceeds --max-p99-ms {max:.1}",
i + 1,
s.p99_ms
));
}
}
}
if let Some(max) = cli.max_error_pct {
for (i, s) in run.stages.iter().enumerate() {
if s.error_rate * 100.0 > max {
breaches.push(format!(
"stage {} error rate {:.2}% exceeds --max-error-pct {max:.2}",
i + 1,
s.error_rate * 100.0
));
}
}
}
if breaches.is_empty() {
println!("\nregression gates: PASS");
} else {
return Err(StressError::Gate(breaches.join("; ")));
}
}
Ok(())
}
/// Milliseconds from a `Duration`.
fn ms(d: Duration) -> f64 {
d.as_secs_f64() * 1000.0
}
/// Render one recall ramp stage as a human-readable block.
fn render_recall_stage(
label: &str,
target_rps: f64,
stats: &tidal_stress::recall::RecallStageStats,
k: usize,
) -> String {
let mut out = String::new();
out.push_str(&format!(
"\n── stage {label} (target {target_rps:.0} rps, achieved {:.0} rps, {:.1}s) ──\n",
stats.achieved_rps(),
stats.elapsed.as_secs_f64(),
));
out.push_str(&format!(
" vector_search: count {} ok/s {:.0} p50 {:.2}ms p90 {:.2}ms p99 {:.2}ms max {:.2}ms\n",
stats.total,
stats.ok as f64 / stats.elapsed.as_secs_f64().max(1e-9),
ms(stats.hist.percentile(0.50)),
ms(stats.hist.percentile(0.90)),
ms(stats.p99()),
ms(stats.hist.max()),
));
out.push_str(&format!(
" recall@{k} {:.4} ({} scored) | error {:.2}% | client-shed {} | schedule-lag p99 {:.2}ms / max {:.2}ms\n",
stats.mean_recall(),
stats.recall_count,
stats.error_rate() * 100.0,
stats.client_shed,
ms(stats.p99_schedule_lag),
ms(stats.max_schedule_lag),
));
if stats.client_shed > 0 {
out.push_str(&format!(
" ⚠ percentiles tail-under-measured: {} request(s) shed (in-flight cap hit)\n",
stats.client_shed,
));
}
out
}
/// Print the read-recall verdict: the read-knee and what it means for G1/G2.
fn print_recall_verdict(run: &RecallRunSummary) {
println!("\n══════════════════════ READ-RECALL VERDICT ══════════════════════");
match run.read_knee() {
None => {
println!(
"No stage held p99 ≤ {:.1}ms AND recall@{} ≥ {:.3} — the read SLA is not met at any rate in this ramp.",
run.p99_target_ms, run.k, run.recall_target
);
// Name which target each stage missed so the failure is actionable.
for (i, s) in run.stages.iter().enumerate() {
let lat = if s.p99_ms <= run.p99_target_ms {
"p99 ok"
} else {
"p99 OVER"
};
let rec = if s.mean_recall >= run.recall_target {
"recall ok"
} else {
"recall LOW"
};
println!(
" stage {}/{} (target {:.0} rps): p99 {:.2}ms [{lat}], recall@{} {:.4} [{rec}]",
i + 1,
run.stages.len(),
s.target_rps,
s.p99_ms,
run.k,
s.mean_recall,
);
}
}
Some(k_idx) => {
let s = &run.stages[k_idx];
println!(
"Read-knee: stage {}/{} — sustained {:.0} read req/s within SLA.",
k_idx + 1,
run.stages.len(),
s.achieved_rps,
);
println!(
" at the knee : p99 {:.2}ms (≤ {:.1}ms target), recall@{} {:.4} (≥ {:.3} target)",
s.p99_ms, run.p99_target_ms, run.k, s.mean_recall, run.recall_target,
);
println!(
" G1 (p99 ≤ {:.0}ms) : {}",
run.p99_target_ms,
if s.p99_ms <= run.p99_target_ms {
"MET at the knee"
} else {
"not met"
}
);
println!(
" G2 (recall@{} ≥ {:.2}) : {}",
run.k,
run.recall_target,
if s.mean_recall >= run.recall_target {
"MET at the knee"
} else {
"not met"
}
);
}
}
println!(
" corpus / dim : {} items / {}-dim (recall is the ANN index quality at this shape)",
run.corpus, run.embedding_dim
);
println!("══════════════════════════════════════════════════════════════════");
}
fn init_tracing() { fn init_tracing() {
let env_filter = std::env::var("TIDAL_STRESS_LOG").unwrap_or_else(|_| "warn".into()); let env_filter = std::env::var("TIDAL_STRESS_LOG").unwrap_or_else(|_| "warn".into());
let _ = tracing_subscriber::fmt() let _ = tracing_subscriber::fmt()

532
tidal-stress/src/recall.rs Normal file
View File

@ -0,0 +1,532 @@
//! The m12p1 recall oracle + open-loop read-recall ramp.
//!
//! Every perf claim before m12p1 was latency-only: recall — the fraction of the
//! true nearest neighbors an ANN query actually returns — was unmeasured at the
//! production shape (1536-dim, 100k1M items). This module closes that gap. It
//! is the harness the whole milestone steers against.
//!
//! # How the oracle works
//!
//! 1. The corpus is seeded with **deterministic, id-keyed** embeddings
//! ([`embedding_for`]), so the generator can reconstruct, bit-for-bit, the
//! exact vectors the engine indexed — no need to capture them over the wire.
//! 2. [`GroundTruth`] holds that corpus in RAM and computes **brute-force cosine
//! top-k** for any query — the exact answer the ANN index is approximating.
//! (Order by cosine == order by the engine's L2-on-normalized distance, so the
//! two are directly comparable; see `storage::vector`.)
//! 3. A [`QueryPool`] of realistic queries (corpus points perturbed by noise) is
//! precomputed once, each with its ground-truth top-k, so the hot request path
//! does only a set-overlap to score `recall@k`.
//! 4. [`run_recall_stage`] fires `/vector_search` probes open-loop (the same
//! coordinated-omission-corrected methodology as the signal ramp) and records,
//! per request, the true latency AND the achieved recall.
//!
//! The verdict reports per-stage **true p99** (not a closed-loop mean) and **mean
//! recall@10**, and finds the **read-knee**: the highest sustained QPS at which
//! `p99 ≤ target AND recall@10 ≥ target` both hold.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::{Semaphore, mpsc};
use tokio::time::Instant;
use crate::client::HttpClient;
use crate::metrics::{LatencyHistogram, StatusClass};
use crate::scheduler::Stage;
// ── Deterministic corpus + query generation ──────────────────────────────────
/// SplitMix64 — a tiny, fast, dependency-free deterministic generator. Seeding it
/// from an entity id makes the whole corpus reproducible from the id alone, which
/// is what lets the generator hold the ground truth without ever reading it back
/// from the engine. The same scheme is mirrored in the engine-side recall test.
fn splitmix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// One uniform `f32` in `[-0.5, 0.5)` drawn from `state`.
fn next_f32(state: &mut u64) -> f32 {
// Top 24 bits → a uniform mantissa in [0,1), then recentre to [-0.5, 0.5).
let bits = (splitmix64(state) >> 40) as u32;
(bits as f32 / 16_777_216.0) - 0.5
}
/// Corpus ids per cluster center. ~100/cluster mirrors the neighbourhood size of
/// a real embedding manifold and guarantees ≥ `k` genuine neighbours per query
/// for any `k` ≤ 100.
const CLUSTER_SIZE: u64 = 100;
/// Per-component spread of the Gaussian mixture (`vector = center + SPREAD·noise`,
/// both unit). `SPREAD = 0.5` ⇒ intra-cluster cosine ≈ 0.89, inter-cluster ≈ 0 —
/// the clear neighbour structure of real embeddings.
const CLUSTER_SPREAD: f32 = 0.5;
/// A normalized pseudo-random unit vector seeded by `seed`.
fn unit_seeded(seed: u64, dim: usize) -> Vec<f32> {
let mut state = seed;
let mut v: Vec<f32> = (0..dim).map(|_| next_f32(&mut state)).collect();
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > f32::EPSILON {
for x in &mut v {
*x /= norm;
}
} else if let Some(first) = v.first_mut() {
*first = 1.0;
}
v
}
/// The deterministic content embedding for an item id — a **clustered**
/// (Gaussian-mixture) vector.
///
/// `id` is assigned to cluster `id / CLUSTER_SIZE`; the vector is that cluster's
/// unit center plus `CLUSTER_SPREAD ×` a per-id unit noise vector. The result is
/// returned raw (un-normalized); the engine L2-normalizes on write and the oracle
/// ranks by cosine, both scale-invariant, so the cluster structure is identical
/// on both sides.
///
/// **Why clustered, not uniform-random.** Uniform-random high-dimensional vectors
/// are pathological for recall@k: by concentration of measure every pair sits at
/// cosine ≈ 0, so beyond a tiny perturbation a query's true top-k is an arbitrary
/// draw from a thick equidistant shell — recall@k then measures impossible
/// tie-breaking, not index quality, and (measured) *falls* as the corpus grows
/// (≈0.97 at 10k → ≈0.54 at 100k at 1536-D). Real text/image embeddings live on a
/// low-dimensional manifold with clusters; this models that so recall@k is a
/// meaningful index-quality metric at scale (m12p3).
#[must_use]
pub fn embedding_for(id: u64, dim: usize) -> Vec<f32> {
let cluster = id / CLUSTER_SIZE;
// Distinct stream constants for the center vs the per-id noise so they are
// independent; offset+odd-multiply keeps adjacent ids/clusters well-separated.
let center = unit_seeded(
cluster
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(0x00C0_FFEE),
dim,
);
let noise = unit_seeded(id.wrapping_mul(0x2545_F491_4F6C_DD1D).wrapping_add(1), dim);
center
.iter()
.zip(&noise)
.map(|(c, n)| c + CLUSTER_SPREAD * n)
.collect()
}
// ── Brute-force ground truth ─────────────────────────────────────────────────
/// The seeded corpus held in RAM for exact nearest-neighbor computation.
///
/// Stored as one flat `Vec<f32>` (id `i` occupies `[(i-1)*dim, i*dim)`) plus a
/// per-vector L2 norm so cosine is a single dot-product + divide. At 1536-dim
/// this costs `n * dim * 4` bytes (~6 GB at 1M) — the honest price of a real
/// brute-force oracle; smaller corpora (100k ≈ 600 MB) fit comfortably.
pub struct GroundTruth {
flat: Vec<f32>,
norms: Vec<f32>,
dim: usize,
n: u64,
}
impl GroundTruth {
/// Build the deterministic corpus for ids `1..=n` at `dim` dimensions.
#[must_use]
pub fn build(n: u64, dim: usize) -> Self {
let mut flat = Vec::with_capacity((n as usize) * dim);
let mut norms = Vec::with_capacity(n as usize);
for id in 1..=n {
let v = embedding_for(id, dim);
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
norms.push(norm);
flat.extend_from_slice(&v);
}
Self {
flat,
norms,
dim,
n,
}
}
#[must_use]
pub const fn n(&self) -> u64 {
self.n
}
#[must_use]
pub const fn dim(&self) -> usize {
self.dim
}
/// The raw stored vector for `id` (1-based).
#[must_use]
fn vector(&self, id: u64) -> &[f32] {
let start = ((id - 1) as usize) * self.dim;
&self.flat[start..start + self.dim]
}
/// The `k` ids whose corpus vector is most cosine-similar to `query`,
/// best-first. The exact answer the ANN index approximates.
#[must_use]
pub fn top_k(&self, query: &[f32], k: usize) -> Vec<u64> {
let q_norm = query.iter().map(|x| x * x).sum::<f32>().sqrt();
if q_norm == 0.0 || k == 0 {
return Vec::new();
}
// A bounded top-k kept as a min-by-score Vec (k is tiny, ~10): cheaper and
// allocation-lighter than a full sort of n scored pairs per query.
let mut top: Vec<(f32, u64)> = Vec::with_capacity(k + 1);
for id in 1..=self.n {
let xn = self.norms[(id - 1) as usize];
if xn == 0.0 {
continue;
}
let dot: f32 = query.iter().zip(self.vector(id)).map(|(a, b)| a * b).sum();
let score = dot / (q_norm * xn); // cosine; higher = nearer
if top.len() < k {
top.push((score, id));
if top.len() == k {
// Smallest score first so the worst-kept is at index 0.
top.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
}
} else if score > top[0].0 {
top[0] = (score, id);
// Re-sink the new minimum to the front (k is tiny).
top.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
}
}
// Return best-first.
top.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
top.into_iter().map(|(_, id)| id).collect()
}
}
// ── Query pool (queries + precomputed ground truth) ──────────────────────────
/// A pool of realistic query vectors, each paired with its brute-force
/// ground-truth top-k.
///
/// Precomputing the (expensive) ground truth once decouples it from the hot
/// request path, where scoring a response is then just a set overlap.
pub struct QueryPool {
pub queries: Vec<Vec<f32>>,
pub truth: Vec<Vec<u64>>,
pub k: usize,
}
impl QueryPool {
/// Build `pool_size` queries and their ground-truth top-`k`.
///
/// Each query is a corpus point (spread across the catalog) perturbed by
/// deterministic noise, so it has a well-defined nearest cluster — the shape
/// of a real preference-vector read, and far more informative than uniformly
/// random high-dimensional queries whose neighbors are near-equidistant.
///
/// Ground truth is computed in parallel across the available cores; the work
/// is embarrassingly parallel (one independent brute-force scan per query).
#[must_use]
pub fn build(gt: &GroundTruth, pool_size: usize, k: usize, noise: f32) -> Self {
let n = gt.n().max(1);
// Spread base points across the corpus with a large odd stride so the pool
// is not clustered on the low ids.
let queries: Vec<Vec<f32>> = (0..pool_size)
.map(|i| {
let base = ((i as u64).wrapping_mul(2_654_435_761) % n) + 1;
let mut q = gt.vector(base).to_vec();
let mut state = (i as u64)
.wrapping_mul(0x100_0000_01B3)
.wrapping_add(0xABCD);
for c in &mut q {
*c += noise * next_f32(&mut state);
}
q
})
.collect();
let threads = std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(4)
.min(pool_size.max(1));
let chunk = pool_size.div_ceil(threads.max(1));
let truth: Vec<Vec<u64>> = std::thread::scope(|s| {
// Spawn every chunk BEFORE joining any (the collect is load-bearing —
// joining inline would serialize the scans), then concatenate in order.
#[allow(clippy::needless_collect)]
let handles: Vec<_> = queries
.chunks(chunk.max(1))
.map(|qs| s.spawn(move || qs.iter().map(|q| gt.top_k(q, k)).collect::<Vec<_>>()))
.collect();
handles
.into_iter()
.flat_map(|h| h.join().unwrap_or_default())
.collect()
});
Self { queries, truth, k }
}
#[must_use]
pub fn len(&self) -> usize {
self.queries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.queries.is_empty()
}
}
/// `recall@k`: the fraction of the ground-truth top-k that the engine returned.
///
/// `returned` may be shorter than `k` (a thin corpus or a degraded index); the
/// denominator is the ground-truth size, never the returned size, so a short
/// answer is correctly penalised.
#[must_use]
pub fn recall_at_k(returned: &[u64], truth: &[u64]) -> f64 {
if truth.is_empty() {
return 1.0; // nothing to find ⇒ vacuously perfect
}
let truth_set: std::collections::HashSet<u64> = truth.iter().copied().collect();
let hits = returned.iter().filter(|id| truth_set.contains(id)).count();
hits as f64 / truth.len() as f64
}
// ── Open-loop recall stage ───────────────────────────────────────────────────
/// One recall request's result, sent from a worker to the collector.
struct RecallOutcome {
class: StatusClass,
/// CO-corrected latency (from the request's intended send time).
latency: Duration,
/// Achieved recall@k, present only when the response carried a usable id list.
recall: Option<f64>,
}
/// Everything one recall ramp stage produced.
pub struct RecallStageStats {
pub hist: LatencyHistogram,
pub ok: u64,
pub errors: u64,
pub total: u64,
pub recall_sum: f64,
pub recall_count: u64,
pub elapsed: Duration,
pub client_shed: u64,
pub p99_schedule_lag: Duration,
pub max_schedule_lag: Duration,
}
impl RecallStageStats {
fn new() -> Self {
Self {
hist: LatencyHistogram::default(),
ok: 0,
errors: 0,
total: 0,
recall_sum: 0.0,
recall_count: 0,
elapsed: Duration::ZERO,
client_shed: 0,
p99_schedule_lag: Duration::ZERO,
max_schedule_lag: Duration::ZERO,
}
}
fn record(&mut self, o: &RecallOutcome) {
self.total += 1;
if o.class == StatusClass::Ok {
self.ok += 1;
self.hist.record(o.latency);
} else {
self.errors += 1;
}
if let Some(r) = o.recall {
self.recall_sum += r;
self.recall_count += 1;
}
}
/// Achieved throughput: completed requests per second over the stage.
#[must_use]
pub fn achieved_rps(&self) -> f64 {
let s = self.elapsed.as_secs_f64();
if s <= 0.0 { 0.0 } else { self.total as f64 / s }
}
#[must_use]
pub fn error_rate(&self) -> f64 {
if self.total == 0 {
0.0
} else {
self.errors as f64 / self.total as f64
}
}
/// Mean recall@k across the stage's successfully-parsed responses.
#[must_use]
pub fn mean_recall(&self) -> f64 {
if self.recall_count == 0 {
0.0
} else {
self.recall_sum / self.recall_count as f64
}
}
/// True p99 latency (bucket-estimated, but a genuine tail over an open loop —
/// NOT a closed-loop mean).
#[must_use]
pub fn p99(&self) -> Duration {
self.hist.percentile(0.99)
}
}
/// Run one recall ramp stage open-loop and return its aggregated stats.
///
/// Mirrors [`crate::scheduler::run_stage`]'s constant-arrival-rate methodology
/// (fire at the target rate regardless of outstanding responses; measure each
/// latency from its intended send time; count — never block on — an in-flight
/// shed), specialised to the single `/vector_search` op and extended to score
/// recall against the precomputed pool.
pub async fn run_recall_stage(
pool: Arc<QueryPool>,
bases: Arc<Vec<String>>,
client: Arc<HttpClient>,
stage: &Stage,
k: usize,
ef_search: Option<usize>,
max_inflight: usize,
) -> RecallStageStats {
let (tx, mut rx) = mpsc::unbounded_channel::<RecallOutcome>();
let collector = tokio::spawn(async move {
let mut stats = RecallStageStats::new();
while let Some(o) = rx.recv().await {
stats.record(&o);
}
stats
});
let sem = Arc::new(Semaphore::new(max_inflight));
let next_base = AtomicUsize::new(0);
let mut shed: u64 = 0;
let mut lag_hist = LatencyHistogram::default();
let start = Instant::now();
let deadline = start + stage.duration;
let period = Duration::from_secs_f64(1.0 / stage.target_rps.max(1e-9));
let mut next = start;
let mut dispatched: u64 = 0;
loop {
let now = Instant::now();
if now >= deadline {
break;
}
if next <= now {
match sem.clone().try_acquire_owned() {
Ok(permit) => {
let intended = next;
lag_hist.record(now.saturating_duration_since(intended));
// Pick the query (round-robin over the pool) and a target base
// (round-robin over the gateways — every replica holds the full
// corpus at the S=1 recall shape).
let qi = (dispatched as usize) % pool.len().max(1);
let base_idx = next_base.fetch_add(1, Ordering::Relaxed) % bases.len().max(1);
let pool = pool.clone();
let bases = bases.clone();
let client = client.clone();
let tx = tx.clone();
tokio::spawn(async move {
let _permit = permit;
let query = &pool.queries[qi];
let truth = &pool.truth[qi];
let base = &bases[base_idx];
let (class, ids) =
client.vector_search_ids(base, query, k, ef_search).await;
let latency = Instant::now().saturating_duration_since(intended);
let recall = ids.map(|ids| recall_at_k(&ids, truth));
let _ = tx.send(RecallOutcome {
class,
latency,
recall,
});
});
}
Err(_) => shed += 1,
}
next += period;
dispatched += 1;
if dispatched.is_multiple_of(256) {
tokio::task::yield_now().await;
}
} else {
tokio::time::sleep_until(next).await;
}
}
let elapsed = start.elapsed();
drop(tx);
let mut stats = collector.await.unwrap_or_else(|_| RecallStageStats::new());
stats.elapsed = elapsed;
stats.client_shed = shed;
stats.p99_schedule_lag = lag_hist.percentile(0.99);
stats.max_schedule_lag = lag_hist.max();
stats
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedding_is_deterministic_and_sized() {
let a = embedding_for(7, 32);
let b = embedding_for(7, 32);
assert_eq!(a, b, "same id+dim must reproduce the same vector");
assert_eq!(a.len(), 32);
assert_ne!(embedding_for(7, 32), embedding_for(8, 32), "ids differ");
// Non-zero norm (the engine rejects zero-norm embeddings).
assert!(a.iter().map(|x| x * x).sum::<f32>() > 0.0);
}
#[test]
fn ground_truth_ranks_self_first() {
let gt = GroundTruth::build(200, 16);
// Querying with a corpus vector returns that id first (cosine 1.0).
let q = embedding_for(42, 16);
let top = gt.top_k(&q, 10);
assert_eq!(top.len(), 10);
assert_eq!(top[0], 42, "an item's own vector is its nearest neighbor");
}
#[test]
fn recall_at_k_counts_overlap_over_truth_size() {
let truth = vec![1, 2, 3, 4, 5];
// 3 of 5 truth ids present (extra/ordering ignored).
assert!((recall_at_k(&[1, 2, 3, 99, 100], &truth) - 0.6).abs() < 1e-9);
assert!((recall_at_k(&[], &truth) - 0.0).abs() < 1e-9);
assert!((recall_at_k(&[1, 2, 3, 4, 5], &truth) - 1.0).abs() < 1e-9);
// Empty truth ⇒ vacuously perfect (nothing to find).
assert!((recall_at_k(&[], &[]) - 1.0).abs() < 1e-9);
}
#[test]
fn query_pool_truth_aligns_with_queries() {
let gt = GroundTruth::build(500, 16);
let pool = QueryPool::build(&gt, 20, 10, 0.05);
assert_eq!(pool.queries.len(), 20);
assert_eq!(pool.truth.len(), 20, "every query has a ground-truth list");
for t in &pool.truth {
assert_eq!(t.len(), 10, "ground truth is top-k");
}
// A perturbed corpus point's nearest neighbor should be its own base or a
// close id — the brute force must return a non-empty, sane list.
assert!(pool.truth.iter().all(|t| !t.is_empty()));
}
}

View File

@ -204,6 +204,131 @@ impl RunSummary {
} }
} }
// ── Recall harness summary (m12p1) ───────────────────────────────────────────
/// One recall ramp stage's machine-readable roll-up.
#[derive(Clone, Copy)]
pub struct RecallStageSummary {
pub target_rps: f64,
pub achieved_rps: f64,
pub ok_per_sec: f64,
pub error_rate: f64,
pub client_shed: u64,
/// True p99 query latency (ms) over the open loop.
pub p99_ms: f64,
/// p50 / max query latency (ms), for the human table.
pub p50_ms: f64,
pub max_ms: f64,
/// Mean recall@k across this stage's parsed responses.
pub mean_recall: f64,
}
impl RecallStageSummary {
/// Whether this stage cleared BOTH the latency and recall targets (and took
/// no client shed / error budget breach) — the read-knee membership test.
#[must_use]
pub fn meets_targets(
&self,
p99_target_ms: f64,
recall_target: f64,
max_error_rate: f64,
) -> bool {
self.p99_ms <= p99_target_ms
&& self.mean_recall >= recall_target
&& self.error_rate <= max_error_rate
&& self.client_shed == 0
}
}
/// The whole recall run's machine-readable summary + the read-knee.
pub struct RecallRunSummary {
pub stages: Vec<RecallStageSummary>,
pub k: usize,
pub corpus: u64,
pub embedding_dim: usize,
pub p99_target_ms: f64,
pub recall_target: f64,
pub max_error_rate: f64,
}
impl RecallRunSummary {
/// The read-knee: the index of the highest-throughput stage that cleared both
/// targets, or `None` if no stage did. "Highest throughput" is keyed on the
/// stage's ACHIEVED rps (a stage that fell behind its target still counts at
/// what it actually sustained).
#[must_use]
pub fn read_knee(&self) -> Option<usize> {
let mut best: Option<(usize, f64)> = None;
for (i, s) in self.stages.iter().enumerate() {
if s.meets_targets(self.p99_target_ms, self.recall_target, self.max_error_rate) {
if let Some((_, rps)) = best {
if s.achieved_rps > rps {
best = Some((i, s.achieved_rps));
}
} else {
best = Some((i, s.achieved_rps));
}
}
}
best.map(|(i, _)| i)
}
/// Whether the run passed: at least one stage cleared both targets.
#[must_use]
pub fn passed(&self) -> bool {
self.read_knee().is_some()
}
/// Render the flat JSON summary (hand-rolled; all values numeric/bool/null).
#[must_use]
pub fn to_json(&self) -> String {
let mut s = String::new();
let knee = self.read_knee();
s.push_str("{\n");
let _ = writeln!(s, " \"mode\": \"verify-recall\",");
let _ = writeln!(s, " \"passed\": {},", self.passed());
let _ = writeln!(s, " \"k\": {},", self.k);
let _ = writeln!(s, " \"corpus\": {},", self.corpus);
let _ = writeln!(s, " \"embedding_dim\": {},", self.embedding_dim);
let _ = writeln!(s, " \"p99_target_ms\": {},", jnum(self.p99_target_ms, 3));
let _ = writeln!(s, " \"recall_target\": {},", jnum(self.recall_target, 4));
if let Some(i) = knee {
let _ = writeln!(s, " \"read_knee_stage\": {},", i + 1);
let _ = writeln!(
s,
" \"read_knee_rps\": {},",
jnum(self.stages[i].achieved_rps, 1)
);
} else {
s.push_str(" \"read_knee_stage\": null,\n");
s.push_str(" \"read_knee_rps\": null,\n");
}
s.push_str(" \"stages\": [\n");
for (i, st) in self.stages.iter().enumerate() {
let meets =
st.meets_targets(self.p99_target_ms, self.recall_target, self.max_error_rate);
s.push_str(" {");
let _ = write!(s, "\"target_rps\": {}, ", jnum(st.target_rps, 1));
let _ = write!(s, "\"achieved_rps\": {}, ", jnum(st.achieved_rps, 1));
let _ = write!(s, "\"ok_per_sec\": {}, ", jnum(st.ok_per_sec, 1));
let _ = write!(s, "\"error_rate\": {}, ", jnum(st.error_rate, 6));
let _ = write!(s, "\"client_shed\": {}, ", st.client_shed);
let _ = write!(s, "\"p50_ms\": {}, ", jnum(st.p50_ms, 3));
let _ = write!(s, "\"p99_ms\": {}, ", jnum(st.p99_ms, 3));
let _ = write!(s, "\"max_ms\": {}, ", jnum(st.max_ms, 3));
let _ = write!(s, "\"mean_recall\": {}, ", jnum(st.mean_recall, 4));
let _ = write!(s, "\"meets_targets\": {meets}");
s.push('}');
if i + 1 < self.stages.len() {
s.push(',');
}
s.push('\n');
}
s.push_str(" ]\n}\n");
s
}
}
/// Format an `f64` for JSON at `decimals` precision, or `null` if it is /// Format an `f64` for JSON at `decimals` precision, or `null` if it is
/// non-finite (NaN/inf are not valid JSON — a single unguarded field would /// non-finite (NaN/inf are not valid JSON — a single unguarded field would
/// otherwise corrupt the whole artifact). Defense in depth: the inputs are /// otherwise corrupt the whole artifact). Defense in depth: the inputs are
@ -392,4 +517,82 @@ mod tests {
); );
assert!(s.overall_p99_ms >= s.signal_p99_ms); assert!(s.overall_p99_ms >= s.signal_p99_ms);
} }
fn recall_stage(target_rps: f64, p99_ms: f64, recall: f64) -> RecallStageSummary {
RecallStageSummary {
target_rps,
achieved_rps: target_rps,
ok_per_sec: target_rps,
error_rate: 0.0,
client_shed: 0,
p99_ms,
p50_ms: p99_ms / 2.0,
max_ms: p99_ms * 1.5,
mean_recall: recall,
}
}
#[test]
fn read_knee_is_highest_throughput_stage_meeting_both_targets() {
// Stages 1-2 meet both targets; stage 3 blows p99; stage 4 blows recall.
let run = RecallRunSummary {
stages: vec![
recall_stage(500.0, 4.0, 0.97),
recall_stage(1500.0, 8.0, 0.96),
recall_stage(3000.0, 22.0, 0.95), // p99 over 10ms target
recall_stage(5000.0, 6.0, 0.80), // recall under 0.95 target
],
k: 10,
corpus: 1_000_000,
embedding_dim: 1536,
p99_target_ms: 10.0,
recall_target: 0.95,
max_error_rate: 0.01,
};
// The knee is the highest-throughput PASSING stage (index 1, 1500 rps).
assert_eq!(run.read_knee(), Some(1));
assert!(run.passed());
}
#[test]
fn read_knee_none_when_no_stage_meets_targets() {
let run = RecallRunSummary {
stages: vec![recall_stage(500.0, 50.0, 0.90)],
k: 10,
corpus: 100_000,
embedding_dim: 1536,
p99_target_ms: 10.0,
recall_target: 0.95,
max_error_rate: 0.01,
};
assert_eq!(run.read_knee(), None);
assert!(!run.passed());
}
#[test]
fn recall_json_round_trips_through_a_real_parser() {
let run = RecallRunSummary {
stages: vec![
recall_stage(500.0, 4.5, 0.972),
recall_stage(3000.0, 21.0, 0.95),
],
k: 10,
corpus: 1_000_000,
embedding_dim: 1536,
p99_target_ms: 10.0,
recall_target: 0.95,
max_error_rate: 0.01,
};
let parsed: serde_json::Value =
serde_json::from_str(&run.to_json()).expect("recall to_json must be valid JSON");
assert_eq!(parsed["mode"], "verify-recall");
assert_eq!(parsed["passed"], true);
assert_eq!(parsed["k"], 10);
assert_eq!(parsed["embedding_dim"], 1536);
assert_eq!(parsed["read_knee_stage"], 1);
let stages = parsed["stages"].as_array().expect("stages array");
assert_eq!(stages.len(), 2);
assert_eq!(stages[0]["meets_targets"], true);
assert_eq!(stages[1]["meets_targets"], false); // p99 21ms > 10ms
}
} }

View File

@ -208,6 +208,9 @@ pub struct Workload {
feed_limit: u32, feed_limit: u32,
embedding_dim: usize, embedding_dim: usize,
categories: Vec<&'static str>, categories: Vec<&'static str>,
/// When set, EVERY feed read uses this profile instead of the weighted mix —
/// so a ramp can measure one profile's retrieve p99 in isolation (m12p2).
forced_profile: Option<String>,
} }
/// Default category vocabulary — stands in for thepeach companion/post tags so /// Default category vocabulary — stands in for thepeach companion/post tags so
@ -239,6 +242,7 @@ impl Workload {
hot_skew: f64, hot_skew: f64,
feed_limit: u32, feed_limit: u32,
embedding_dim: usize, embedding_dim: usize,
forced_profile: Option<String>,
) -> Self { ) -> Self {
// for_you is the deployed default and the one E2 will A/B; trending/hot/new // for_you is the deployed default and the one E2 will A/B; trending/hot/new
// exercise the other built-in sort paths. (related/following need // exercise the other built-in sort paths. (related/following need
@ -267,6 +271,7 @@ impl Workload {
feed_limit, feed_limit,
embedding_dim, embedding_dim,
categories: CATEGORIES.to_vec(), categories: CATEGORIES.to_vec(),
forced_profile,
} }
} }
@ -312,7 +317,10 @@ impl Workload {
match op { match op {
OpKind::FeedRead => { OpKind::FeedRead => {
let user = self.pick_user(rng); let user = self.pick_user(rng);
let profile = self.pick_profile(rng); let profile = self
.forced_profile
.as_deref()
.unwrap_or_else(|| self.pick_profile(rng));
let url = format!( let url = format!(
"{}/feed?profile={profile}&user_id={user}&limit={}", "{}/feed?profile={profile}&user_id={user}&limit={}",
self.reads.pick(), self.reads.pick(),
@ -486,6 +494,7 @@ mod tests {
1.5, 1.5,
24, 24,
128, 128,
None,
); );
let mut rng = rand::rng(); let mut rng = rand::rng();
let mut low = 0; let mut low = 0;

View File

@ -101,6 +101,9 @@ name = "actix_embedding"
[[example]] [[example]]
name = "cli_embedding" name = "cli_embedding"
[[example]]
name = "ann_grid_search"
[[test]] [[test]]
name = "sandboxed_storage" name = "sandboxed_storage"
required-features = ["test-utils"] required-features = ["test-utils"]

View File

@ -6,7 +6,17 @@
//! Criterion benchmarks for production-representative load: 1M items. //! Criterion benchmarks for production-representative load: 1M items.
//! //!
//! Validates the m7p3 performance acceptance criteria: //! These measure **isolated per-op cost (mean, single-threaded, closed loop)** —
//! a regression tripwire, NOT tail-SLO evidence. Criterion's `[lower mean upper]`
//! is a confidence interval on the MEAN; a closed loop stops sending when the
//! system stalls, so the queue that would inflate p99 never forms. The tail SLOs
//! below are the targets these means must sit comfortably under (a necessary, not
//! sufficient, condition); the p99/p999 themselves are signed off only by the
//! open-loop, coordinated-omission-corrected `tidal-stress` ramp — see
//! `docs/profiling/scale-baselines.md` (and `tidal-stress --verify-recall` for
//! recall@k at the production shape).
//!
//! Tail SLOs (validated open-loop, NOT by these means):
//! - RETRIEVE p99 < 50ms //! - RETRIEVE p99 < 50ms
//! - SEARCH p99 < 100ms //! - SEARCH p99 < 100ms
//! - Signal write p99 < 100µs //! - Signal write p99 < 100µs

View File

@ -10,10 +10,13 @@
//! All setup (index construction, vector insertion) is done OUTSIDE the //! All setup (index construction, vector insertion) is done OUTSIDE the
//! `b.iter()` closure. Only the search/insert/delete call is measured. //! `b.iter()` closure. Only the search/insert/delete call is measured.
use std::collections::HashSet;
use criterion::{Criterion, black_box, criterion_group, criterion_main}; use criterion::{Criterion, black_box, criterion_group, criterion_main};
use rand::Rng; use rand::Rng;
use tidaldb::storage::vector::{ use tidaldb::storage::vector::{
BruteForceIndex, DistanceMetric, QuantizationLevel, VectorId, VectorIndex, VectorIndexConfig, BruteForceIndex, DistanceMetric, QuantizationLevel, UsearchIndex, VectorId, VectorIndex,
VectorIndexConfig,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -179,6 +182,72 @@ fn bench_ann_recall_at_100(c: &mut Criterion) {
}); });
} }
/// Benchmark: HNSW **recall@10 at the production shape** (1536D, F16) vs an exact
/// brute-force ground truth over the SAME 10K vectors.
///
/// This extends the 128D `bench_ann_recall_at_100` to the production embedding
/// width (m12p1): unlike the brute-force variants above (which are exact, so
/// recall is trivially 1.0 and the bench measures only the search+compare cost),
/// this builds a real `UsearchIndex` (HNSW) and measures the approximation's
/// recall against a `BruteForceIndex` oracle on each iteration. It is a LOCAL
/// micro-tripwire at one shape; the authoritative recall@10 + true p99 across
/// 100k/1M is `tidal-stress --verify-recall` (open-loop, real server).
fn bench_ann_recall_at_10_1536d(c: &mut Criterion) {
let dim = 1536;
let n = 10_000_u64;
let recall_k = 10;
// Generate the corpus ONCE and insert the identical vectors into both indexes
// so the brute-force result is a true ground truth for the HNSW result.
let mut rng = rand::rng();
let vectors: Vec<Vec<f32>> = (0..n).map(|_| random_unit_vector(dim, &mut rng)).collect();
let brute = BruteForceIndex::new(VectorIndexConfig {
dimensions: dim,
metric: DistanceMetric::L2,
quantization: QuantizationLevel::F32,
connectivity: 16,
ef_construction: 400,
ef_search: 200,
});
// Production HNSW posture: M=16, ef_construction=400, F16 quantization.
let hnsw = UsearchIndex::new(VectorIndexConfig {
dimensions: dim,
metric: DistanceMetric::L2,
quantization: QuantizationLevel::F16,
connectivity: 16,
ef_construction: 400,
ef_search: 200,
})
.unwrap();
hnsw.reserve(n as usize).unwrap();
for (id, v) in vectors.iter().enumerate() {
brute.insert(id as VectorId, v).unwrap();
hnsw.insert(id as VectorId, v).unwrap();
}
let query = random_unit_vector(dim, &mut rng);
// Exact top-`recall_k` ground truth from the brute-force index.
let gt: HashSet<VectorId> = brute
.search(&query, recall_k, 200)
.unwrap()
.iter()
.map(|r| r.id)
.collect();
c.bench_function("ann_recall_at_10_1536d_10k", |b| {
b.iter(|| {
let results = hnsw
.search(black_box(&query), black_box(recall_k), black_box(200))
.unwrap();
let hits = results.iter().filter(|r| gt.contains(&r.id)).count();
#[allow(clippy::cast_precision_loss)]
let recall = hits as f64 / recall_k as f64;
black_box(recall)
});
});
}
/// Benchmark: single vector insert into a pre-filled 10K index. /// Benchmark: single vector insert into a pre-filled 10K index.
fn bench_ann_insert_single(c: &mut Criterion) { fn bench_ann_insert_single(c: &mut Criterion) {
let dim = 128; let dim = 128;
@ -232,6 +301,7 @@ criterion_group!(
bench_ann_search_filtered_5pct, bench_ann_search_filtered_5pct,
bench_ann_search_brute_force, bench_ann_search_brute_force,
bench_ann_recall_at_100, bench_ann_recall_at_100,
bench_ann_recall_at_10_1536d,
bench_ann_insert_single, bench_ann_insert_single,
bench_ann_delete_single, bench_ann_delete_single,
); );

View File

@ -0,0 +1,464 @@
//! m12p3 ANN parameter grid search + quantization recall/memory frontier.
//!
//! Builds a `UsearchIndex` (HNSW) and an exact `BruteForceIndex` oracle over the
//! SAME deterministic, id-keyed corpus, then sweeps the HNSW parameters and the
//! quantization level — reporting, for each point, the **measured** recall@k vs
//! the exact oracle, the single-thread mean/p99 search latency, the build time,
//! and the **true** in-memory footprint (`UsearchIndex::memory_usage`, which
//! includes the proximity-graph links, not just the vectors).
//!
//! This is the tool that produces the documented `M`/`ef` and the F32/F16/Int8
//! recall+memory numbers the m12p3 exit gate requires — at the production shape
//! (1536-D). Recall here is a *property of the index given its parameters* and is
//! independent of load, so a deterministic single-thread harness is the right
//! instrument; the authoritative tail-latency-under-load number is the open-loop
//! `tidal-stress --verify-recall` ramp (m12p1).
//!
//! Run (local, 100k/1536-D — the exit-gate shape that fits one laptop):
//! ```bash
//! cargo run --release --example ann_grid_search -- \
//! --corpus 100000 --dim 1536 --queries 200 --k 10
//! ```
//! The 1M shape needs ≈ 6 GB for the F32 oracle (1M × 1536 × 4 B) plus the HNSW;
//! run it on the k3s node with `--corpus 1000000`.
#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
use std::{
collections::HashSet,
time::{Duration, Instant},
};
use tidaldb::storage::vector::{
BruteForceIndex, DistanceMetric, QuantizationLevel, UsearchIndex, VectorId, VectorIndex,
VectorIndexConfig,
};
// ---------------------------------------------------------------------------
// Deterministic corpus (SplitMix64) — reproducible recall ground truth
// ---------------------------------------------------------------------------
/// A deterministic, L2-normalized unit vector for `id` at dimensionality `dim`.
///
/// Same family the m12p1 recall harness uses (`SplitMix64`), so the corpus is
/// reproducible across runs and machines — the recall numbers are comparable.
fn unit_vector(id: u64, dim: usize) -> Vec<f32> {
let mut state = id
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(0x1234_5678_9ABC_DEF0);
let mut next = || {
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
// Map the high mantissa bits into [-0.5, 0.5).
((z >> 40) as f32 / (1u64 << 24) as f32) - 0.5
};
let mut v: Vec<f32> = (0..dim).map(|_| next()).collect();
normalize(&mut v);
v
}
/// L2-normalize in place (unit vector), falling back to the first axis if zero.
fn normalize(v: &mut [f32]) {
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > f32::EPSILON {
for x in v.iter_mut() {
*x /= norm;
}
} else if let Some(first) = v.first_mut() {
*first = 1.0;
}
}
/// A clustered (Gaussian-mixture) corpus vector for `id` — the faithful
/// representation of real embedding geometry, and the right instrument for an
/// honest recall@k.
///
/// Uniform-random high-dimensional vectors are PATHOLOGICAL for recall@k: by the
/// concentration of measure, every pair sits at cosine ≈ 0, so beyond a tiny
/// perturbation a query's "top-10" is an arbitrary draw from a thick equidistant
/// shell — recall@10 then measures impossible tie-breaking, not index quality,
/// and (worse) it gets *lower* as the corpus grows because the shell thickens.
/// (Measured: uniform-random recall@10 fell from ~0.97 at 10k to ~0.54 at 100k —
/// a corpus-size artifact, not an index regression.)
///
/// Real text/image embeddings instead live on a low-dimensional manifold with
/// clusters: a point's nearest neighbours are its cluster-mates, distinctly
/// closer than the bulk. We model that as a Gaussian mixture: `id` is assigned to
/// cluster `id % n_clusters`, and the vector is `center + spread · noise`,
/// normalized. With `spread = 0.5`, intra-cluster cosine ≈ 0.8 and inter-cluster
/// ≈ 0 — a clear neighbour structure, exactly the shape `related`/`for_you` reads
/// query against, where recall@k is a meaningful index-quality metric.
fn clustered_vector(centers: &[Vec<f32>], id: u64, dim: usize, spread: f32) -> Vec<f32> {
let center = &centers[(id as usize) % centers.len()];
let noise = unit_vector(id, dim);
let mut v: Vec<f32> = center
.iter()
.zip(&noise)
.map(|(c, nz)| c + spread * nz)
.collect();
normalize(&mut v);
v
}
/// A realistic query: a corpus point (spread across the catalog) perturbed by
/// deterministic noise of magnitude `noise` RELATIVE to the unit base — so the
/// query sits a small, controlled angle off a real corpus point. This is the
/// shape of a production `related`/`for_you` read (a seed/preference vector near
/// real content), and the same realistic model the authoritative m12p1 recall
/// harness (`tidal-stress` `QueryPool`) uses.
///
/// Uniform-random high-dim queries are the WRONG instrument: in 1536-D their
/// neighbours are near-equidistant (distance concentration), so recall looks
/// pathologically low for reasons unrelated to the index.
///
/// The noise is scaled by `1/√(dim/12)` so the additive perturbation has total
/// magnitude ≈ `noise` against the *unit* base (a raw `noise × U[-0.5,0.5]` per
/// component would be ≈ `noise·√(dim/12)` — ~11× too large at 1536-D, which would
/// push the query far off its base and understate recall). The query is NOT
/// re-normalized: the corpus is unit-norm, so L2 order over it equals cosine
/// order even for a non-unit query (`‖c‖=1` ⇒ argmin‖qc‖² = argmax q·c) — the
/// exact metric the engine serves (it normalizes on write, not on query).
fn perturbed_query(corpus: &[Vec<f32>], i: usize, noise: f32) -> Vec<f32> {
let n = corpus.len().max(1) as u64;
let base = ((i as u64).wrapping_mul(2_654_435_761) % n) as usize;
let mut q = corpus[base].clone();
let dim = q.len().max(1);
// Per-component coefficient so ‖noise_vec‖ ≈ noise for a unit base.
let coef = noise / (dim as f32 / 12.0).sqrt();
let mut state = (i as u64)
.wrapping_mul(0x100_0000_01B3)
.wrapping_add(0xABCD);
let mut next = || {
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
((z >> 40) as f32 / (1u64 << 24) as f32) - 0.5
};
for c in &mut q {
*c += coef * next();
}
q
}
// ---------------------------------------------------------------------------
// Measurement
// ---------------------------------------------------------------------------
/// recall@k of `got` against the exact `truth` id set.
fn recall(truth: &HashSet<VectorId>, got: &[VectorId], k: usize) -> f64 {
let hits = got.iter().filter(|id| truth.contains(id)).count();
hits as f64 / k as f64
}
/// One grid/quant point's measured outcome.
struct Point {
label: String,
recall: f64,
mean_us: f64,
p99_us: f64,
build_s: f64,
mem_mb: f64,
mem_per_1m_gb: f64,
}
/// Build a `UsearchIndex` with the given parameters over `vectors`, returning the
/// built index and the wall-clock build time.
fn build_hnsw(
vectors: &[Vec<f32>],
dim: usize,
quant: QuantizationLevel,
connectivity: usize,
ef_construction: usize,
ef_search: usize,
) -> (UsearchIndex, Duration) {
let index = UsearchIndex::new(VectorIndexConfig {
dimensions: dim,
metric: DistanceMetric::L2,
quantization: quant,
connectivity,
ef_construction,
ef_search,
})
.expect("usearch index construction");
// Parallel build, done CORRECTLY: reserve one writer slot per thread up front
// (`reserve_with_threads`), THEN insert distinct ids concurrently. A plain
// `reserve` allocates a single slot, so concurrent `add` would corrupt the
// graph (recall collapses ~0.95→~0.1) — that was tried and rejected. With the
// per-thread reservation, USearch's own test does exactly this, and recall
// matches a sequential build while the 100k/1536-D build drops from ~10 min to
// well under a minute.
let threads = std::thread::available_parallelism().map_or(8, std::num::NonZeroUsize::get);
index
.reserve_with_threads(vectors.len(), threads)
.expect("reserve");
let start = Instant::now();
let chunk = vectors.len().div_ceil(threads).max(1);
std::thread::scope(|s| {
for (c, slice) in vectors.chunks(chunk).enumerate() {
let index = &index;
let base = c * chunk;
s.spawn(move || {
for (i, v) in slice.iter().enumerate() {
index.insert((base + i) as VectorId, v).expect("insert");
}
});
}
});
(index, start.elapsed())
}
/// Run `queries` against `index` at `ef_search`, scoring recall@k vs `truths`
/// and recording per-query latency. Returns `(avg_recall, mean_us, p99_us)`.
fn measure(
index: &UsearchIndex,
queries: &[Vec<f32>],
truths: &[HashSet<VectorId>],
k: usize,
ef_search: usize,
) -> (f64, f64, f64) {
let mut recalls = 0.0;
let mut lat_us: Vec<f64> = Vec::with_capacity(queries.len());
for (q, truth) in queries.iter().zip(truths) {
let start = Instant::now();
let res = index.search(q, k, ef_search).expect("search");
lat_us.push(start.elapsed().as_secs_f64() * 1e6);
let ids: Vec<VectorId> = res.iter().map(|r| r.id).collect();
recalls += recall(truth, &ids, k);
}
let mean = lat_us.iter().sum::<f64>() / lat_us.len() as f64;
lat_us.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p99 = lat_us[((lat_us.len() as f64 * 0.99) as usize).min(lat_us.len() - 1)];
(recalls / queries.len() as f64, mean, p99)
}
/// Footprint in MB now, and extrapolated to 1M vectors (GB), from the measured
/// `memory_usage` of the built index over `n` vectors.
fn footprint(index: &UsearchIndex, n: usize) -> (f64, f64) {
let bytes = index.memory_usage();
let mb = bytes as f64 / (1024.0 * 1024.0);
let per_1m_gb = (bytes as f64 / n as f64) * 1_000_000.0 / (1024.0 * 1024.0 * 1024.0);
(mb, per_1m_gb)
}
const fn quant_name(q: QuantizationLevel) -> &'static str {
match q {
QuantizationLevel::F32 => "F32",
QuantizationLevel::F16 => "F16",
QuantizationLevel::Int8 => "Int8",
}
}
// ---------------------------------------------------------------------------
// Arg parsing (tiny, dependency-free)
// ---------------------------------------------------------------------------
fn arg(args: &[String], flag: &str, default: usize) -> usize {
args.iter()
.position(|a| a == flag)
.and_then(|i| args.get(i + 1))
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
#[allow(clippy::too_many_lines)] // a linear measurement script reads best top-to-bottom
fn main() {
let args: Vec<String> = std::env::args().collect();
let corpus = arg(&args, "--corpus", 20_000);
let dim = arg(&args, "--dim", 1536);
let n_queries = arg(&args, "--queries", 200);
let k = arg(&args, "--k", 10);
// Query perturbation noise (× 1000 on the CLI to keep the arg an integer):
// `--noise-milli 50` ⇒ 0.05 relative — a query a few degrees off a real point.
let noise = arg(&args, "--noise-milli", 50) as f32 / 1000.0;
// Clustered corpus shape (Gaussian mixture — see `clustered_vector`):
// `--clusters` defaults to ~100 points/cluster; `--spread-milli 500` ⇒ 0.5.
let n_clusters = arg(&args, "--clusters", (corpus / 100).max(1));
let spread = arg(&args, "--spread-milli", 500) as f32 / 1000.0;
eprintln!(
"[grid] building clustered corpus: {corpus} vectors × {dim}-D, {n_clusters} clusters \
(spread {spread:.2}), {n_queries} queries (noise {noise:.3}), recall@{k}"
);
let corpus_start = Instant::now();
let centers: Vec<Vec<f32>> = (0..n_clusters as u64)
.map(|c| unit_vector(c.wrapping_add(0x00C0_FFEE), dim))
.collect();
let vectors: Vec<Vec<f32>> = (0..corpus as u64)
.map(|id| clustered_vector(&centers, id, dim, spread))
.collect();
// Queries are corpus points perturbed by small noise — they stay in-cluster,
// so each has a well-defined nearest cluster (the production read shape, the
// same model the m12p1 harness uses; see `perturbed_query`).
let queries: Vec<Vec<f32>> = (0..n_queries)
.map(|q| perturbed_query(&vectors, q, noise))
.collect();
eprintln!(
"[grid] corpus built in {:.1}s",
corpus_start.elapsed().as_secs_f64()
);
// Exact ground truth, computed ONCE (independent of HNSW parameters) and
// reused across every grid/quant point. F32 brute force == the true answer.
eprintln!("[grid] computing exact brute-force ground truth (once)…");
let gt_start = Instant::now();
let oracle = BruteForceIndex::new(VectorIndexConfig {
dimensions: dim,
metric: DistanceMetric::L2,
quantization: QuantizationLevel::F32,
connectivity: 16,
ef_construction: 400,
ef_search: 400,
});
for (id, v) in vectors.iter().enumerate() {
oracle.insert(id as VectorId, v).expect("oracle insert");
}
let truths: Vec<HashSet<VectorId>> = queries
.iter()
.map(|q| {
oracle
.search(q, k, 0)
.expect("oracle search")
.iter()
.map(|r| r.id)
.collect()
})
.collect();
eprintln!(
"[grid] ground truth ready in {:.1}s",
gt_start.elapsed().as_secs_f64()
);
// -------------------------------------------------------------------
// Sweep 1 — HNSW graph parameters at F16 (the production quantization).
// For each (M, ef_construction) we build the graph ONCE and sweep ef_search
// on it (search-time only — no rebuild), since ef_search is a per-query knob.
// -------------------------------------------------------------------
let grid_graphs: &[(usize, usize)] = &[(16, 400), (24, 400), (32, 400)];
let ef_searches: &[usize] = &[128, 200, 400, 600];
let mut grid_points: Vec<Point> = Vec::new();
for &(m, ef_c) in grid_graphs {
eprintln!("[grid] building HNSW M={m} ef_c={ef_c} (F16)…");
let (index, build) = build_hnsw(&vectors, dim, QuantizationLevel::F16, m, ef_c, 200);
let (mem_mb, mem_1m) = footprint(&index, corpus);
for &ef_s in ef_searches {
let (rec, mean, p99) = measure(&index, &queries, &truths, k, ef_s);
grid_points.push(Point {
label: format!("M={m}, ef_c={ef_c}, ef_s={ef_s}"),
recall: rec,
mean_us: mean,
p99_us: p99,
build_s: build.as_secs_f64(),
mem_mb,
mem_per_1m_gb: mem_1m,
});
}
}
// -------------------------------------------------------------------
// Sweep 2 — quantization at a graph + beam that clears the 0.95 gate
// (M=24, ef_c=400, ef_s=400), so the F32→F16→Int8 recall penalty and the
// memory saving are compared on a config that actually meets the target.
// -------------------------------------------------------------------
let quants = [
QuantizationLevel::F32,
QuantizationLevel::F16,
QuantizationLevel::Int8,
];
let quant_m = 24usize;
let quant_ef_construction = 400usize;
let quant_beam = 400usize;
let mut quant_points: Vec<Point> = Vec::new();
for q in quants {
eprintln!(
"[grid] building HNSW {} M={quant_m} ef_c={quant_ef_construction}…",
quant_name(q)
);
let (index, build) =
build_hnsw(&vectors, dim, q, quant_m, quant_ef_construction, quant_beam);
let (mem_mb, mem_1m) = footprint(&index, corpus);
let (rec, mean, p99) = measure(&index, &queries, &truths, k, quant_beam);
quant_points.push(Point {
label: format!(
"{} (M={quant_m}, ef_c={quant_ef_construction}, ef_s={quant_beam})",
quant_name(q)
),
recall: rec,
mean_us: mean,
p99_us: p99,
build_s: build.as_secs_f64(),
mem_mb,
mem_per_1m_gb: mem_1m,
});
}
// -------------------------------------------------------------------
// Report — markdown tables ready to paste into docs/profiling/.
// -------------------------------------------------------------------
println!(
"\n## ANN grid search — measured (corpus={corpus}, dim={dim}, recall@{k}, queries={n_queries})\n"
);
println!("### HNSW parameter sweep (F16)\n");
println!(
"| M / ef_c / ef_s | recall@{k} | mean (µs) | p99 (µs) | build (s) | mem (MB) | mem/1M (GB) |"
);
println!("|---|---|---|---|---|---|---|");
for p in &grid_points {
println!(
"| {} | {:.4} | {:.0} | {:.0} | {:.1} | {:.0} | {:.2} |",
p.label, p.recall, p.mean_us, p.p99_us, p.build_s, p.mem_mb, p.mem_per_1m_gb
);
}
println!("\n### Quantization sweep (M=16, ef_c=400, ef_s=200)\n");
println!("| quantization | recall@{k} | mean (µs) | p99 (µs) | mem (MB) | mem/1M (GB) |");
println!("|---|---|---|---|---|---|");
for p in &quant_points {
println!(
"| {} | {:.4} | {:.0} | {:.0} | {:.0} | {:.2} |",
p.label, p.recall, p.mean_us, p.p99_us, p.mem_mb, p.mem_per_1m_gb
);
}
// Recommend the cheapest grid point (min mean latency) that clears recall ≥ 0.95.
let best = grid_points
.iter()
.filter(|p| p.recall >= 0.95)
.min_by(|a, b| {
a.mean_us
.partial_cmp(&b.mean_us)
.unwrap_or(std::cmp::Ordering::Equal)
});
println!("\n### Recommendation\n");
match best {
Some(p) => println!(
"- **Frontier point:** `{}` → recall@{k} {:.4}, mean {:.0} µs, p99 {:.0} µs, {:.2} GB/1M.",
p.label, p.recall, p.mean_us, p.p99_us, p.mem_per_1m_gb
),
None => println!(
"- ⚠ NO grid point reached recall@{k} ≥ 0.95 at this shape — widen ef or raise M."
),
}
let smallest_ok = quant_points
.iter()
.filter(|p| p.recall >= 0.95)
.min_by(|a, b| {
a.mem_per_1m_gb
.partial_cmp(&b.mem_per_1m_gb)
.unwrap_or(std::cmp::Ordering::Equal)
});
if let Some(p) = smallest_ok {
println!(
"- **Smallest quantization clearing recall ≥ 0.95:** `{}` → {:.4} recall, {:.2} GB/1M.",
p.label, p.recall, p.mem_per_1m_gb
);
}
}

View File

@ -7,6 +7,7 @@ use crate::{
retrieve::{Results, Retrieve}, retrieve::{Results, Retrieve},
search::{Search, SearchExecutor, SearchResults}, search::{Search, SearchExecutor, SearchResults},
}, },
ranking::profile::CandidateStrategy,
schema::{EntityKind, TidalError}, schema::{EntityKind, TidalError},
session as session_mod, session as session_mod,
}; };
@ -88,6 +89,32 @@ impl TidalDb {
base_executor = base_executor.with_items_storage(storage); base_executor = base_executor.with_items_storage(storage);
} }
// m12p2: ANN candidate generation. Thread the embedding registry and, when
// the profile uses `CandidateStrategy::Ann`, resolve the query vector here
// (the db layer owns both the seed-embedding read and the preference
// vectors): the seed item's embedding for `similar_to`, else the user's
// preference vector for `for_user`. `None` ⇒ the executor's Ann arm
// degrades to a scan (anonymous read / no preference vector yet). Resolving
// only for Ann profiles keeps the common (non-ANN) feed off this path.
let ann_query_vector = if self
.profile_registry
.get(&query.profile.name)
.is_ok_and(|p| matches!(p.candidate_strategy, CandidateStrategy::Ann { .. }))
{
if let Some(seed) = query.similar_to {
self.read_item_embedding(seed).ok().flatten()
} else if let Some(user) = query.for_user {
self.preference_vectors.get(user)
} else {
None
}
} else {
None
};
base_executor = base_executor
.with_embedding_registry(&self.embedding_registry)
.with_ann_query_vector(ann_query_vector);
// M6: wire co-engagement for related profile scoring. // M6: wire co-engagement for related profile scoring.
base_executor = base_executor.with_co_engagement(&self.co_engagement); base_executor = base_executor.with_co_engagement(&self.co_engagement);
@ -322,6 +349,65 @@ impl TidalDb {
Ok(result) Ok(result)
} }
/// Pure k-nearest-neighbor vector search over the item content embedding
/// slot — the **recall-measurement probe** (m12p1).
///
/// Unlike [`Self::search`], this runs NO BM25 fusion, profile scoring, or
/// diversity enforcement. It returns the raw ANN result: the `k` items whose
/// stored content embedding is nearest to `query_vector` under the slot's
/// distance metric (L2 over L2-normalized vectors, monotonic with cosine —
/// see [`crate::storage::vector`]), ordered closest-first. Comparing this
/// output against a brute-force cosine ground truth yields the ANN
/// `recall@k` that G2 targets, isolated from the ranking layers that would
/// otherwise reorder the set.
///
/// `ef_search` overrides the slot's default HNSW beam width when `Some`
/// (a per-request recall/latency knob); `None` uses the slot default. The
/// brute-force and mock indexes ignore it (exact search has no beam width).
///
/// # Errors
///
/// Returns [`TidalError`] if the embedding-registry lock is poisoned, if the
/// schema declares no item content embedding slot, or if the underlying
/// index search fails (e.g. a dimension mismatch on `query_vector`).
// The registry read guard is deliberately held across the slot lookup AND the
// index search: `slot` borrows from the guard, so it cannot be dropped earlier.
#[allow(clippy::significant_drop_tightening)]
pub fn vector_search_items(
&self,
query_vector: &[f32],
k: usize,
ef_search: Option<usize>,
) -> crate::Result<Vec<crate::storage::vector::VectorSearchResult>> {
let slot_name = self.item_embedding_slot();
// A read guard is enough: the index is interior-mutable (Send + Sync) and
// concurrent searches against the same slot are the expected production
// shape (ranking queries running alongside signal writes).
let registry = self.embedding_registry.read().map_err(|_| {
TidalError::internal(
"vector_search_items",
"embedding_registry read lock poisoned",
)
})?;
let slot = registry.get(EntityKind::Item, slot_name).ok_or_else(|| {
TidalError::invalid_input(format!(
"no '{slot_name}' embedding slot declared for Item; vector search unavailable"
))
})?;
let ef = ef_search.unwrap_or(slot.params.ef_search);
slot.index.search(query_vector, k, ef).map_err(|e| {
// A dimension-mismatched query vector is a CALLER error (→ 400 at the
// HTTP boundary), not an engine fault — keep it distinct from a real
// backend failure (→ 500). The latter is genuinely internal.
match e {
crate::storage::vector::VectorError::DimensionMismatch { .. } => {
TidalError::invalid_input(e.to_string())
}
other => TidalError::internal("vector_search_items", other.to_string()),
}
})
}
/// Autocomplete suggestions for a query prefix. /// Autocomplete suggestions for a query prefix.
/// ///
/// Returns matching title terms if `prefix` is non-empty, or trending search /// Returns matching title terms if `prefix` is non-empty, or trending search

View File

@ -140,10 +140,11 @@ impl TidalDb {
// than hardcoding brute force. A freshly auto-registered slot has // than hardcoding brute force. A freshly auto-registered slot has
// no vectors yet, so the factory builds the exact `BruteForceIndex` // no vectors yet, so the factory builds the exact `BruteForceIndex`
// (correct for a tiny / cold slot — no graph-build cost, exact // (correct for a tiny / cold slot — no graph-build cost, exact
// results). When the slot's durable vector count crosses // results). When the slot's durable vector count crosses the
// `USEARCH_MIN_VECTORS`, the next process restart's // dimension-aware crossover (`usearch_min_vectors`), the next
// `rebuild_from_store` promotes it to the production HNSW // process restart's `rebuild_from_store` promotes it to the
// (`UsearchIndex`) automatically — no config plumbing required. // production HNSW (`UsearchIndex`) automatically — no config
// plumbing required.
let state = EmbeddingSlotState { let state = EmbeddingSlotState {
index: build_slot_index(embedding.len(), 0), index: build_slot_index(embedding.len(), 0),
dimensions: embedding.len(), dimensions: embedding.len(),

View File

@ -9,8 +9,9 @@ use roaring::RoaringBitmap;
use crate::{ use crate::{
ranking::executor::{ScoredCandidate, SignalSnapshot}, ranking::executor::{ScoredCandidate, SignalSnapshot},
schema::{EntityId, Timestamp}, schema::{EntityId, EntityKind},
signals::SignalLedger, signals::SignalLedger,
storage::vector::EmbeddingSlotRegistry,
}; };
/// Scan the universe bitmap for all entity IDs. /// Scan the universe bitmap for all entity IDs.
@ -51,67 +52,58 @@ pub(crate) fn scan_candidates(
/// is no secondary per-signal-type index that would let us avoid the scan, so /// is no secondary per-signal-type index that would let us avoid the scan, so
/// the `O(N)` pass is inherent to the current ledger layout — `N` is the number /// the `O(N)` pass is inherent to the current ledger layout — `N` is the number
/// of live entity/signal cells, not the catalog size, and the per-cell work is a /// of live entity/signal cells, not the catalog size, and the per-cell work is a
/// single `current_score` evaluation. To keep this off the heap, the working /// single `current_score` evaluation.
/// set is **bounded to `O(cap)`** where `cap` is the top-K candidate budget: ///
/// rather than collecting every matching cell into an unbounded `Vec` and /// **m12p2:** that O(N) scan is now CACHED. This function delegates to the
/// partitioning once at the end, we cap the buffer at `2 * cap` and /// ledger's per-signal-type top-K cache ([`SignalLedger::hot_top_k_candidates`]),
/// partition-then-truncate to `cap` whenever it fills. That is amortized `O(N)` /// which serves a fresh-enough materialized top-K in O(K) and only pays the
/// time with `O(cap)` memory instead of `O(N)` memory — important when one /// bounded O(N) rebuild when the cache is stale (small ledgers always-fresh;
/// signal type dominates a large ledger. If this scan ever shows up in a /// large ledgers throttled off the read hot path). The rebuild keeps the same
/// profile, add a per-signal-type inverted index (entity IDs sorted by decay /// bounded-buffer scan this function used to perform inline. See
/// score) updated on the write path. /// [`crate::signals::ledger`]'s `hot_top_k` module for the freshness policy.
pub(crate) fn signal_ranked_candidates( pub(crate) fn signal_ranked_candidates(
ledger: &SignalLedger, ledger: &SignalLedger,
signal_name: &str, signal_name: &str,
limit: usize, limit: usize,
) -> Vec<EntityId> { ) -> Vec<EntityId> {
let max_candidates = (limit * 4).max(200); ledger.hot_top_k_candidates(signal_name, limit)
let Ok(type_id) = ledger.resolve_signal_type(signal_name) else { }
/// ANN candidate generation (m12p2): the `k` items whose content embedding is
/// nearest to `query_vector`, via the slot's HNSW index — `O(ef_search)`, not `O(N)`.
///
/// This is the G1 unblock: instead of scanning an arbitrary low-id slice of the
/// universe (which collapses feed relevance as the corpus grows past the scan
/// cap), the candidate set is the actual nearest neighbours of the query vector
/// (the user's preference vector for `for_you`, the seed item's embedding for
/// `similar_to`). Stage 3 then re-scores this bounded, relevant set by signals.
///
/// Returns an EMPTY vec on any miss (no registry, absent slot, dimension
/// mismatch, poisoned lock) so the caller can fall back to a scan and surface a
/// warning rather than silently returning nothing.
pub(crate) fn ann_candidates(
registry: &RwLock<EmbeddingSlotRegistry>,
slot: &str,
query_vector: &[f32],
k: usize,
ef_search: usize,
) -> Vec<EntityId> {
let Ok(reg) = registry.read() else {
return Vec::new(); return Vec::new();
}; };
let Some(state) = reg.get(EntityKind::Item, slot) else {
let now_ns = Timestamp::now().as_nanos(); return Vec::new();
// Descending by score; NaN (degenerate decay math) falls back to descending
// entity-ID order so output is deterministic and NaNs sink to the bottom.
let cmp = |a: &(EntityId, f64), b: &(EntityId, f64)| {
b.1.partial_cmp(&a.1)
.unwrap_or_else(|| b.0.as_u64().cmp(&a.0.as_u64()))
}; };
// `ef_search == 0` ⇒ use the slot's configured default beam width.
// Bounded buffer: never exceeds 2 * max_candidates entries. When it fills, an let ef = if ef_search == 0 {
// O(buffer-len) select-and-truncate keeps the top max_candidates and drops the state.params.ef_search
// rest, so peak memory is O(max_candidates) regardless of ledger size. } else {
let buffer_cap = max_candidates.saturating_mul(2).max(max_candidates + 1); ef_search
let mut scored: Vec<(EntityId, f64)> = Vec::with_capacity(buffer_cap); };
match state.index.search(query_vector, k, ef) {
for entry in ledger.entries() { Ok(results) => results.into_iter().map(|r| EntityId::new(r.id)).collect(),
let (entity_id, signal_type_id) = entry.key(); Err(_) => Vec::new(),
if *signal_type_id == type_id {
// Use decay score at index 0 with lambda=0 (no additional decay
// beyond what was already applied at write time). This is a
// simplified ranking for candidate generation -- the full scoring
// happens in Stage 3 via ProfileExecutor.
let score = entry.value().hot.current_score(0, now_ns, 0.0);
scored.push((*entity_id, score));
if scored.len() >= buffer_cap {
// Partition so the top `max_candidates` occupy [0, max_candidates)
// then discard the tail, keeping the buffer bounded.
scored.select_nth_unstable_by(max_candidates - 1, cmp);
scored.truncate(max_candidates);
} }
}
}
// Final top-K: partition (O(N)) then sort just the survivors (O(K log K)).
if scored.len() > max_candidates {
scored.select_nth_unstable_by(max_candidates - 1, cmp);
scored.truncate(max_candidates);
}
scored.sort_unstable_by(cmp);
scored.into_iter().map(|(id, _)| id).collect()
} }
/// Inject exploration candidates into the scored list. /// Inject exploration candidates into the scored list.

View File

@ -93,6 +93,16 @@ pub struct RetrieveExecutor<'a> {
/// retrieve-time preference boost reads embeddings from the same slot /// retrieve-time preference boost reads embeddings from the same slot
/// `write_item_embedding` wrote them to. `None` falls back to "content". /// `write_item_embedding` wrote them to. `None` falls back to "content".
item_embedding_slot: Option<&'a str>, item_embedding_slot: Option<&'a str>,
// ── m12p2 ANN candidate generation ────────────────────────────────
/// Embedding-slot registry, for `CandidateStrategy::Ann` in Stage 1 — the
/// HNSW index the nearest-neighbour candidate search runs against. `None`
/// (or an absent slot) ⇒ the `Ann` strategy degrades to a scan.
embedding_registry: Option<&'a RwLock<crate::storage::vector::EmbeddingSlotRegistry>>,
/// The resolved ANN query vector, set by the db layer when the profile is
/// `Ann`: the user's preference vector (`for_user`) or the seed item's
/// embedding (`similar_to`). `None` ⇒ no query vector resolvable (anonymous
/// read, or a user with no preference vector yet) ⇒ `Ann` degrades to a scan.
ann_query_vector: Option<Vec<f32>>,
// ── M6 cohort context ───────────────────────────────────────────── // ── M6 cohort context ─────────────────────────────────────────────
cohort_ledger: Option<&'a crate::cohort::CohortSignalLedger>, cohort_ledger: Option<&'a crate::cohort::CohortSignalLedger>,
cohort_registry: Option<&'a crate::cohort::CohortRegistry>, cohort_registry: Option<&'a crate::cohort::CohortRegistry>,
@ -142,6 +152,8 @@ impl<'a> RetrieveExecutor<'a> {
session_snapshot: None, session_snapshot: None,
items_storage: None, items_storage: None,
item_embedding_slot: None, item_embedding_slot: None,
embedding_registry: None,
ann_query_vector: None,
cohort_ledger: None, cohort_ledger: None,
cohort_registry: None, cohort_registry: None,
co_engagement: None, co_engagement: None,
@ -206,6 +218,32 @@ impl<'a> RetrieveExecutor<'a> {
self self
} }
/// Attach the embedding-slot registry for `CandidateStrategy::Ann` (m12p2).
///
/// Stage 1 runs the nearest-neighbour candidate search against the item
/// content slot's HNSW index in this registry. Without it (or without a
/// resolved [`with_ann_query_vector`](Self::with_ann_query_vector)), the
/// `Ann` strategy degrades to a scan.
#[must_use]
pub const fn with_embedding_registry(
mut self,
registry: &'a RwLock<crate::storage::vector::EmbeddingSlotRegistry>,
) -> Self {
self.embedding_registry = Some(registry);
self
}
/// Attach the resolved ANN query vector for `CandidateStrategy::Ann` (m12p2).
///
/// Resolved by the db layer: the user's preference vector for `for_you`, or
/// the seed item's embedding for `similar_to`. `None` leaves the `Ann`
/// strategy to degrade to a scan (anonymous read / no preference vector yet).
#[must_use]
pub fn with_ann_query_vector(mut self, vector: Option<Vec<f32>>) -> Self {
self.ann_query_vector = vector;
self
}
/// Attach M6 co-engagement index for `related` profile scoring. /// Attach M6 co-engagement index for `related` profile scoring.
#[must_use] #[must_use]
pub const fn with_co_engagement( pub const fn with_co_engagement(

View File

@ -25,6 +25,15 @@ use crate::{
}, },
}; };
/// ANN candidate over-fetch multiplier (m12p2): fetch `limit * this` nearest
/// neighbours so Stage 2/2.5 filtering and Stage 4 diversity can trim back to
/// `limit` without starving. Mirrors the SEARCH ANN over-fetch.
const ANN_OVERFETCH: usize = 10;
/// Minimum ANN candidate count regardless of `limit`, so a tiny-limit feed still
/// seeds a usable candidate pool.
const ANN_CANDIDATE_FLOOR: usize = 200;
impl RetrieveExecutor<'_> { impl RetrieveExecutor<'_> {
/// Execute a RETRIEVE query through the 6-stage pipeline. /// Execute a RETRIEVE query through the 6-stage pipeline.
/// ///
@ -122,26 +131,79 @@ impl RetrieveExecutor<'_> {
candidate_gen::scan_candidates(self.universe, query.limit, has_user_context) candidate_gen::scan_candidates(self.universe, query.limit, has_user_context)
} }
CandidateStrategy::SignalRanked { signal, .. } => { CandidateStrategy::SignalRanked { signal, .. } => {
candidate_gen::signal_ranked_candidates(self.ledger, signal, query.limit) // m12p2: candidates come from the cached per-signal-type top-K
// (O(K)). When the signal is absent from the schema, or no
// writes have landed yet, that yields nothing — fall back to a
// scan so the read still works (e.g. trending on a fresh corpus
// with no views yet) rather than returning an empty feed.
let ranked =
candidate_gen::signal_ranked_candidates(self.ledger, signal, query.limit);
if ranked.is_empty() {
warnings.push(format!(
"SignalRanked('{signal}') produced no candidates \
(signal absent or no writes yet); falling back to scan"
));
candidate_gen::scan_candidates(self.universe, query.limit, has_user_context)
} else {
ranked
} }
CandidateStrategy::Ann { .. } => { }
// ANN candidate strategy is not yet wired into RETRIEVE CandidateStrategy::Ann {
// (vector retrieval lives in the SEARCH pipeline). The limit: ann_limit, ..
// fallback to a full scan changes the candidate set the } => {
// profile asked for, so it is surfaced BOTH in the per-query // m12p2: ANN candidate generation in RETRIEVE — O(ef_search)
// `warnings` (caller-visible) AND a `tracing::warn!` // nearest neighbours of the resolved query vector (the user's
// (operator-visible) — a silent degrade with no log line // preference vector for `for_you`, the seed item's embedding
// leaves no way to track how often profiles hit this path. // for `similar_to`), over-fetched so Stage 2/2.5 + diversity
// have room. Degrades to a scan (with a caller- AND
// operator-visible note) when no registry/query vector is
// available — an anonymous read or a user with no preference
// vector yet — so the read keeps working instead of erroring.
// Over-fetch limit×10, floored so tiny feeds still seed a pool,
// and capped by the profile's declared `Ann.limit` so a huge
// page size can't drive an unbounded ANN beam.
let k = query
.limit
.saturating_mul(ANN_OVERFETCH)
.max(ANN_CANDIDATE_FLOOR)
.min((*ann_limit).max(ANN_CANDIDATE_FLOOR));
if let (Some(registry), Some(query_vector)) =
(self.embedding_registry, self.ann_query_vector.as_deref())
{
let slot = self.item_embedding_slot.unwrap_or("content");
let ann = candidate_gen::ann_candidates(registry, slot, query_vector, k, 0);
if ann.is_empty() {
warnings.push( warnings.push(
"ANN candidate strategy not yet wired; falling back to scan".to_string(), "ANN candidate generation returned no candidates; \
falling back to scan"
.to_string(),
); );
tracing::warn!( tracing::warn!(
profile = %query.profile.name, profile = %query.profile.name,
"ANN candidate strategy requested but not wired in RETRIEVE; \ "ANN returned no candidates (empty/absent slot); scan fallback"
falling back to a full scan" );
candidate_gen::scan_candidates(
self.universe,
query.limit,
has_user_context,
)
} else {
ann
}
} else {
warnings.push(
"ANN candidate strategy: no query vector resolvable \
(anonymous read or no preference vector yet); falling back to scan"
.to_string(),
);
tracing::debug!(
profile = %query.profile.name,
has_registry = self.embedding_registry.is_some(),
"ANN strategy without a query vector; scan fallback"
); );
candidate_gen::scan_candidates(self.universe, query.limit, has_user_context) candidate_gen::scan_candidates(self.universe, query.limit, has_user_context)
} }
}
CandidateStrategy::Relationship => { CandidateStrategy::Relationship => {
// M3: source candidates from the user's followed creators. // M3: source candidates from the user's followed creators.
// Uses the follows index in user_state + creator_items bitmap to build // Uses the follows index in user_state + creator_items bitmap to build

View File

@ -143,6 +143,15 @@ pub fn register_builtins(registry: &mut ProfileRegistry) -> Result<(), ProfileEr
/// cohort ordering matches the global one. Keep the two in sync. /// cohort ordering matches the global one. Keep the two in sync.
fn trending() -> RankingProfile { fn trending() -> RankingProfile {
let mut p = skeleton("trending"); let mut p = skeleton("trending");
// m12p2: source candidates from the most-viewed items via the cached
// per-signal-type top-K, so trending ranks the actually-engaged corpus at
// scale (O(K)) instead of an arbitrary low-id scan slice. Sort::Trending then
// re-ranks this pool by velocity. Degrades to a scan when no `view` signals
// exist yet (executor fallback), so a fresh corpus still serves.
p.candidate_strategy = CandidateStrategy::SignalRanked {
signal: "view".into(),
window: Window::TwentyFourHours,
};
p.sort = Some(Sort::Trending); p.sort = Some(Sort::Trending);
// Cohort-rescore signal definition (see doc above); the global path uses the // Cohort-rescore signal definition (see doc above); the global path uses the
// Sort::Trending formula and skips these per spec §11.9. // Sort::Trending formula and skips these per spec §11.9.
@ -276,6 +285,15 @@ const FOLLOWING_MAX_PER_CREATOR: usize = 3;
/// scoring in the executor. /// scoring in the executor.
fn for_you() -> RankingProfile { fn for_you() -> RankingProfile {
let mut p = skeleton("for_you"); let mut p = skeleton("for_you");
// m12p2: ANN candidate generation over the user's preference vector — the
// nearest content to the user's learned taste, O(ef_search), not an arbitrary
// low-id scan slice. Degrades to a scan for anonymous reads or a user with no
// preference vector yet (executor handles the fallback). `limit` caps the ANN
// candidate pool; the executor over-fetches `query.limit × 10` within it.
p.candidate_strategy = CandidateStrategy::Ann {
slot: "content".into(),
limit: 1000,
};
p.sort = Some(Sort::Hot { gravity: 1.5 }); p.sort = Some(Sort::Hot { gravity: 1.5 });
p.boosts = vec![ p.boosts = vec![
Boost { Boost {
@ -335,6 +353,13 @@ fn following() -> RankingProfile {
/// content-type boosting. /// content-type boosting.
fn related() -> RankingProfile { fn related() -> RankingProfile {
let mut p = skeleton("related"); let mut p = skeleton("related");
// m12p2: ANN candidate generation over the seed item's embedding (resolved
// from the query's `similar_to`) — true "more like this", O(ef_search).
// Degrades to a scan when no `similar_to` is supplied (executor fallback).
p.candidate_strategy = CandidateStrategy::Ann {
slot: "content".into(),
limit: 1000,
};
p.sort = Some(Sort::Hot { gravity: 1.2 }); p.sort = Some(Sort::Hot { gravity: 1.2 });
p.boosts = vec![ p.boosts = vec![
Boost { Boost {

View File

@ -35,6 +35,11 @@ pub struct SignalLedger {
signal_name_to_id: HashMap<String, SignalTypeId>, signal_name_to_id: HashMap<String, SignalTypeId>,
/// `SignalTypeId` -> lambda array (cached from schema, immutable after construction). /// `SignalTypeId` -> lambda array (cached from schema, immutable after construction).
signal_lambdas: HashMap<SignalTypeId, Vec<f64>>, signal_lambdas: HashMap<SignalTypeId, Vec<f64>>,
/// m12p2: cached per-signal-type top-K backing the `SignalRanked` candidate
/// strategy, so its candidate generation is O(K) per query instead of an
/// O(N) ledger scan every time. Read-path only (write path just bumps a
/// counter); see [`super::hot_top_k`].
hot_top_k: super::hot_top_k::HotTopKCache,
} }
impl SignalLedger { impl SignalLedger {
@ -54,9 +59,25 @@ impl SignalLedger {
schema, schema,
signal_name_to_id, signal_name_to_id,
signal_lambdas, signal_lambdas,
hot_top_k: super::hot_top_k::HotTopKCache::new(),
} }
} }
/// Cached per-signal-type top-K candidates (m12p2), best-first by decayed
/// score, capped to `(limit × 4).max(200)`. Backs the `SignalRanked`
/// candidate strategy; O(K) per query on the cached path, with a bounded O(N)
/// rebuild only when the cache is stale (see [`super::hot_top_k`]). Returns an
/// empty vec for an unknown signal name.
pub(crate) fn hot_top_k_candidates(&self, signal_name: &str, limit: usize) -> Vec<EntityId> {
let Ok(type_id) = self.resolve_signal_type(signal_name) else {
return Vec::new();
};
let needed = limit.saturating_mul(4).max(200);
let now_ns = Timestamp::now().as_nanos();
self.hot_top_k
.candidates(&self.entries, type_id, needed, now_ns)
}
/// Record a signal event for an entity. /// Record a signal event for an entity.
/// ///
/// Steps: /// Steps:
@ -542,6 +563,10 @@ impl SignalLedger {
// Explicitly drop the DashMap entry ref to release the shard lock before // Explicitly drop the DashMap entry ref to release the shard lock before
// returning (satisfies clippy::significant_drop_tightening). // returning (satisfies clippy::significant_drop_tightening).
drop(entry); drop(entry);
// m12p2: invalidate the SignalRanked top-K cache (one relaxed atomic add).
// This is the single in-memory mutation chokepoint for every signal apply
// path (record/replay/replication), so the cache can never miss a write.
self.hot_top_k.note_write();
} }
/// Apply a WAL event directly to in-memory state, bypassing the WAL write. /// Apply a WAL event directly to in-memory state, bypassing the WAL write.

View File

@ -0,0 +1,160 @@
//! Cached per-signal-type top-K (m12p2): bound `SignalRanked` candidate
//! generation to O(K) per query instead of an O(N) ledger scan on every call.
//!
//! # Why a cache is correct here
//!
//! A `SignalRanked` strategy ranks candidates by a signal's decayed score. The
//! score of an entity at query time is `accumulated * exp(-lambda * dt)`, and the
//! SAME `lambda` applies to every entity of a given signal type, so time decay
//! multiplies every entity's score by the same factor — it **never changes the
//! relative order**. The membership of the top-K therefore only changes when a
//! NEW signal write bumps some entity's accumulated score. That makes a cached
//! top-K valid until the next write, not until the next clock tick.
//!
//! # Freshness policy
//!
//! - **Small ledgers** (`< SMALL_LEDGER_ENTRIES`): the O(N) rebuild is
//! sub-millisecond, so we rebuild whenever a write has landed since the cache
//! was built — always fresh, which is what in-process tests and small apps
//! expect.
//! - **Large ledgers** (`>= SMALL_LEDGER_ENTRIES`): a continuous-write workload
//! would otherwise land an O(N) scan on the read hot path and blow the p99, so
//! rebuilds are throttled to at most one per [`REFRESH_INTERVAL_NS`]. Trending
//! tolerates a second of staleness; the p99 SLO does not tolerate a 50ms scan.
use std::sync::atomic::{AtomicU64, Ordering};
use dashmap::DashMap;
use super::super::SignalTypeId;
use super::types::EntitySignalEntry;
use crate::schema::EntityId;
/// Ledgers smaller than this rebuild the cache on every post-write query (always
/// fresh; the scan is cheap at this size). At or above it, rebuilds throttle to
/// [`REFRESH_INTERVAL_NS`] to keep the O(N) scan off the read hot path.
const SMALL_LEDGER_ENTRIES: usize = 50_000;
/// Minimum wall-clock between rebuilds for a large ledger (1 second).
const REFRESH_INTERVAL_NS: u64 = 1_000_000_000;
/// How many entities to materialize per signal type. Bounds cache memory (a few
/// thousand `EntityId`s per signal type) and caps the candidate pool any single
/// `SignalRanked` query draws from. Comfortably above `MAX_LIMIT × 4`.
const REBUILD_K: usize = 4096;
/// One signal type's materialized top-K plus the bookkeeping that decides
/// freshness.
struct CachedTopK {
/// Top entities by decayed score, best-first (length ≤ [`REBUILD_K`]).
items: Vec<EntityId>,
/// Wall-clock (ns) the cache was built — drives the large-ledger throttle.
built_at_ns: u64,
/// The global write counter at build time — if it still matches, no signal
/// has been written since, so the cache is exact regardless of elapsed time.
built_dirty: u64,
}
/// Per-signal-type cached top-K, with a global write counter for invalidation.
pub struct HotTopKCache {
per_type: DashMap<SignalTypeId, CachedTopK>,
/// Bumped once per signal apply (one relaxed atomic add — negligible on the
/// write path). A cache built at an earlier value knows writes have landed.
dirty: AtomicU64,
}
impl HotTopKCache {
pub fn new() -> Self {
Self {
per_type: DashMap::new(),
dirty: AtomicU64::new(0),
}
}
/// Record that a signal was written (invalidates caches on their next read).
pub fn note_write(&self) {
self.dirty.fetch_add(1, Ordering::Relaxed);
}
/// Top entities for `type_id` by decayed score, best-first, capped to the
/// caller's `needed` count. Serves a fresh-enough cache in O(needed); rebuilds
/// (one bounded O(N) scan) only when stale per the freshness policy.
pub fn candidates(
&self,
entries: &DashMap<(EntityId, SignalTypeId), EntitySignalEntry>,
type_id: SignalTypeId,
needed: usize,
now_ns: u64,
) -> Vec<EntityId> {
let needed = needed.min(REBUILD_K);
let dirty_now = self.dirty.load(Ordering::Relaxed);
if let Some(cached) = self.per_type.get(&type_id) {
let fresh = if cached.built_dirty == dirty_now {
// No writes at all since the build ⇒ exact, regardless of clock.
true
} else if entries.len() < SMALL_LEDGER_ENTRIES {
// Small ledger + new writes ⇒ rebuild (cheap, always fresh).
false
} else {
// Large ledger ⇒ throttle the O(N) rebuild off the hot path.
now_ns.saturating_sub(cached.built_at_ns) < REFRESH_INTERVAL_NS
};
if fresh {
return cached.items.iter().take(needed).copied().collect();
}
}
let items = rebuild(entries, type_id, now_ns);
let out = items.iter().take(needed).copied().collect();
self.per_type.insert(
type_id,
CachedTopK {
items,
built_at_ns: now_ns,
built_dirty: dirty_now,
},
);
out
}
}
/// The bounded O(N) ledger scan: the top [`REBUILD_K`] entities of `type_id` by
/// decayed score, best-first. Memory stays O(K): a buffer capped at `2·K` is
/// partition-truncated to K whenever it fills, so peak allocation is bounded
/// regardless of how many entities carry the signal.
fn rebuild(
entries: &DashMap<(EntityId, SignalTypeId), EntitySignalEntry>,
type_id: SignalTypeId,
now_ns: u64,
) -> Vec<EntityId> {
let cap = REBUILD_K;
// Descending by score; NaN sinks via a deterministic id tiebreak.
let cmp = |a: &(EntityId, f64), b: &(EntityId, f64)| {
b.1.partial_cmp(&a.1)
.unwrap_or_else(|| b.0.as_u64().cmp(&a.0.as_u64()))
};
let buffer_cap = cap.saturating_mul(2).max(cap + 1);
let mut scored: Vec<(EntityId, f64)> = Vec::with_capacity(buffer_cap);
for entry in entries {
let (entity_id, signal_type_id) = entry.key();
if *signal_type_id == type_id {
// Decay score with lambda=0 (no extra decay beyond what was applied at
// write time) — the same simplified candidate-gen ranking the prior
// O(N) scan used. Stage 3 does the full scoring.
let score = entry.value().hot.current_score(0, now_ns, 0.0);
scored.push((*entity_id, score));
if scored.len() >= buffer_cap {
scored.select_nth_unstable_by(cap - 1, cmp);
scored.truncate(cap);
}
}
}
if scored.len() > cap {
scored.select_nth_unstable_by(cap - 1, cmp);
scored.truncate(cap);
}
scored.sort_unstable_by(cmp);
scored.into_iter().map(|(id, _)| id).collect()
}

View File

@ -19,6 +19,7 @@
//! tier) is lock-free once the entry reference is obtained. //! tier) is lock-free once the entry reference is obtained.
pub mod core; pub mod core;
mod hot_top_k;
pub mod types; pub mod types;
pub use core::{SignalLedger, StagedLedgerApply}; pub use core::{SignalLedger, StagedLedgerApply};

View File

@ -18,25 +18,62 @@ use crate::schema::EntityKind;
// Vector backend selection (production HNSW vs. brute-force) // Vector backend selection (production HNSW vs. brute-force)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Minimum live-vector count for a slot before the production HNSW engine /// Upper bound (cap) on the brute-force→HNSW crossover, in live vectors.
/// (`UsearchIndex`) is preferred over the exact `BruteForceIndex`.
/// ///
/// `BruteForceIndex` computes an L2 distance against every stored vector under /// This is the crossover at *low* dimensionality, where a brute-force scan is
/// an `RwLock` read on each search — exact, but O(n) per query and serialized /// cheap per vector. The effective crossover is dimension-scaled by
/// against writes. It is the right choice for tiny slots (no graph-build cost, /// [`usearch_min_vectors`] — see there for why a fixed count is wrong at 1536-D.
/// exact results) but blows past the latency budget at scale. `UsearchIndex` /// Retained as `USEARCH_MIN_VECTORS` (its historical name) because it is the
/// (HNSW) is the mandated production engine (`CODING_GUIDELINES` §4: "126K+ QPS", /// crossover for every slot at or below [`BRUTE_FORCE_SCAN_OPS_BUDGET`] ÷ cap
/// §8: "ANN retrieval at 1M vectors <10ms p99"). Gating on slot size needs NO /// dimensionality (≈ 400-D), which includes the 128-D test/bench shapes.
/// external config plumbing: at process restart, `rebuild_from_store` already
/// knows each slot's vector count, so a slot that crossed this threshold is
/// rebuilt as HNSW automatically, while a small slot stays exact. The threshold
/// is deliberately conservative — well inside the range where brute force is
/// still fast — so correctness-sensitive small slots keep exact results.
pub(crate) const USEARCH_MIN_VECTORS: usize = 10_000; pub(crate) const USEARCH_MIN_VECTORS: usize = 10_000;
/// Smallest crossover the dimension scaling will ever pick, in live vectors.
///
/// Below this many vectors, HNSW recall is poor (too few nodes for a good graph)
/// and the brute-force scan is sub-millisecond at any realistic dimensionality,
/// so exact search wins regardless of dim. Floors [`usearch_min_vectors`] so an
/// extreme dimensionality cannot drive the crossover to a recall-hostile handful.
pub(crate) const USEARCH_MIN_VECTORS_FLOOR: usize = 1_000;
/// Target work budget for a single brute-force scan, in scalar multiply-adds
/// (`count × dimensions`). The dimension-aware crossover keeps a brute-force
/// slot's per-query scan at or under this many FMAs.
///
/// `BruteForceIndex::search` computes a scalar (non-SIMD) L2 distance against
/// every live vector while holding its `RwLock` read guard, so the scan also
/// blocks writers for its duration. At ~4M FMAs the scalar scan is on the order
/// of a couple of milliseconds — comfortably inside the retrieve budget — so we
/// flip a slot to HNSW once `count × dim` would exceed this. Worked example: at
/// the production 1536-D this caps brute force at `⌊4_000_000 ÷ 1536⌋` = 2604
/// vectors, where the old fixed `10_000` would have scanned 15.4M FMAs (≈ tens of
/// ms) under the read lock — the exact SLA blow-up the m12p3 roadmap flagged.
const BRUTE_FORCE_SCAN_OPS_BUDGET: usize = 4_000_000;
/// Dimension-aware brute-force→HNSW crossover, in live vectors.
///
/// The cost of a brute-force scan is `count × dim`, so a single vector-count
/// crossover is wrong: `10_000` vectors at 128-D is a 1.3M-FMA scan (sub-ms) but
/// at 1536-D it is a 15.4M-FMA scan (tens of ms, under a read lock that also
/// blocks writers). We instead pick the largest `count` whose scan stays within
/// [`BRUTE_FORCE_SCAN_OPS_BUDGET`], clamped to
/// `[USEARCH_MIN_VECTORS_FLOOR, USEARCH_MIN_VECTORS]`. At/under ≈ 400-D this
/// returns the historical [`USEARCH_MIN_VECTORS`] (so 128-D shapes are
/// byte-for-byte unchanged); at 1536-D it returns ≈ 2604, flipping high-dim
/// slots to HNSW before the scan can blow the latency budget.
#[must_use]
pub(crate) fn usearch_min_vectors(dimensions: usize) -> usize {
// dimensions == 0 cannot occur for a real slot (the write/rebuild paths
// reject zero-dim vectors), but guard the divide regardless.
if dimensions == 0 {
return USEARCH_MIN_VECTORS;
}
(BRUTE_FORCE_SCAN_OPS_BUDGET / dimensions).clamp(USEARCH_MIN_VECTORS_FLOOR, USEARCH_MIN_VECTORS)
}
/// Build the vector index backend for a slot, gating on the expected vector /// Build the vector index backend for a slot, gating on the expected vector
/// `count`: `UsearchIndex` (production HNSW) at or above [`USEARCH_MIN_VECTORS`], /// `count`: `UsearchIndex` (production HNSW) at or above the dimension-aware
/// `BruteForceIndex` (exact) below it. /// crossover [`usearch_min_vectors`], `BruteForceIndex` (exact) below it.
/// ///
/// When the `USearch` backend is selected, capacity is reserved up front /// When the `USearch` backend is selected, capacity is reserved up front
/// (`UsearchIndex::add` requires a prior `reserve`), so the subsequent inserts /// (`UsearchIndex::add` requires a prior `reserve`), so the subsequent inserts
@ -52,7 +89,7 @@ pub(crate) fn build_slot_index(dimensions: usize, count: usize) -> Box<dyn Vecto
dimensions, dimensions,
..VectorIndexConfig::default() ..VectorIndexConfig::default()
}; };
if count < USEARCH_MIN_VECTORS { if count < usearch_min_vectors(dimensions) {
return Box::new(super::BruteForceIndex::new(config)); return Box::new(super::BruteForceIndex::new(config));
} }
match super::UsearchIndex::new(config.clone()) { match super::UsearchIndex::new(config.clone()) {
@ -341,10 +378,11 @@ impl EmbeddingSlotRegistry {
/// # Backend selection /// # Backend selection
/// ///
/// Each slot's backend is chosen by [`build_slot_index`] from the slot's live /// Each slot's backend is chosen by [`build_slot_index`] from the slot's live
/// vector count: a slot at or above [`USEARCH_MIN_VECTORS`] is rebuilt as the /// vector count: a slot at or above the dimension-aware crossover
/// production HNSW engine (`UsearchIndex`); smaller slots stay exact /// [`usearch_min_vectors`] is rebuilt as the production HNSW engine
/// (`BruteForceIndex`). This is the live path that makes `UsearchIndex` /// (`UsearchIndex`); smaller slots stay exact (`BruteForceIndex`). This is the
/// reachable in production after a restart, with no config plumbing. /// live path that makes `UsearchIndex` reachable in production after a restart,
/// with no config plumbing.
/// ///
/// # Fault isolation /// # Fault isolation
/// ///
@ -514,10 +552,11 @@ impl EmbeddingSlotRegistry {
} }
// Lazily register the slot on first sight, routing the backend through // Lazily register the slot on first sight, routing the backend through
// the size-gated factory: a slot with >= USEARCH_MIN_VECTORS live // the size-gated factory: a slot at/above the dimension-aware
// vectors is rebuilt as the production HNSW (USearch) engine; smaller // crossover (usearch_min_vectors) is rebuilt as the production HNSW
// slots stay exact (brute force). This is the live path that makes // (USearch) engine; smaller slots stay exact (brute force). This is
// USearch reachable in production restarts — no config flag required. // the live path that makes USearch reachable in production restarts —
// no config flag required.
if self.get(entity_kind, &slot_name).is_none() { if self.get(entity_kind, &slot_name).is_none() {
let state = EmbeddingSlotState { let state = EmbeddingSlotState {
index: build_slot_index(dimensions, entries.len()), index: build_slot_index(dimensions, entries.len()),
@ -983,7 +1022,8 @@ mod tests {
// At threshold: USearch (HNSW), with capacity reserved by the factory so // At threshold: USearch (HNSW), with capacity reserved by the factory so
// inserts succeed without a manual `reserve`. Searchable end-to-end — // inserts succeed without a manual `reserve`. Searchable end-to-end —
// proving the production engine is actually reachable, not dead code. // proving the production engine is actually reachable, not dead code.
let big = build_slot_index(4, USEARCH_MIN_VECTORS); // At 4-D the dimension-aware crossover == USEARCH_MIN_VECTORS (the cap).
let big = build_slot_index(4, usearch_min_vectors(4));
big.insert(10, &[1.0, 0.0, 0.0, 0.0]).unwrap(); big.insert(10, &[1.0, 0.0, 0.0, 0.0]).unwrap();
big.insert(20, &[0.0, 1.0, 0.0, 0.0]).unwrap(); big.insert(20, &[0.0, 1.0, 0.0, 0.0]).unwrap();
let r = big.search(&[1.0, 0.0, 0.0, 0.0], 1, big.len()).unwrap(); let r = big.search(&[1.0, 0.0, 0.0, 0.0], 1, big.len()).unwrap();
@ -993,6 +1033,70 @@ mod tests {
); );
} }
/// m12p3: the brute-force→HNSW crossover must scale DOWN with dimensionality
/// so a high-dim slot does not scan millions of FMAs under the read lock. At
/// 1536-D the crossover must be far below the 128-D/cap value, and a count
/// that stays brute force at 128-D must flip to HNSW at 1536-D.
#[test]
fn usearch_min_vectors_scales_down_with_dimension() {
// Low dim: clamped to the historical cap (byte-compatible with pre-m12p3).
assert_eq!(usearch_min_vectors(128), USEARCH_MIN_VECTORS);
assert_eq!(usearch_min_vectors(4), USEARCH_MIN_VECTORS);
// Production 1536-D: well below the cap, keeping the scan bounded.
let hi = usearch_min_vectors(1536);
assert!(
hi < USEARCH_MIN_VECTORS
&& (USEARCH_MIN_VECTORS_FLOOR..USEARCH_MIN_VECTORS).contains(&hi),
"1536-D crossover {hi} must be below the cap and at/above the floor"
);
// The bounded scan: count × dim must stay within the budget.
assert!(
hi * 1536 <= BRUTE_FORCE_SCAN_OPS_BUDGET,
"1536-D crossover scan {} FMAs exceeds the budget",
hi * 1536
);
// Crossover is monotonically non-increasing in dimensionality.
assert!(usearch_min_vectors(768) >= usearch_min_vectors(1536));
// A slot of 5000 vectors: brute force at 128-D, HNSW at 1536-D.
assert!(
5_000 < usearch_min_vectors(128),
"5000 stays brute at 128-D"
);
assert!(
5_000 >= usearch_min_vectors(1536),
"5000 flips to HNSW at 1536-D"
);
// Extreme dim is floored, never driven to a recall-hostile handful.
assert_eq!(usearch_min_vectors(1_000_000), USEARCH_MIN_VECTORS_FLOOR);
// Defensive: zero dim cannot divide-by-zero.
assert_eq!(usearch_min_vectors(0), USEARCH_MIN_VECTORS);
}
/// The factory must actually build HNSW at a 1536-D count that the old fixed
/// `10_000` threshold would have left as a brute-force scan.
#[test]
fn build_slot_index_flips_to_hnsw_for_high_dim_midsize_slot() {
// 5000 < 10_000, so the OLD code built brute force; the dimension-aware
// crossover at 1536-D (~2604) puts 5000 above it → HNSW.
let dim = 1536;
let count = 5_000;
assert!(count >= usearch_min_vectors(dim));
let index = build_slot_index(dim, count);
// A freshly built HNSW reserved for `count` accepts inserts and searches.
let mut v = vec![0.0_f32; dim];
v[0] = 1.0;
index.insert(1, &v).unwrap();
let r = index.search(&v, 1, 64).unwrap();
assert_eq!(
r[0].id, 1,
"high-dim midsize slot must build a working HNSW"
);
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// rebuild fault isolation — findings 8 and 13 // rebuild fault isolation — findings 8 and 13
// ------------------------------------------------------------------- // -------------------------------------------------------------------

View File

@ -12,20 +12,27 @@
//! - **`total_slots` tracking**: `USearch`'s `size()` excludes tombstoned entries //! - **`total_slots` tracking**: `USearch`'s `size()` excludes tombstoned entries
//! after `remove()`. We maintain an `AtomicUsize` counter to provide the total //! after `remove()`. We maintain an `AtomicUsize` counter to provide the total
//! occupied graph slot count (including tombstones) via `len()`. //! occupied graph slot count (including tombstones) via `len()`.
//! - **No per-query `ef_search` override**: `USearch`'s `change_expansion_search()` //! - **Per-query `ef_search` override** (m12p3): `USearch` 2.24 has no per-call
//! mutates global index state (not per-query). We accept the parameter for //! beam-width argument — `search(query, count)` reads the index-global
//! trait compliance but use the index-level default set at construction time. //! `expansion_search`, and the only knob is `change_expansion_search()`, which
//! See [`ef_search_unsupported_warn`](UsearchIndex::ef_search_unsupported_warn) //! mutates that shared value for every concurrent search. We make a per-query
//! and the rationale on [`search`](VectorIndex::search). Tracked as deferred //! override race-free with an `RwLock` *epoch guard* (not a per-search mutex):
//! work in `docs/specs/07-vector-retrieval.md` (per-query beam-width override); //! a search holds the lock SHARED while the live expansion already equals the
//! not scheduled for an M0M10 milestone. //! requested `ef`, so any number of same-`ef` searches run in parallel; only a
//! query that DISAGREES with the live expansion takes the lock EXCLUSIVE to
//! change it. The uniform-`ef` hot path (the overwhelmingly common case) pays
//! one uncontended read-lock acquire, not serialization. See
//! [`with_expansion`](UsearchIndex::with_expansion) and [`search`](VectorIndex::search).
//! - **Thread safety**: `usearch::Index` implements `Send + Sync` (backed by //! - **Thread safety**: `usearch::Index` implements `Send + Sync` (backed by
//! C++ thread-safe HNSW). `AtomicUsize` is trivially `Send + Sync`. Therefore //! C++ thread-safe HNSW). `AtomicUsize`/`RwLock` are trivially `Send + Sync`.
//! `UsearchIndex` is `Send + Sync` without any unsafe impl blocks. //! Therefore `UsearchIndex` is `Send + Sync` without any unsafe impl blocks.
use std::{ use std::{
path::Path, path::Path,
sync::atomic::{AtomicUsize, Ordering}, sync::{
RwLock,
atomic::{AtomicUsize, Ordering},
},
}; };
use super::{ use super::{
@ -83,6 +90,29 @@ pub struct UsearchIndex {
/// persistence. A ranking query that reads a slightly stale count /// persistence. A ranking query that reads a slightly stale count
/// suffers no correctness issue. /// suffers no correctness issue.
total_slots: AtomicUsize, total_slots: AtomicUsize,
/// The `expansion_search` value currently configured on `inner`.
///
/// Starts at `config.ef_search` (the value passed to `new_index`) and is
/// mutated only while holding the WRITE side of [`Self::expansion_lock`], so a
/// reader that holds the read side and observes `current_expansion == ef`
/// knows the C++ beam width is pinned to `ef` for the whole search.
///
/// `Relaxed` is sufficient: the value is only ever read/written under
/// `expansion_lock`, which already provides the acquire/release ordering.
current_expansion: AtomicUsize,
/// Guards a *change* of the index-global `expansion_search` against concurrent
/// searches, making per-query `ef_search` race-free without serializing the
/// hot path.
///
/// A shared (read) hold admits unlimited concurrent searches that agree on the
/// beam width; the exclusive (write) hold is taken only to change the beam
/// width — i.e. only by a query whose `ef_search` differs from the live value.
/// This is the distinction the old "a mutex around search + expansion change
/// is unacceptable on the hot path" note missed: an `RwLock` read does NOT
/// serialize searches that share an `ef_search`, which is the production norm.
/// The guard protects only the `(change_expansion_search, search)` window; it
/// holds no data, hence `RwLock<()>`.
expansion_lock: RwLock<()>,
} }
impl UsearchIndex { impl UsearchIndex {
@ -117,9 +147,13 @@ impl UsearchIndex {
.map_err(|e| VectorError::Backend(format!("USearch init failed: {e}")))?; .map_err(|e| VectorError::Backend(format!("USearch init failed: {e}")))?;
Ok(Self { Ok(Self {
// `new_index` set the C++ `expansion_search` to `config.ef_search`,
// so the tracked value starts there.
current_expansion: AtomicUsize::new(config.ef_search),
inner, inner,
config, config,
total_slots: AtomicUsize::new(0), total_slots: AtomicUsize::new(0),
expansion_lock: RwLock::new(()),
}) })
} }
@ -128,6 +162,39 @@ impl UsearchIndex {
validate_dimensions(self.config.dimensions, vec.len()) validate_dimensions(self.config.dimensions, vec.len())
} }
/// Reserve capacity for `total` vectors AND `threads` concurrent writer slots.
///
/// `USearch`'s plain `reserve` allocates a single writer slot, so concurrent
/// `add` from multiple threads corrupts the proximity graph (recall collapses).
/// `reserve_capacity_and_threads` allocates one slot per thread, after which
/// concurrent `insert` of DISTINCT ids from up to `threads` threads is safe
/// (each `add` grabs a free slot internally; `total_slots` uses an atomic
/// `fetch_add`). Used by the m12p3 grid-search harness to build the 100k/1M
/// indexes in parallel — the difference between a tractable and a ~70-minute
/// sequential build — without sacrificing graph quality.
///
/// # Errors
///
/// Returns [`VectorError::Backend`] if the underlying reservation fails (OOM).
pub fn reserve_with_threads(&self, total: usize, threads: usize) -> Result<(), VectorError> {
self.inner
.reserve_capacity_and_threads(total, threads)
.map_err(|e| VectorError::Backend(format!("USearch reserve failed: {e}")))
}
/// Actual resident memory of the HNSW graph + vectors, in bytes, as reported
/// by `USearch` itself.
///
/// Unlike [`EmbeddingSlotRegistry::index_stats`](super::EmbeddingSlotRegistry::index_stats),
/// which estimates `count × dim × bytes_per_component` (a lower bound that
/// omits the proximity-graph links), this is the true footprint including the
/// HNSW graph overhead. Used by the m12p3 grid-search harness to size pods at
/// the production shape (F16 vs Int8 vs F32 at 1M/1536-D).
#[must_use]
pub fn memory_usage(&self) -> usize {
self.inner.memory_usage()
}
/// Convert a `&Path` to a `&str`, returning a `VectorError::Io` on non-UTF-8 paths. /// Convert a `&Path` to a `&str`, returning a `VectorError::Io` on non-UTF-8 paths.
fn path_to_str(path: &Path) -> Result<&str, VectorError> { fn path_to_str(path: &Path) -> Result<&str, VectorError> {
path.to_str().ok_or_else(|| { path.to_str().ok_or_else(|| {
@ -138,22 +205,56 @@ impl UsearchIndex {
}) })
} }
/// Warn once per call when a query requests an `ef_search` that differs from /// Run a `USearch` search `op` with the index-global `expansion_search` pinned
/// the index-level default, which `USearch` cannot honor per-query. /// to `ef` for the whole call, honoring the per-query beam-width override
/// race-free (m12p3).
/// ///
/// Extracted so `search` and `filtered_search` emit an identical diagnostic /// `USearch` 2.24 exposes no per-call expansion argument; the only knob is
/// (single source of truth — the two paths previously duplicated this block /// `change_expansion_search`, which mutates index-global state shared by every
/// verbatim). See the module-level docs for why per-query override is not /// concurrent search. The [`Self::expansion_lock`] makes that safe:
/// supported. ///
fn ef_search_unsupported_warn(&self, requested: usize) { /// * **Shared (read) path** — the common case. If the live expansion already
if requested != self.config.ef_search { /// equals `ef`, hold the read guard and run `op`. No writer can change the
tracing::warn!( /// expansion while ANY reader holds the guard, so the C++ value is pinned to
requested, /// `ef` for the entire search, and unlimited same-`ef` searches proceed in
default = self.config.ef_search, /// parallel. Cost: one uncontended read-lock acquire — not serialization.
"per-query ef_search override not supported; using index-level default (may affect recall)" /// * **Exclusive (write) path** — only a query whose `ef` differs from the
); /// live value. Take the write guard, set the expansion, then run `op` while
/// still holding it (so a third query cannot change the value mid-search).
///
/// `ef == 0` is normalized to the construction-time default
/// (`config.ef_search`), matching the "0 ⇒ slot default" convention the query
/// layer uses ([`crate::query::executor::candidate_gen`]). A poisoned guard is
/// recovered with `into_inner`: the lock protects only the transient
/// `(set, search)` window — there is no invariant to uphold across a panic —
/// so one panicking search must not brick every future search on the slot.
fn with_expansion<R>(&self, ef: usize, op: impl FnOnce() -> R) -> R {
let ef = if ef == 0 { self.config.ef_search } else { ef };
// Shared path: the live beam width already matches — run concurrently.
{
let _shared = self
.expansion_lock
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if self.current_expansion.load(Ordering::Relaxed) == ef {
return op();
} }
} }
// Exclusive path: change the beam width, then search under the same guard.
let _exclusive = self
.expansion_lock
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
// Re-check under the exclusive guard: another writer may have already set
// `ef` between our shared-path miss and acquiring the write lock.
if self.current_expansion.load(Ordering::Relaxed) != ef {
self.inner.change_expansion_search(ef);
self.current_expansion.store(ef, Ordering::Relaxed);
}
op()
}
} }
impl VectorIndex for UsearchIndex { impl VectorIndex for UsearchIndex {
@ -177,22 +278,11 @@ impl VectorIndex for UsearchIndex {
/// Search for the `k` nearest neighbors using HNSW approximate search. /// Search for the `k` nearest neighbors using HNSW approximate search.
/// ///
/// The `ef_search` parameter is accepted for trait compliance. If it /// The `ef_search` parameter is honored per-query (m12p3): it sets the layer-0
/// differs from the index-level default, a debug log is emitted, but /// beam width for this search, trading latency for recall. `0` uses the
/// the index-level default is used. /// construction-time default. The override is made race-free against
/// /// concurrent searches by [`Self::with_expansion`] — searches that share an
/// # Per-query `ef_search` override (deferred) /// `ef_search` run concurrently; only a differing one briefly serializes.
///
/// `USearch`'s `change_expansion_search()` mutates global state and is
/// not safe for concurrent queries with different `ef_search` values.
/// A proper solution would require either:
///
/// 1. A mutex around search + expansion change (unacceptable on the hot path), or
/// 2. `USearch` adding a per-query expansion parameter to their search API.
///
/// For now, we use the index-level default set at construction time.
/// Tracked as deferred work in `docs/specs/07-vector-retrieval.md`
/// (per-query beam-width override); not scheduled for an M0M10 milestone.
fn search( fn search(
&self, &self,
query: &[f32], query: &[f32],
@ -200,11 +290,9 @@ impl VectorIndex for UsearchIndex {
ef_search: usize, ef_search: usize,
) -> Result<Vec<VectorSearchResult>, VectorError> { ) -> Result<Vec<VectorSearchResult>, VectorError> {
self.validate_dimensions(query)?; self.validate_dimensions(query)?;
self.ef_search_unsupported_warn(ef_search);
let matches = self let matches = self
.inner .with_expansion(ef_search, || self.inner.search(query, k))
.search(query, k)
.map_err(|e| VectorError::Backend(format!("USearch search failed: {e}")))?; .map_err(|e| VectorError::Backend(format!("USearch search failed: {e}")))?;
Ok(matches Ok(matches
@ -221,7 +309,8 @@ impl VectorIndex for UsearchIndex {
/// through the C++ layer. The predicate is evaluated during graph traversal, /// through the C++ layer. The predicate is evaluated during graph traversal,
/// so non-matching vectors are skipped without computing their distance. /// so non-matching vectors are skipped without computing their distance.
/// ///
/// See `search()` for the `ef_search` override limitation. /// The `ef_search` parameter is honored per-query exactly as in
/// [`search`](Self::search) — see there for the concurrency model.
fn filtered_search( fn filtered_search(
&self, &self,
query: &[f32], query: &[f32],
@ -230,15 +319,15 @@ impl VectorIndex for UsearchIndex {
filter: &dyn Fn(VectorId) -> bool, filter: &dyn Fn(VectorId) -> bool,
) -> Result<Vec<VectorSearchResult>, VectorError> { ) -> Result<Vec<VectorSearchResult>, VectorError> {
self.validate_dimensions(query)?; self.validate_dimensions(query)?;
self.ef_search_unsupported_warn(ef_search);
// Wrap the &dyn Fn in a concrete closure type that USearch's generic // Wrap the &dyn Fn in a concrete closure type that USearch's generic
// filtered_search can monomorphize over. // filtered_search can monomorphize over.
let predicate = |key: usearch::Key| filter(key); let predicate = |key: usearch::Key| filter(key);
let matches = self let matches = self
.inner .with_expansion(ef_search, || {
.filtered_search(query, k, predicate) self.inner.filtered_search(query, k, predicate)
})
.map_err(|e| VectorError::Backend(format!("USearch filtered_search failed: {e}")))?; .map_err(|e| VectorError::Backend(format!("USearch filtered_search failed: {e}")))?;
Ok(matches Ok(matches
@ -304,6 +393,12 @@ impl VectorIndex for UsearchIndex {
.load(path_str) .load(path_str)
.map_err(|e| VectorError::Backend(format!("USearch load failed: {e}")))?; .map_err(|e| VectorError::Backend(format!("USearch load failed: {e}")))?;
// The on-disk index may carry a different `expansion_search` than
// `config`. Force the configured default so the tracked
// `current_expansion` (set by `new` to `config.ef_search`) is the truth.
// Single-threaded at construction — no `expansion_lock` needed.
index.inner.change_expansion_search(config.ef_search);
// Reconstruct total_slots from the loaded index size. // Reconstruct total_slots from the loaded index size.
// After load, all entries are live (no tombstones in a freshly loaded index). // After load, all entries are live (no tombstones in a freshly loaded index).
index index
@ -321,6 +416,11 @@ impl VectorIndex for UsearchIndex {
.view(path_str) .view(path_str)
.map_err(|e| VectorError::Backend(format!("USearch view failed: {e}")))?; .map_err(|e| VectorError::Backend(format!("USearch view failed: {e}")))?;
// Pin the beam width to `config.ef_search` (the C++ config is in-process
// memory, independent of the mmap'd graph) so `current_expansion` is
// truthful even on a read-only view. See `load`.
index.inner.change_expansion_search(config.ef_search);
// After view, all entries are live (no tombstones). // After view, all entries are live (no tombstones).
index index
.total_slots .total_slots
@ -552,6 +652,70 @@ mod tests {
assert!(index.is_empty()); // no side effects assert!(index.is_empty()); // no side effects
} }
/// m12p3: concurrent searches that request DIFFERENT `ef_search` values must
/// each return correct results — the epoch guard must serialize only the
/// beam-width change, never corrupt a neighbour set. We hammer the index from
/// many threads alternating between two `ef` values and assert every result is
/// a valid, in-range, descending-distance neighbour list.
#[test]
fn usearch_concurrent_mixed_ef_search_is_correct() {
use std::sync::Arc;
let dim = 64;
let n = 2_000_u64;
let config = VectorIndexConfig {
dimensions: dim,
metric: DistanceMetric::L2,
quantization: QuantizationLevel::F16,
connectivity: 16,
ef_construction: 200,
ef_search: 100,
};
let index = Arc::new(UsearchIndex::new(config).unwrap());
index.reserve(n as usize).unwrap();
for id in 0..n {
#[allow(clippy::cast_precision_loss)]
let mut v = vec![0.0_f32; dim];
v[(id as usize) % dim] = 1.0;
v[(id as usize / dim) % dim] += 0.5;
index.insert(id, &v).unwrap();
}
let query: Vec<f32> = {
let mut v = vec![0.0_f32; dim];
v[0] = 1.0;
v
};
let mut handles = Vec::new();
for t in 0..8_u64 {
let index = Arc::clone(&index);
let query = query.clone();
handles.push(std::thread::spawn(move || {
for i in 0..200_u64 {
// Alternate beam widths so threads contend on the epoch guard.
let ef = if (t + i) % 2 == 0 { 16 } else { 256 };
let results = index.search(&query, 10, ef).unwrap();
assert!(results.len() <= 10);
// Distances are ascending (closest first) and in the L2 range.
let mut prev = f32::NEG_INFINITY;
for r in &results {
assert!(r.id < n, "id {} out of range", r.id);
assert!(r.distance >= -1e-3, "negative distance {}", r.distance);
assert!(
r.distance + 1e-3 >= prev,
"results not ascending by distance"
);
prev = r.distance;
}
}
}));
}
for h in handles {
h.join().unwrap();
}
}
#[test] #[test]
fn usearch_tombstone_ratio() { fn usearch_tombstone_ratio() {
let config = default_config(4); let config = default_config(4);

View File

@ -0,0 +1,174 @@
#![allow(clippy::unwrap_used, clippy::cast_precision_loss)]
//! m12p1 — pure k-NN recall probe (`TidalDb::vector_search_items`).
//!
//! The recall harness (`tidal-stress --verify-recall`) measures ANN recall@k by
//! comparing the engine's nearest-neighbor result against a brute-force cosine
//! ground truth. This test exercises the engine-side surface that harness hits
//! — the raw k-NN probe — and proves it returns the true nearest neighbors
//! (recall == 1.0) at a corpus size where the slot index is exact brute-force,
//! so any later HNSW recall shortfall is attributable to the index, not the
//! plumbing.
//!
//! # UAT Scenario
//!
//! ```
//! Given: A db with an Item "content" embedding slot and N indexed vectors
//! When: db.vector_search_items(q, 10, None)
//! Then: Returns the 10 items nearest q by cosine, closest-first
//! And: The set equals a brute-force cosine top-10 ground truth (recall 1.0)
//! ```
use std::{collections::HashMap, time::Duration};
use tidaldb::{
TidalDb,
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Window},
};
const DIM: usize = 16;
// Below `USEARCH_MIN_VECTORS` (10k) the slot is an exact BruteForceIndex, so the
// probe's recall against a brute-force ground truth must be exactly 1.0 — this
// isolates the plumbing from HNSW approximation, which the live harness measures.
const N: u64 = 500;
/// `SplitMix64` — a tiny deterministic generator so the corpus is reproducible
/// without a dev-dependency on a seeded RNG. Same scheme the live harness uses.
const fn splitmix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// A deterministic, non-zero vector keyed by entity id (each component in
/// `[-0.5, 0.5)`). The engine L2-normalizes on write, so cosine order over these
/// raw vectors matches the engine's L2-on-normalized order.
fn vector_for(id: u64) -> Vec<f32> {
let mut state = id.wrapping_mul(0x2545_F491_4F6C_DD1D).wrapping_add(1);
(0..DIM)
.map(|_| {
let bits = (splitmix64(&mut state) >> 40) as u32; // 24 random bits
(bits as f32 / f32::from(1u16 << 12) / 4096.0) - 0.5
})
.collect()
}
fn cosine(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if na == 0.0 || nb == 0.0 {
0.0
} else {
dot / (na * nb)
}
}
/// Brute-force cosine top-`k` ground truth over the deterministic corpus.
fn ground_truth(query: &[f32], k: usize) -> Vec<u64> {
let mut scored: Vec<(u64, f32)> = (1..=N)
.map(|id| (id, cosine(query, &vector_for(id))))
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
scored.into_iter().take(k).map(|(id, _)| id).collect()
}
fn build_db() -> TidalDb {
let mut builder = SchemaBuilder::new();
// A schema must declare at least one signal; the probe ignores signals
// entirely, but the engine requires one to open.
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::TwentyFourHours])
.velocity(false)
.add();
builder.embedding_slot("content", EntityKind::Item, DIM);
let schema = builder.build().unwrap();
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema)
.open()
.unwrap();
for id in 1..=N {
// An item must exist for its embedding; metadata is irrelevant to k-NN.
db.write_item_with_metadata(EntityId::new(id), &HashMap::new())
.unwrap();
db.write_item_embedding(EntityId::new(id), &vector_for(id))
.unwrap();
}
db
}
#[test]
fn vector_search_returns_exact_nearest_neighbors() {
let db = build_db();
// Query with item 42's own vector: it must come back first, ~zero distance.
let q = vector_for(42);
let results = db.vector_search_items(&q, 10, None).unwrap();
assert_eq!(results.len(), 10, "k=10 nearest requested");
assert_eq!(
results[0].id, 42,
"an item's own vector is its nearest neighbor"
);
// ~0 modulo F16 quantization (the slot's default), which perturbs a stored
// unit vector by ~1e-3 — far below any other item's distance.
assert!(
results[0].distance <= 0.01,
"self-distance must be ~0, got {}",
results[0].distance
);
// Results are ordered closest-first (ascending L2 distance).
for w in results.windows(2) {
assert!(
w[0].distance <= w[1].distance,
"results must be sorted by ascending distance"
);
}
// Recall@10 vs an INDEPENDENT brute-force cosine ground truth. The exhaustive
// index is exact, but it scores in F16 (and computes L2-on-normalized where the
// oracle computes cosine), so a single item at the k=10 boundary may swap —
// hence `>= 9`, not `== 10`. A real recall miss (HNSW approximation) shows up
// as a much larger shortfall, which the live harness measures at scale.
let truth: std::collections::HashSet<u64> = ground_truth(&q, 10).into_iter().collect();
let got: std::collections::HashSet<u64> = results.iter().map(|r| r.id).collect();
let hits = got.intersection(&truth).count();
assert!(
hits >= 9,
"exact index must achieve recall@10 ~ 1.0, got {hits}/10"
);
}
#[test]
fn vector_search_accepts_ef_search_override() {
let db = build_db();
let q = vector_for(7);
// Both the slot default (None) and an explicit override resolve and return
// the same exact nearest set on a brute-force slot (ef is ignored there).
let a = db.vector_search_items(&q, 5, None).unwrap();
let b = db.vector_search_items(&q, 5, Some(64)).unwrap();
let ids_a: Vec<u64> = a.iter().map(|r| r.id).collect();
let ids_b: Vec<u64> = b.iter().map(|r| r.id).collect();
assert_eq!(ids_a, ids_b);
assert_eq!(ids_a[0], 7);
}
#[test]
fn vector_search_rejects_dimension_mismatch() {
let db = build_db();
let wrong = vec![0.1_f32; DIM + 1];
let err = db.vector_search_items(&wrong, 10, None);
assert!(
err.is_err(),
"a query vector of the wrong dimension must error, not silently return"
);
}

View File

@ -0,0 +1,295 @@
#![allow(
clippy::too_many_lines,
clippy::unwrap_used,
clippy::cast_possible_truncation
)]
//! m12p2 — ANN candidate generation + cached `SignalRanked` in RETRIEVE.
//!
//! These prove the G1 unblock end-to-end through the engine: `for_you` sources
//! its candidates by nearest-neighbour over the user's preference vector (not an
//! arbitrary low-id scan slice), and `trending` sources its candidates from the
//! cached per-signal-type top-K (reaching the actually-viewed items at any id).
//! The distinguishing trick: seed a corpus far larger than the scan cap and
//! place the relevant items at HIGH ids — a scan (capped at the lowest ~240 ids)
//! cannot reach them, so their presence in the feed proves the index path ran.
use std::collections::HashMap;
use tidaldb::{
TidalDb,
query::retrieve::{ProfileRef, RetrieveBuilder},
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window},
};
const DIM: usize = 8;
/// Corpus far larger than the executor's scan cap (~240) so high-id relevant
/// items are unreachable by a scan — their presence proves the index path.
const N: u64 = 2000;
/// A one-hot embedding on axis `i % DIM`: items sharing an axis are identical
/// (distance 0), so a preference vector on axis A makes every axis-A item — at
/// any id — a nearest neighbour.
fn one_hot(axis: usize) -> Vec<f32> {
let mut v = vec![0.0_f32; DIM];
v[axis] = 1.0;
v
}
fn schema_with_embeddings() -> tidaldb::schema::Schema {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: std::time::Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::OneHour, Window::TwentyFourHours])
.velocity(true)
.add();
let _ = builder
.signal(
"like",
EntityKind::Item,
DecaySpec::Exponential {
half_life: std::time::Duration::from_secs(30 * 24 * 3600),
},
)
.windows(&[Window::TwentyFourHours])
.velocity(false)
.positive_engagement(true)
.add();
builder.embedding_slot("content", EntityKind::Item, DIM);
builder.build().unwrap()
}
#[test]
fn for_you_ann_surfaces_nearest_to_preference_across_the_whole_corpus() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema_with_embeddings())
.open()
.unwrap();
let ts = Timestamp::now();
for id in 1..=N {
db.write_item_with_metadata(EntityId::new(id), &HashMap::new())
.unwrap();
db.write_item_embedding(EntityId::new(id), &one_hot((id % DIM as u64) as usize))
.unwrap();
}
// A HIGH-id axis-5 item, made engaging with views. Its id (1997) is far beyond
// the executor's scan pool (limit × 10 = 500 lowest ids), so a scan can NEVER
// surface it — only ANN candidate-gen over the axis-5 preference vector reaches
// it. (1997 % 8 == 5.)
let far = 1997u64;
for _ in 0..50 {
db.signal("view", EntityId::new(far), 1.0, ts).unwrap();
}
// User 7's learned taste is axis 5. Set the preference vector directly (the
// signal-driven path that builds it is covered elsewhere; here we isolate the
// ANN candidate-gen). Axis-5 items are ids 5, 13, 21, …, 1997.
let user = 7u64;
assert!(db.preference_vectors().set(user, one_hot(5)));
let results = db
.retrieve(
&RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("for_you"))
.for_user(user)
.limit(50)
.build()
.unwrap(),
)
.unwrap();
let ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
assert_eq!(ids.len(), 50, "expected a full page");
let axis5 = ids.iter().filter(|id| *id % DIM as u64 == 5).count();
// ANN over the axis-5 preference vector ⇒ the page is dominated by axis-5
// items (for_you injects ~10% exploration, so allow for that). A scan would
// return ~1/8 axis-5 from the low-id slice — nowhere near this.
assert!(
axis5 >= 30,
"expected the page dominated by axis-5 (ANN); got {axis5}/50 — looks like a scan"
);
// The engaging high-id item is reachable ONLY through the ANN candidate pool
// (it sits beyond the scan cap), so its presence proves the ANN index — not the
// universe scan — generated these candidates.
assert!(
ids.contains(&far),
"ANN must reach the engaging high-id nearest neighbour {far} that the scan pool \
(ids 1..=500) cannot; got {ids:?}"
);
}
#[test]
fn for_you_without_preference_vector_falls_back_to_scan() {
// An anonymous-ish read (a user with no preference vector) must still serve —
// the ANN strategy degrades to a scan rather than erroring or returning empty.
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema_with_embeddings())
.open()
.unwrap();
for id in 1..=300u64 {
db.write_item_with_metadata(EntityId::new(id), &HashMap::new())
.unwrap();
db.write_item_embedding(EntityId::new(id), &one_hot((id % DIM as u64) as usize))
.unwrap();
}
let results = db
.retrieve(
&RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("for_you"))
.for_user(999) // no preference vector set
.limit(20)
.build()
.unwrap(),
)
.unwrap();
assert!(
!results.items.is_empty(),
"for_you with no preference vector must fall back to scan, not return empty"
);
}
#[test]
fn related_ann_surfaces_seed_neighbours_across_the_whole_corpus() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema_with_embeddings())
.open()
.unwrap();
let ts = Timestamp::now();
for id in 1..=N {
db.write_item_with_metadata(EntityId::new(id), &HashMap::new())
.unwrap();
db.write_item_embedding(EntityId::new(id), &one_hot((id % DIM as u64) as usize))
.unwrap();
}
// Seed item 3 is axis 3; an engaging high-id axis-3 item (1995 % 8 == 3) is
// reachable only through the seed's ANN neighbour pool, never the scan slice.
let far = 1995u64;
for _ in 0..50 {
db.signal("view", EntityId::new(far), 1.0, ts).unwrap();
}
let seed = EntityId::new(3);
let results = db
.retrieve(
&RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("related"))
.similar_to(seed)
.limit(50)
.build()
.unwrap(),
)
.unwrap();
let ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
assert_eq!(ids.len(), 50, "expected a full page");
// The decisive proof of ANN candidate generation: the seed's high-id
// neighbour (1995, beyond the scan pool of ids 1..=500) is in the page. Only
// the ANN search over the seed embedding can reach it; a scan never could.
// (related ranks the ANN-similar pool by engagement/recency — surfacing
// SIMILAR items by similarity score is a related-scoring follow-up; here we
// assert candidate generation, the m12p2 work item.)
assert!(
ids.contains(&far),
"related ANN must reach the seed's high-id neighbour {far} beyond the scan pool; got {ids:?}"
);
// Axis-3 (the seed's true neighbours) appear well above the ~1/8 a scan slice
// would yield, confirming the pool is the seed's neighbourhood.
let axis3 = ids.iter().filter(|id| *id % DIM as u64 == 3).count();
assert!(
axis3 >= 8,
"related's candidate pool should be the seed's axis-3 neighbourhood; got {axis3}/50"
);
}
#[test]
fn trending_signalranked_reaches_high_id_viewed_items() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema_with_embeddings())
.open()
.unwrap();
let ts = Timestamp::now();
for id in 1..=N {
db.write_item_with_metadata(EntityId::new(id), &HashMap::new())
.unwrap();
}
// Heavy views on three HIGH-id items (unreachable by a low-id scan), light
// views on a few low ids as noise.
let hot = [1500u64, 1700, 1900];
for &id in &hot {
for _ in 0..100 {
db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
}
}
for id in 1..=20u64 {
db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
}
let results = db
.retrieve(
&RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("trending"))
.limit(20)
.build()
.unwrap(),
)
.unwrap();
let ids: std::collections::HashSet<u64> =
results.items.iter().map(|r| r.entity_id.as_u64()).collect();
for &id in &hot {
assert!(
ids.contains(&id),
"trending must surface heavily-viewed high-id item {id} via SignalRanked; \
a scan (low ~240 ids) could never reach it. got {ids:?}"
);
}
}
#[test]
fn trending_cache_reflects_new_writes() {
// The SignalRanked top-K cache must not serve stale results across writes on a
// small ledger (always-fresh policy): a newly-hot item appears immediately.
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema_with_embeddings())
.open()
.unwrap();
let ts = Timestamp::now();
for id in 1..=100u64 {
db.write_item_with_metadata(EntityId::new(id), &HashMap::new())
.unwrap();
}
for _ in 0..50 {
db.signal("view", EntityId::new(10), 1.0, ts).unwrap();
}
let q = || {
db.retrieve(
&RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("trending"))
.limit(10)
.build()
.unwrap(),
)
.unwrap()
};
let first: std::collections::HashSet<u64> =
q().items.iter().map(|r| r.entity_id.as_u64()).collect();
assert!(
first.contains(&10),
"item 10 should be trending after its views"
);
// New burst on a different item AFTER the first query (which warmed the cache).
for _ in 0..200 {
db.signal("view", EntityId::new(90), 1.0, ts).unwrap();
}
let second: std::collections::HashSet<u64> =
q().items.iter().map(|r| r.entity_id.as_u64()).collect();
assert!(
second.contains(&90),
"the cache must reflect the new burst on item 90 (always-fresh on a small ledger); got {second:?}"
);
}

View File

@ -408,3 +408,99 @@ fn usearch_recall_at_10k() {
eprintln!("10K vectors recall@{k}: {avg_recall:.3}"); eprintln!("10K vectors recall@{k}: {avg_recall:.3}");
} }
/// m12p3: the per-query `ef_search` override must be HONORED, not silently
/// dropped to the index default (the pre-m12p3 behaviour, which only logged a
/// warning).
///
/// The index is built with a deliberately starved default beam (`ef_search=8`),
/// so if the override were ignored every search would use that starved beam and
/// a wide per-query beam would change nothing. We run the same query set twice —
/// once at the starved default, once at a wide `ef_search=512` — and assert the
/// wide beam recovers strictly more true neighbours. Summed over many queries so
/// the verdict does not hinge on a single lucky/unlucky query.
#[test]
fn usearch_per_query_ef_search_is_honored() {
let dim = 128;
let n = 10_000u64;
let k: usize = 10;
let num_queries: usize = 30;
// Starved construction default: if the override is ignored, *every* search
// runs at this beam and the wide pass below cannot possibly do better.
let config = VectorIndexConfig {
dimensions: dim,
metric: DistanceMetric::L2,
quantization: QuantizationLevel::F16,
connectivity: 16,
ef_construction: 200,
ef_search: 8,
};
let usearch = UsearchIndex::new(config).unwrap();
let brute = BruteForceIndex::new(f32_config(dim));
usearch.reserve(n as usize).unwrap();
let mut rng = rand::rng();
for id in 0..n {
let v = random_unit_vector(dim, &mut rng);
usearch.insert(id, &v).unwrap();
brute.insert(id, &v).unwrap();
}
// Use a fixed query set so the narrow and wide passes are compared on the
// SAME queries (the only thing that differs is the requested ef_search).
let queries: Vec<Vec<f32>> = (0..num_queries)
.map(|_| random_unit_vector(dim, &mut rng))
.collect();
let mut narrow_hits = 0usize;
let mut wide_hits = 0usize;
for q in &queries {
let truth: Vec<u64> = brute
.search(q, k, 0)
.unwrap()
.iter()
.map(|r| r.id)
.collect();
let narrow: Vec<u64> = usearch
.search(q, k, 8) // starved beam (== construction default)
.unwrap()
.iter()
.map(|r| r.id)
.collect();
let wide: Vec<u64> = usearch
.search(q, k, 512) // wide per-query override
.unwrap()
.iter()
.map(|r| r.id)
.collect();
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
let scale = |frac: f64| -> usize { (frac * k as f64).round() as usize };
narrow_hits += scale(recall_at_k(&truth, &narrow));
wide_hits += scale(recall_at_k(&truth, &wide));
}
assert!(
wide_hits > narrow_hits,
"per-query ef_search appears ignored: ef=512 recovered {wide_hits} hits, \
ef=8 recovered {narrow_hits} (a wider beam must recover strictly more)"
);
// The wide beam should also reach near-exact recall — sanity that 512 is
// actually exploring, not just marginally above the starved default.
#[allow(clippy::cast_precision_loss)]
let wide_recall = wide_hits as f64 / (num_queries as f64 * k as f64);
assert!(
wide_recall > 0.95,
"ef=512 recall@{k} = {wide_recall:.3}, expected > 0.95"
);
eprintln!(
"per-query ef_search honored: ef=8 hits={narrow_hits}, ef=512 hits={wide_hits} \
(wide recall@{k}={wide_recall:.3})"
);
}