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.
465 lines
19 KiB
Rust
465 lines
19 KiB
Rust
//! 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 = ¢ers[(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‖q−c‖² = 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(¢ers, 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
|
||
);
|
||
}
|
||
}
|