use crate::config::{HnswParams, KnnMethod};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerfRecord {
pub method: String,
pub n: usize,
pub d: usize,
pub k: usize,
pub median_secs: f64,
pub throughput_elem_per_s: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub machine: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub captured_at: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct RecommendOpts {
pub exact_ok_factor: Option<f64>,
pub prefer_usearch: bool,
pub k: Option<usize>,
pub allow_gpu: bool,
}
const SHIPPED_MATRIX_JSONL: &str = include_str!("../data/knn_perf_matrix.jsonl");
pub fn builtin_matrix() -> Vec<PerfRecord> {
parse_matrix_jsonl(SHIPPED_MATRIX_JSONL).unwrap_or_else(|_| {
vec![
rec("hnsw_usearch", 50_000, 10, 60, 0.803),
rec("hnsw_ann_search", 50_000, 10, 60, 0.529),
rec("exact", 50_000, 10, 60, 1.265),
rec("hnsw_usearch", 100_000, 15, 60, 1.817),
rec("hnsw_ann_search", 100_000, 15, 60, 1.626),
rec("hnsw_usearch", 250_000, 20, 60, 9.254),
rec("hnsw_ann_search", 250_000, 20, 60, 5.757),
rec("hnsw_usearch", 500_000, 20, 60, 20.967),
rec("hnsw_ann_search", 500_000, 20, 60, 13.467),
]
})
}
fn rec(method: &str, n: usize, d: usize, k: usize, median_secs: f64) -> PerfRecord {
PerfRecord {
method: method.to_string(),
n,
d,
k,
median_secs,
throughput_elem_per_s: n as f64 / median_secs,
machine: Some("local-criterion".into()),
captured_at: Some("2026-07-23".into()),
}
}
pub fn parse_matrix_jsonl(text: &str) -> Result<Vec<PerfRecord>, String> {
let mut out = Vec::new();
for (i, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let rec: PerfRecord = serde_json::from_str(line)
.map_err(|e| format!("jsonl line {}: {e}", i + 1))?;
out.push(rec);
}
Ok(out)
}
pub fn load_matrix(path: Option<&std::path::Path>) -> Vec<PerfRecord> {
if let Some(p) = path
&& let Ok(text) = std::fs::read_to_string(p)
&& let Ok(recs) = parse_matrix_jsonl(&text)
&& !recs.is_empty()
{
return recs;
}
builtin_matrix()
}
pub fn recommend_method(n: usize, d: usize, opts: &RecommendOpts) -> KnnMethod {
recommend_method_with_matrix(n, d, opts, &load_matrix(None))
}
pub fn recommend_method_with_matrix(
n: usize,
d: usize,
opts: &RecommendOpts,
matrix: &[PerfRecord],
) -> KnnMethod {
let factor = opts.exact_ok_factor.unwrap_or(1.25);
if n <= 5_000 {
return KnnMethod::Exact;
}
let candidates = available_method_ids(opts.prefer_usearch, opts.allow_gpu, n);
let scored: Vec<(&str, f64)> = candidates
.iter()
.filter_map(|&method| estimate_secs(method, n, d, matrix).map(|secs| (method, secs)))
.collect();
if scored.is_empty() {
return heuristic_fallback(n, opts.prefer_usearch);
}
let best = scored
.iter()
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.copied();
if let Some((best_id, best_secs)) = best {
if best_id != "exact"
&& let Some((_, exact_secs)) = scored.iter().find(|(id, _)| *id == "exact")
&& *exact_secs <= best_secs * factor
{
return KnnMethod::Exact;
}
return method_from_id(best_id);
}
heuristic_fallback(n, opts.prefer_usearch)
}
fn available_method_ids(prefer_usearch: bool, allow_gpu: bool, n: usize) -> Vec<&'static str> {
let mut ids = Vec::new();
if n <= 80_000 {
ids.push("exact");
}
#[cfg(feature = "hnsw")]
ids.push("hnsw_usearch");
#[cfg(feature = "ann-search")]
if !prefer_usearch {
ids.push("hnsw_ann_search");
}
#[cfg(feature = "gpu")]
if allow_gpu && crate::gpu_adapter_available() {
if n <= 50_000 {
ids.push("exact_gpu");
}
ids.push("ivf_gpu");
if n >= 50_000 {
ids.push("nndescent_gpu");
}
}
#[cfg(not(feature = "gpu"))]
let _ = allow_gpu;
ids
}
fn method_from_id(id: &str) -> KnnMethod {
match id {
"exact" => KnnMethod::Exact,
#[cfg(feature = "hnsw")]
"hnsw_usearch" => KnnMethod::Hnsw(HnswParams::default()),
#[cfg(feature = "ann-search")]
"hnsw_ann_search" => KnnMethod::AnnSearchHnsw(HnswParams::default()),
#[cfg(feature = "gpu")]
"exact_gpu" => KnnMethod::GpuExact,
#[cfg(feature = "gpu")]
"ivf_gpu" => KnnMethod::GpuIvf(crate::IvfGpuParams::default()),
#[cfg(feature = "gpu")]
"nndescent_gpu" => KnnMethod::GpuNnDescent(crate::NnDescentGpuParams::default()),
_ => heuristic_fallback(100_000, false),
}
}
fn heuristic_fallback(n: usize, prefer_usearch: bool) -> KnnMethod {
if n <= 5_000 {
return KnnMethod::Exact;
}
#[cfg(feature = "ann-search")]
if !prefer_usearch {
return KnnMethod::AnnSearchHnsw(HnswParams::default());
}
#[cfg(feature = "hnsw")]
{
return KnnMethod::Hnsw(HnswParams::default());
}
#[allow(unreachable_code)]
KnnMethod::Exact
}
fn estimate_secs(method: &str, n: usize, d: usize, matrix: &[PerfRecord]) -> Option<f64> {
let mut num = 0.0;
let mut den = 0.0;
for r in matrix.iter().filter(|r| r.method == method) {
let dn = (n as f64).ln() - (r.n as f64).ln();
let dd = (d as f64).ln() - (r.d as f64).ln();
let dist = (dn * dn + dd * dd).sqrt();
let w = if dist < 1e-9 {
return Some(r.median_secs * (n as f64 / r.n as f64));
} else {
1.0 / (dist * dist)
};
let scaled = r.median_secs * (n as f64 / r.n as f64) * ((d as f64 / r.d as f64).sqrt());
num += w * scaled;
den += w;
}
if den > 0.0 {
Some(num / den)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn small_n_picks_exact() {
let m = recommend_method(1_000, 10, &RecommendOpts::default());
assert!(matches!(m, KnnMethod::Exact));
}
#[test]
fn large_n_prefers_ann_when_available() {
let m = recommend_method(250_000, 20, &RecommendOpts::default());
#[cfg(feature = "ann-search")]
assert!(matches!(m, KnnMethod::AnnSearchHnsw(_)));
#[cfg(all(not(feature = "ann-search"), feature = "hnsw"))]
assert!(matches!(m, KnnMethod::Hnsw(_)));
}
#[test]
fn prefer_usearch_honored() {
let opts = RecommendOpts {
prefer_usearch: true,
..Default::default()
};
let m = recommend_method(250_000, 20, &opts);
#[cfg(feature = "hnsw")]
assert!(matches!(m, KnnMethod::Hnsw(_)));
}
#[test]
fn shipped_matrix_parses() {
let recs = builtin_matrix();
assert!(recs.len() >= 5);
assert!(recs.iter().any(|r| r.method == "hnsw_ann_search"));
}
#[test]
fn jsonl_roundtrip() {
let text = concat!(
r#"{"method":"exact","n":1000,"d":10,"k":10,"median_secs":0.01,"throughput_elem_per_s":100000}"#,
"\n"
);
let recs = parse_matrix_jsonl(text).unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].method, "exact");
}
}