vecscope 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vecscope/evidence.py ADDED
@@ -0,0 +1,337 @@
1
+ """Evidence model: deterministic diagnostics with verdicts an agent can act on."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass, field
7
+
8
+ import numpy as np
9
+
10
+ from vecscope._knn import exact_knn
11
+ from vecscope.diagnostics import (
12
+ anisotropy,
13
+ ann_recall,
14
+ cluster_report,
15
+ collapse,
16
+ connectivity,
17
+ distance_concentration,
18
+ hubness,
19
+ metric_mismatch,
20
+ norms,
21
+ outliers,
22
+ query_audit,
23
+ rogue_dimensions,
24
+ )
25
+
26
+ # Verdict thresholds. Conservative, documented, overridable.
27
+ THRESHOLDS = {
28
+ "hubness.skewness": (2.0, 4.0), # real 1k-1.5k-dim text models sit at 1.1-1.6
29
+ "hubness.antihub_ratio": (0.05, 0.20),
30
+ "anisotropy.mean_random_cosine": (0.3, 0.6), # ada-002 ~0.69, 3-large ~0.09
31
+ "anisotropy.effective_rank_ratio": (0.10, 0.05), # lower is worse; real models 0.09-0.18
32
+ "collapse.near_duplicate_ratio": (0.02, 0.10),
33
+ "ann_recall.recall_at_k": (0.95, 0.85), # lower is worse
34
+ "rogue_dimensions.top1_dim_variance_share": (0.05, 0.20),
35
+ "norms.norm_cv": (0.10, 0.30),
36
+ "metric_mismatch.topk_overlap": (0.90, 0.70), # lower is worse
37
+ "distance_concentration.relative_contrast": (1.2, 1.05), # lower is worse; real 1.9-3.0, 256-d noise 1.28
38
+ "outliers.outlier_ratio": (0.01, 0.05),
39
+ "connectivity.island_ratio": (0.01, 0.10),
40
+ "queries.ood_query_ratio": (0.05, 0.20),
41
+ "queries.query_hubness_skewness": (2.0, 4.0),
42
+ }
43
+
44
+
45
+ @dataclass
46
+ class Finding:
47
+ name: str
48
+ metric: str
49
+ value: float
50
+ verdict: str # ok | warn | fail
51
+ explanation: str
52
+ details: dict = field(default_factory=dict)
53
+ warn_at: float | None = None
54
+ fail_at: float | None = None
55
+ lower_is_worse: bool = False
56
+ healthy_range: str = ""
57
+
58
+
59
+ @dataclass
60
+ class Evidence:
61
+ tool: str
62
+ version: str
63
+ n: int
64
+ dim: int
65
+ k: int
66
+ findings: list[Finding]
67
+ n_total: int = 0
68
+ sampled: bool = False
69
+ n_queries: int = 0
70
+ clusters: list[dict] = field(default_factory=list)
71
+
72
+ @property
73
+ def verdict(self) -> str:
74
+ order = {"ok": 0, "warn": 1, "fail": 2}
75
+ return max((f.verdict for f in self.findings), key=order.get, default="ok")
76
+
77
+ def to_dict(self) -> dict:
78
+ d = asdict(self)
79
+ d["verdict"] = self.verdict
80
+ return d
81
+
82
+ def to_json(self, indent: int | None = 2) -> str:
83
+ return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
84
+
85
+
86
+ def _verdict(key: str, value: float) -> str:
87
+ warn, fail = THRESHOLDS[key]
88
+ if warn > fail: # lower is worse
89
+ return "fail" if value < fail else "warn" if value < warn else "ok"
90
+ return "fail" if value >= fail else "warn" if value >= warn else "ok"
91
+
92
+
93
+ _FINDING_KEYS = {
94
+ "hubness": "hubness.skewness",
95
+ "antihubs": "hubness.antihub_ratio",
96
+ "anisotropy": "anisotropy.mean_random_cosine",
97
+ "effective_rank": "anisotropy.effective_rank_ratio",
98
+ "collapse": "collapse.near_duplicate_ratio",
99
+ "ann_recall": "ann_recall.recall_at_k",
100
+ "rogue_dimensions": "rogue_dimensions.top1_dim_variance_share",
101
+ "norms": "norms.norm_cv",
102
+ "metric_mismatch": "metric_mismatch.topk_overlap",
103
+ "distance_concentration": "distance_concentration.relative_contrast",
104
+ "outliers": "outliers.outlier_ratio",
105
+ "connectivity": "connectivity.island_ratio",
106
+ "ood_queries": "queries.ood_query_ratio",
107
+ "query_hubness": "queries.query_hubness_skewness",
108
+ }
109
+
110
+
111
+ def _attach_thresholds(findings: list[Finding]) -> list[Finding]:
112
+ for f in findings:
113
+ warn, fail = THRESHOLDS[_FINDING_KEYS[f.name]]
114
+ f.warn_at, f.fail_at, f.lower_is_worse = warn, fail, warn > fail
115
+ if f.lower_is_worse:
116
+ f.healthy_range = f"ok >= {warn:g}, warn < {warn:g}, fail < {fail:g}"
117
+ else:
118
+ f.healthy_range = f"ok < {warn:g}, warn >= {warn:g}, fail >= {fail:g}"
119
+ return findings
120
+
121
+
122
+ def audit(
123
+ x: np.ndarray,
124
+ k: int = 10,
125
+ ann_neighbors: np.ndarray | None = None,
126
+ near_dup_cos: float = 0.99,
127
+ sample: int | None = 100_000,
128
+ seed: int = 0,
129
+ queries: np.ndarray | None = None,
130
+ clusters: int = 0,
131
+ ) -> Evidence:
132
+ """Audit an embedding matrix and return deterministic Evidence.
133
+
134
+ Exact k-NN is O(n^2); when ``sample`` is set and ``n > sample``, a seeded random
135
+ subset of rows is audited instead (``ann_neighbors`` is then ignored because its
136
+ row ids no longer line up). Pass ``sample=None`` to audit every row.
137
+
138
+ ``queries`` adds query-side findings (out-of-distribution queries, query hubness).
139
+ ``clusters`` > 0 attaches a per-cluster report with sample ids (no verdicts).
140
+ """
141
+ from vecscope import __version__
142
+
143
+ x = np.asarray(x, dtype=np.float32)
144
+ if x.ndim != 2 or x.shape[0] < 3:
145
+ raise ValueError("expected a 2-D array with at least 3 rows")
146
+ if not np.isfinite(x).all():
147
+ raise ValueError("embeddings contain NaN or Inf")
148
+ n_total = int(x.shape[0])
149
+ sampled = sample is not None and n_total > sample
150
+ if sampled:
151
+ rows = np.sort(np.random.default_rng(seed).choice(n_total, sample, replace=False))
152
+ x = x[rows]
153
+ ann_neighbors = None
154
+ if k >= x.shape[0]:
155
+ raise ValueError(f"k={k} must be smaller than the number of rows ({x.shape[0]})")
156
+ knn_idx, knn_dist = exact_knn(x, k)
157
+ findings: list[Finding] = []
158
+
159
+ h = hubness(x, k, knn=knn_idx)
160
+ findings.append(
161
+ Finding(
162
+ "hubness", "skewness", h["skewness"], _verdict("hubness.skewness", h["skewness"]),
163
+ f"k-occurrence skewness {h['skewness']:.2f}; top 1% of items take "
164
+ f"{h['hub_share_top1pct']:.1%} of all neighbor slots (max N_k={h['max_k_occurrence']}, "
165
+ f"expected {k}).",
166
+ h,
167
+ )
168
+ )
169
+ findings.append(
170
+ Finding(
171
+ "antihubs", "antihub_ratio", h["antihub_ratio"],
172
+ _verdict("hubness.antihub_ratio", h["antihub_ratio"]),
173
+ f"{h['antihub_ratio']:.1%} of items are never anyone's neighbor; they are "
174
+ "unreachable through k-NN retrieval.",
175
+ {},
176
+ )
177
+ )
178
+
179
+ a = anisotropy(x)
180
+ findings.append(
181
+ Finding(
182
+ "anisotropy", "mean_random_cosine", a["mean_random_cosine"],
183
+ _verdict("anisotropy.mean_random_cosine", a["mean_random_cosine"]),
184
+ f"random pairs have mean cosine {a['mean_random_cosine']:.3f} (isotropic ~0); "
185
+ f"mean unit vector has norm {a['mean_vector_norm']:.2f}; centering would bring the "
186
+ f"cosine to {a['mean_random_cosine_after_centering']:.3f}.",
187
+ a,
188
+ )
189
+ )
190
+ findings.append(
191
+ Finding(
192
+ "effective_rank", "effective_rank_ratio", a["effective_rank_ratio"],
193
+ _verdict("anisotropy.effective_rank_ratio", a["effective_rank_ratio"]),
194
+ f"effective rank {a['effective_rank']:.1f} of {a['dim']} dims "
195
+ f"({a['effective_rank_ratio']:.1%}); low values mean most dimensions carry nothing.",
196
+ {},
197
+ )
198
+ )
199
+
200
+ c = collapse(x, near_dup_cos, knn_dist=knn_dist)
201
+ findings.append(
202
+ Finding(
203
+ "collapse", "near_duplicate_ratio", c["near_duplicate_ratio"],
204
+ _verdict("collapse.near_duplicate_ratio", c["near_duplicate_ratio"]),
205
+ f"{c['near_duplicate_ratio']:.1%} of items have a neighbor with cosine >= {near_dup_cos} "
206
+ f"(exact duplicates {c['exact_duplicate_ratio']:.1%}).",
207
+ c,
208
+ )
209
+ )
210
+
211
+ g = rogue_dimensions(x)
212
+ findings.append(
213
+ Finding(
214
+ "rogue_dimensions", "top1_dim_variance_share", g["top1_dim_variance_share"],
215
+ _verdict("rogue_dimensions.top1_dim_variance_share", g["top1_dim_variance_share"]),
216
+ f"dimension {g['rogue_dims'][0]} alone holds {g['top1_dim_variance_share']:.1%} of "
217
+ f"variance (uniform would be {g['expected_share']:.2%}); top 3 dims hold "
218
+ f"{g['top3_dim_variance_share']:.1%}. Rogue dimensions dominate cosine.",
219
+ g,
220
+ )
221
+ )
222
+
223
+ nm = norms(x)
224
+ findings.append(
225
+ Finding(
226
+ "norms", "norm_cv", nm["norm_cv"], _verdict("norms.norm_cv", nm["norm_cv"]),
227
+ f"vector norms vary with CV {nm['norm_cv']:.3f} (median {nm['norm_median']:.3f}, "
228
+ f"range {nm['norm_min']:.3f}-{nm['norm_max']:.3f}); {nm['zero_norm_ratio']:.2%} are "
229
+ "zero vectors. Unequal norms bias inner-product search toward long vectors.",
230
+ nm,
231
+ )
232
+ )
233
+
234
+ if nm["norm_cv"] < 1e-3:
235
+ mm = {"k": int(k), "topk_overlap": 1.0, "queries_fully_agreeing_ratio": 1.0,
236
+ "min_overlap": 1.0, "skipped": "norms are uniform, cosine == inner product"}
237
+ else:
238
+ dot_idx, _ = exact_knn(x, k, metric="dot")
239
+ mm = metric_mismatch(knn_idx, dot_idx)
240
+ findings.append(
241
+ Finding(
242
+ "metric_mismatch", "topk_overlap", mm["topk_overlap"],
243
+ _verdict("metric_mismatch.topk_overlap", mm["topk_overlap"]),
244
+ f"cosine and inner-product top-{k} share {mm['topk_overlap']:.1%} of neighbors; "
245
+ f"{mm['queries_fully_agreeing_ratio']:.1%} of items get identical lists. "
246
+ "Low overlap means the index metric changes the results.",
247
+ mm,
248
+ )
249
+ )
250
+
251
+ dc = distance_concentration(knn_dist[:, 0], a["mean_random_cosine"])
252
+ findings.append(
253
+ Finding(
254
+ "distance_concentration", "relative_contrast", dc["relative_contrast"],
255
+ _verdict("distance_concentration.relative_contrast", dc["relative_contrast"]),
256
+ f"a random item is only {dc['relative_contrast']:.2f}x farther than the nearest "
257
+ f"neighbor (median NN distance {dc['median_nn_distance']:.3f}, random "
258
+ f"{dc['mean_random_distance']:.3f}). Near 1.0 means distances carry no signal.",
259
+ dc,
260
+ )
261
+ )
262
+
263
+ o = outliers(knn_dist[:, 0])
264
+ findings.append(
265
+ Finding(
266
+ "outliers", "outlier_ratio", o["outlier_ratio"],
267
+ _verdict("outliers.outlier_ratio", o["outlier_ratio"]),
268
+ f"{o['outlier_ratio']:.2%} of items have no neighbor within cosine distance "
269
+ f"{o['cutoff_nn_distance']:.3f} (median {o['median_nn_distance']:.3f}); see "
270
+ "details.sample_ids. Typically junk, empty or foreign-language content.",
271
+ o,
272
+ )
273
+ )
274
+
275
+ cn = connectivity(knn_idx)
276
+ findings.append(
277
+ Finding(
278
+ "connectivity", "island_ratio", cn["island_ratio"],
279
+ _verdict("connectivity.island_ratio", cn["island_ratio"]),
280
+ f"{cn['island_ratio']:.2%} of items sit in {cn['n_islands']} small island(s) of the "
281
+ f"k-NN graph (fewer than {cn['min_island_size']} items; {cn['n_components']} "
282
+ "components in total). Graph-based ANN (HNSW) attaches islands to the wrong region "
283
+ "or never reaches them.",
284
+ cn,
285
+ )
286
+ )
287
+
288
+ n_queries = 0
289
+ if queries is not None:
290
+ qv = np.asarray(queries, dtype=np.float32)
291
+ if qv.ndim != 2 or qv.shape[1] != x.shape[1]:
292
+ raise ValueError(f"queries must be (m, {x.shape[1]}) to match the corpus")
293
+ if not np.isfinite(qv).all():
294
+ raise ValueError("queries contain NaN or Inf")
295
+ n_queries = int(qv.shape[0])
296
+ qa = query_audit(x, qv, k, knn_dist[:, 0])
297
+ findings.append(
298
+ Finding(
299
+ "ood_queries", "ood_query_ratio", qa["ood_query_ratio"],
300
+ _verdict("queries.ood_query_ratio", qa["ood_query_ratio"]),
301
+ f"{qa['ood_query_ratio']:.1%} of {qa['n_queries']} queries are farther from "
302
+ f"their nearest corpus item (median {qa['median_query_nn_distance']:.3f}) than "
303
+ f"99% of corpus items are from theirs (cutoff {qa['ood_cutoff_distance']:.3f}). "
304
+ "Out-of-distribution queries: wrong model, missing query prefix, or uncovered "
305
+ "topics; see details.ood_sample_ids.",
306
+ qa,
307
+ )
308
+ )
309
+ findings.append(
310
+ Finding(
311
+ "query_hubness", "query_hubness_skewness", qa["query_hubness_skewness"],
312
+ _verdict("queries.query_hubness_skewness", qa["query_hubness_skewness"]),
313
+ f"k-occurrence skewness over query results {qa['query_hubness_skewness']:.2f} "
314
+ f"(max {qa['max_query_k_occurrence']} appearances); queries reach "
315
+ f"{qa['coverage']:.1%} of the corpus in their top-{qa['k']}.",
316
+ {},
317
+ )
318
+ )
319
+
320
+ if ann_neighbors is not None:
321
+ r = ann_recall(knn_idx, np.asarray(ann_neighbors))
322
+ findings.append(
323
+ Finding(
324
+ "ann_recall", "recall_at_k", r["recall_at_k"],
325
+ _verdict("ann_recall.recall_at_k", r["recall_at_k"]),
326
+ f"ANN recall@{r['k']} = {r['recall_at_k']:.3f}; "
327
+ f"{r['queries_below_floor_ratio']:.1%} of queries fall below {r['floor']:.0%}.",
328
+ r,
329
+ )
330
+ )
331
+
332
+ report = cluster_report(x, clusters, knn_idx, knn_dist, near_dup_cos, seed) if clusters else []
333
+ return Evidence(
334
+ "vecscope", __version__, int(x.shape[0]), int(x.shape[1]), int(k),
335
+ _attach_thresholds(findings),
336
+ n_total=n_total, sampled=sampled, n_queries=n_queries, clusters=report,
337
+ )
vecscope/io.py ADDED
@@ -0,0 +1,58 @@
1
+ """Load embeddings from common on-disk formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+
11
+ def _first_vector_key(row: dict) -> str:
12
+ for key, value in row.items():
13
+ if isinstance(value, list):
14
+ return key
15
+ raise ValueError("no list-valued field found in JSON rows")
16
+
17
+
18
+ def load_vectors(path: str | Path) -> np.ndarray:
19
+ """Return a 2-D float array with one vector per row.
20
+
21
+ Supported: .npy, .npz (first array), .parquet/.pq (column containing "emb" or "vec"),
22
+ .jsonl (one object per line), .json (array of objects or array of lists), .csv/.tsv/.txt.
23
+ """
24
+ p = Path(path)
25
+ if not p.exists():
26
+ raise FileNotFoundError(f"embedding file not found: {p}")
27
+ suf = p.suffix.lower()
28
+ if suf == ".npy":
29
+ return np.load(p)
30
+ if suf == ".npz":
31
+ z = np.load(p)
32
+ return z[z.files[0]]
33
+ if suf in {".parquet", ".pq"}:
34
+ try:
35
+ import pyarrow.parquet as pq
36
+ except ImportError as e: # pragma: no cover
37
+ raise ImportError("reading parquet needs pyarrow: pip install pyarrow") from e
38
+ t = pq.read_table(p)
39
+ cols = [c for c in t.column_names if "emb" in c.lower() or "vec" in c.lower()]
40
+ if not cols:
41
+ raise ValueError(f"no column containing 'emb' or 'vec' in {t.column_names}")
42
+ return np.stack(t.column(cols[0]).to_pylist()).astype(np.float32)
43
+ if suf == ".jsonl":
44
+ text = p.read_text(encoding="utf-8")
45
+ rows = [json.loads(line) for line in text.splitlines() if line.strip()]
46
+ key = _first_vector_key(rows[0])
47
+ return np.asarray([r[key] for r in rows], dtype=np.float32)
48
+ if suf == ".json":
49
+ rows = json.loads(p.read_text(encoding="utf-8"))
50
+ if not isinstance(rows, list) or not rows:
51
+ raise ValueError("expected a non-empty JSON array")
52
+ if isinstance(rows[0], dict):
53
+ key = _first_vector_key(rows[0])
54
+ rows = [r[key] for r in rows]
55
+ return np.asarray(rows, dtype=np.float32)
56
+ if suf in {".csv", ".tsv", ".txt"}:
57
+ return np.loadtxt(p, delimiter="," if suf == ".csv" else None, dtype=np.float32)
58
+ raise ValueError(f"unsupported embedding file: {p} (use .npy/.npz/.parquet/.jsonl/.json/.csv)")
vecscope/mcp_server.py ADDED
@@ -0,0 +1,64 @@
1
+ """MCP server exposing vecscope as a tool. Run: `python -m vecscope.mcp_server`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from vecscope.evidence import audit
8
+ from vecscope.io import load_vectors
9
+
10
+ mcp = FastMCP("vecscope")
11
+
12
+
13
+ @mcp.tool()
14
+ def audit_embeddings(
15
+ path: str,
16
+ k: int = 10,
17
+ ann_path: str | None = None,
18
+ queries_path: str | None = None,
19
+ clusters: int = 0,
20
+ near_dup_cos: float = 0.99,
21
+ ) -> dict:
22
+ """Audit an embedding file: hubness, anisotropy, collapse, rogue dimensions, norms,
23
+ metric mismatch, distance concentration, outliers, k-NN graph islands, and optionally
24
+ ANN recall and query-side findings.
25
+
26
+ `path` is a local .npy/.npz/.parquet/.jsonl/.json/.csv with one vector per row.
27
+ `ann_path` is an optional (n x k) integer array of neighbor ids from an ANN index.
28
+ `queries_path` is an optional (m x dim) file of query embeddings.
29
+ `clusters` > 0 attaches a per-cluster report with sample ids.
30
+ Returns deterministic evidence with ok/warn/fail verdicts, thresholds and explanations.
31
+ """
32
+ x = load_vectors(path)
33
+ ann = load_vectors(ann_path).astype(int) if ann_path else None
34
+ q = load_vectors(queries_path) if queries_path else None
35
+ return audit(x, k=k, ann_neighbors=ann, near_dup_cos=near_dup_cos, queries=q,
36
+ clusters=clusters).to_dict()
37
+
38
+
39
+ @mcp.tool()
40
+ def sweep_index(
41
+ path: str,
42
+ k: int = 10,
43
+ queries_path: str | None = None,
44
+ target_recall: float = 0.95,
45
+ device: str = "auto",
46
+ ) -> dict:
47
+ """Sweep HNSW (M, ef_search) and IVF (nprobe) parameters on an embedding file and report
48
+ recall@k against exact k-NN, build time and queries per second for each setting, plus
49
+ the fastest configuration that reaches `target_recall`. Needs the faiss extra.
50
+ `device` is "auto" (GPU when the faiss build has one), "cpu" or "gpu"; HNSW is CPU-only.
51
+ """
52
+ from vecscope.sweep import sweep
53
+
54
+ x = load_vectors(path)
55
+ q = load_vectors(queries_path) if queries_path else None
56
+ return sweep(x, k=k, queries=q, target_recall=target_recall, device=device).to_dict()
57
+
58
+
59
+ def main() -> None:
60
+ mcp.run()
61
+
62
+
63
+ if __name__ == "__main__":
64
+ main()
vecscope/sweep.py ADDED
@@ -0,0 +1,207 @@
1
+ """Index-side evidence: how do HNSW and IVF parameters trade recall for speed on this corpus?
2
+
3
+ Requires the optional `faiss-cpu` dependency (`pip install "vecscope[index]"`), or a GPU
4
+ build of faiss (`faiss-gpu-cu12`), in which case the IVF index and the exact baseline run on
5
+ the GPU automatically. HNSW has no GPU implementation in faiss and always runs on the CPU.
6
+ Recall is measured against exact k-NN for a seeded sample of queries drawn from the corpus
7
+ (or a supplied query file). Timings are wall-clock on this machine and only meaningful
8
+ relative to each other.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ from dataclasses import asdict, dataclass, field
15
+
16
+ import numpy as np
17
+
18
+ from vecscope._knn import normalize
19
+ from vecscope.diagnostics.queries import query_knn
20
+
21
+ HNSW_GRID = {"M": (8, 16, 32), "ef_construction": (100,), "ef_search": (16, 32, 64, 128, 256)}
22
+ IVF_NPROBE = (1, 2, 4, 8, 16, 32, 64)
23
+
24
+
25
+ @dataclass
26
+ class SweepPoint:
27
+ index: str
28
+ params: dict
29
+ recall_at_k: float
30
+ build_seconds: float
31
+ queries_per_second: float
32
+ device: str = "cpu"
33
+
34
+
35
+ @dataclass
36
+ class SweepResult:
37
+ tool: str
38
+ version: str
39
+ n: int
40
+ dim: int
41
+ k: int
42
+ n_queries: int
43
+ target_recall: float
44
+ points: list[SweepPoint]
45
+ recommendation: dict = field(default_factory=dict)
46
+ device: str = "cpu"
47
+ gpu_name: str | None = None
48
+
49
+ def to_dict(self) -> dict:
50
+ return asdict(self)
51
+
52
+
53
+ def gpu_available(faiss) -> bool:
54
+ """True when this faiss build has GPU support and at least one GPU is visible."""
55
+ needed = ("StandardGpuResources", "GpuParameterSpace", "index_cpu_to_gpu", "get_num_gpus")
56
+ if not all(hasattr(faiss, name) for name in needed):
57
+ return False # CPU wheels (notably on macOS) ship partial GPU stubs
58
+ try:
59
+ if faiss.get_num_gpus() <= 0:
60
+ return False
61
+ faiss.StandardGpuResources() # fails on a stub build or a broken driver
62
+ except (RuntimeError, AttributeError, TypeError):
63
+ return False
64
+ return True
65
+
66
+
67
+ def _resolve_device(faiss, device: str) -> str:
68
+ if device not in {"auto", "cpu", "gpu"}:
69
+ raise ValueError("device must be 'auto', 'cpu' or 'gpu'")
70
+ has_gpu = gpu_available(faiss)
71
+ if device == "gpu" and not has_gpu:
72
+ raise RuntimeError(
73
+ "device='gpu' but this faiss build has no GPU support or no GPU is visible; "
74
+ "install a GPU build (e.g. faiss-gpu-cu12) or use device='auto'"
75
+ )
76
+ return "gpu" if (device == "gpu" or (device == "auto" and has_gpu)) else "cpu"
77
+
78
+
79
+ def _recall(approx: np.ndarray, exact: np.ndarray) -> float:
80
+ k = exact.shape[1]
81
+ hits = [np.intersect1d(a[:k], e, assume_unique=False).size for a, e in zip(approx, exact)]
82
+ return float(np.mean(hits) / k)
83
+
84
+
85
+ def sweep(
86
+ x: np.ndarray,
87
+ k: int = 10,
88
+ queries: np.ndarray | None = None,
89
+ n_queries: int = 1000,
90
+ target_recall: float = 0.95,
91
+ seed: int = 0,
92
+ device: str = "auto",
93
+ ) -> SweepResult:
94
+ """Sweep HNSW and IVF settings. ``device`` is 'auto' (GPU when available), 'cpu' or 'gpu'."""
95
+ try:
96
+ import faiss
97
+ except ImportError as e: # pragma: no cover
98
+ raise ImportError('index sweeps need faiss: pip install "vecscope[index]"') from e
99
+ from vecscope import __version__
100
+
101
+ dev = _resolve_device(faiss, device)
102
+ gpu_res = faiss.StandardGpuResources() if dev == "gpu" else None
103
+ gpu_name = None
104
+ if dev == "gpu":
105
+ try:
106
+ import torch # optional, only for a readable device name
107
+
108
+ gpu_name = torch.cuda.get_device_name(0)
109
+ except (ImportError, RuntimeError, AssertionError):
110
+ gpu_name = "gpu:0"
111
+
112
+ def to_device(index):
113
+ return faiss.index_cpu_to_gpu(gpu_res, 0, index) if dev == "gpu" else index
114
+
115
+ c = normalize(x)
116
+ n, d = c.shape
117
+ rng = np.random.default_rng(seed)
118
+ if queries is None:
119
+ q = c[rng.choice(n, min(n_queries, n), replace=False)]
120
+ else:
121
+ q = normalize(queries)
122
+ if q.shape[0] > n_queries:
123
+ q = q[rng.choice(q.shape[0], n_queries, replace=False)]
124
+ kq = k + 1 if queries is None else k
125
+ if dev == "gpu": # exact baseline on the GPU too
126
+ flat = to_device(faiss.IndexFlatIP(d))
127
+ flat.add(c)
128
+ _, exact_idx = flat.search(q, kq)
129
+ else:
130
+ exact_idx, _ = query_knn(c, q, kq)
131
+ if queries is None: # drop the query itself from its own neighbor list
132
+ exact_idx = exact_idx[:, 1:]
133
+ points: list[SweepPoint] = []
134
+
135
+ def measure(index, name, params):
136
+ t = time.perf_counter()
137
+ _, approx = index.search(q, k + 1 if queries is None else k)
138
+ qps = q.shape[0] / max(time.perf_counter() - t, 1e-9)
139
+ if queries is None:
140
+ approx = approx[:, 1:]
141
+ return name, params, _recall(approx, exact_idx), qps
142
+
143
+ for m in HNSW_GRID["M"]:
144
+ for efc in HNSW_GRID["ef_construction"]:
145
+ index = faiss.IndexHNSWFlat(d, m, faiss.METRIC_INNER_PRODUCT)
146
+ index.hnsw.efConstruction = efc
147
+ t = time.perf_counter()
148
+ index.add(c)
149
+ build = time.perf_counter() - t
150
+ for efs in HNSW_GRID["ef_search"]:
151
+ index.hnsw.efSearch = efs
152
+ name, params, rec, qps = measure(
153
+ index, "hnsw", {"M": m, "ef_construction": efc, "ef_search": efs}
154
+ )
155
+ points.append(SweepPoint(name, params, rec, build, qps, "cpu")) # HNSW: CPU only
156
+
157
+ nlist = int(max(1, min(4 * np.sqrt(n), n // 39 or 1)))
158
+ quantizer = faiss.IndexFlatIP(d)
159
+ index = to_device(faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_INNER_PRODUCT))
160
+ t = time.perf_counter()
161
+ index.train(c)
162
+ index.add(c)
163
+ build = time.perf_counter() - t
164
+ for nprobe in IVF_NPROBE:
165
+ if nprobe > nlist:
166
+ break
167
+ if dev == "gpu":
168
+ faiss.GpuParameterSpace().set_index_parameter(index, "nprobe", nprobe)
169
+ else:
170
+ index.nprobe = nprobe
171
+ name, params, rec, qps = measure(index, "ivf", {"nlist": nlist, "nprobe": nprobe})
172
+ points.append(SweepPoint(name, params, rec, build, qps, dev))
173
+
174
+ ok = [p for p in points if p.recall_at_k >= target_recall]
175
+ rec = {}
176
+ if ok:
177
+ best = max(ok, key=lambda p: p.queries_per_second)
178
+ rec = {"index": best.index, "params": best.params, "recall_at_k": best.recall_at_k,
179
+ "queries_per_second": best.queries_per_second,
180
+ "reason": f"fastest configuration with recall@{k} >= {target_recall}"}
181
+ else:
182
+ best = max(points, key=lambda p: p.recall_at_k)
183
+ rec = {"index": best.index, "params": best.params, "recall_at_k": best.recall_at_k,
184
+ "queries_per_second": best.queries_per_second,
185
+ "reason": f"no configuration reached recall@{k} >= {target_recall}; "
186
+ "this is the best one. Expect a hard corpus (see audit findings)."}
187
+ return SweepResult("vecscope", __version__, int(n), int(d), int(k), int(q.shape[0]),
188
+ target_recall, points, rec, device=dev, gpu_name=gpu_name)
189
+
190
+
191
+ def render_sweep(res: SweepResult) -> str:
192
+ head = (f"vecscope {res.version} sweep n={res.n} dim={res.dim} k={res.k} "
193
+ f"queries={res.n_queries} target_recall={res.target_recall} device={res.device}")
194
+ if res.gpu_name:
195
+ head += f" ({res.gpu_name}; HNSW stays on CPU)"
196
+ lines = [head]
197
+ lines.append(f" {'index':<6}{'params':<44}{'recall':>8}{'build s':>9}{'qps':>10} dev")
198
+ for p in res.points:
199
+ params = ", ".join(f"{a}={b}" for a, b in p.params.items())
200
+ mark = "*" if p.recall_at_k >= res.target_recall else " "
201
+ lines.append(f" {mark}{p.index:<6}{params:<44}{p.recall_at_k:>8.3f}"
202
+ f"{p.build_seconds:>9.2f}{p.queries_per_second:>10.0f} {p.device}")
203
+ r = res.recommendation
204
+ params = ", ".join(f"{a}={b}" for a, b in r["params"].items())
205
+ lines.append(f" recommendation: {r['index']} ({params}) recall={r['recall_at_k']:.3f} "
206
+ f"qps={r['queries_per_second']:.0f} ({r['reason']})")
207
+ return "\n".join(lines)