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/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """vecscope: embedding space evidence for agents."""
2
+
3
+ from vecscope.evidence import Evidence, Finding, audit
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["Evidence", "Finding", "__version__", "audit"]
vecscope/_knn.py ADDED
@@ -0,0 +1,46 @@
1
+ """Exact k-nearest-neighbor helpers (NumPy only, blocked for memory)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+
8
+ def normalize(x: np.ndarray) -> np.ndarray:
9
+ x = np.asarray(x, dtype=np.float32)
10
+ norms = np.linalg.norm(x, axis=1, keepdims=True)
11
+ norms[norms == 0] = 1.0
12
+ return x / norms
13
+
14
+
15
+ def exact_knn(
16
+ x: np.ndarray, k: int, metric: str = "cosine", block: int = 2048
17
+ ) -> tuple[np.ndarray, np.ndarray]:
18
+ """Return (indices, distances) of the k nearest neighbors, excluding self.
19
+
20
+ distances are cosine distance (1 - cos) or squared euclidean.
21
+ """
22
+ n = x.shape[0]
23
+ k = min(k, n - 1)
24
+ if metric == "cosine":
25
+ q = normalize(x)
26
+ else: # "dot" (negative inner product) or "euclidean"
27
+ q = np.asarray(x, dtype=np.float32)
28
+ sq = (q * q).sum(axis=1)
29
+ idx = np.empty((n, k), dtype=np.int64)
30
+ dist = np.empty((n, k), dtype=np.float32)
31
+ for s in range(0, n, block):
32
+ e = min(s + block, n)
33
+ if metric == "cosine":
34
+ d = 1.0 - q[s:e] @ q.T
35
+ elif metric == "dot":
36
+ d = -(q[s:e] @ q.T)
37
+ else:
38
+ d = sq[s:e, None] - 2.0 * (q[s:e] @ q.T) + sq[None, :]
39
+ rows = np.arange(e - s)
40
+ d[rows, np.arange(s, e)] = np.inf # drop self
41
+ part = np.argpartition(d, k, axis=1)[:, :k]
42
+ pd = np.take_along_axis(d, part, axis=1)
43
+ order = np.argsort(pd, axis=1)
44
+ idx[s:e] = np.take_along_axis(part, order, axis=1)
45
+ dist[s:e] = np.take_along_axis(pd, order, axis=1)
46
+ return idx, dist
vecscope/cli.py ADDED
@@ -0,0 +1,87 @@
1
+ """vecscope CLI: `vecscope audit embeddings.npy`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from vecscope import __version__
10
+ from vecscope.evidence import audit
11
+ from vecscope.io import load_vectors
12
+ from vecscope.sweep import render_sweep, sweep
13
+
14
+
15
+ def _render_text(ev) -> str:
16
+ mark = {"ok": "ok ", "warn": "WARN", "fail": "FAIL"}
17
+ head = f"vecscope {ev.version} n={ev.n} dim={ev.dim} k={ev.k} verdict={ev.verdict.upper()}"
18
+ if ev.sampled:
19
+ head += f" (sampled from {ev.n_total} rows)"
20
+ if ev.n_queries:
21
+ head += f" queries={ev.n_queries}"
22
+ lines = [head]
23
+ for f in ev.findings:
24
+ lines.append(
25
+ f" [{mark[f.verdict]}] {f.name:<16} {f.metric}={f.value:.4f} ({f.healthy_range})"
26
+ )
27
+ lines.append(f"{'':25}{f.explanation}")
28
+ if ev.clusters:
29
+ lines.append(f" clusters ({len(ev.clusters)}):")
30
+ lines.append(f"{'':4}{'id':>3}{'size':>7}{'share':>7}{'cos2ctr':>9}{'neardup':>9}"
31
+ f"{'hubshare':>10} sample_ids")
32
+ for c in ev.clusters:
33
+ lines.append(f"{'':4}{c['cluster']:>3}{c['size']:>7}{c['share']:>7.1%}"
34
+ f"{c['mean_cosine_to_center']:>9.3f}{c['near_duplicate_ratio']:>9.1%}"
35
+ f"{c['hub_share']:>10.1%} {c['sample_ids']}")
36
+ return "\n".join(lines)
37
+
38
+
39
+ def main(argv: list[str] | None = None) -> int:
40
+ p = argparse.ArgumentParser(prog="vecscope", description="Embedding space evidence for agents.")
41
+ p.add_argument("--version", action="version", version=f"vecscope {__version__}")
42
+ sub = p.add_subparsers(dest="cmd", required=True)
43
+ a = sub.add_parser("audit", help="audit an embedding matrix")
44
+ a.add_argument("path", help=".npy/.npz/.parquet/.jsonl/.csv with one vector per row")
45
+ a.add_argument("-k", type=int, default=10, help="neighbors per item (default 10)")
46
+ a.add_argument("--ann", help="optional .npy of ANN neighbor indices (n x k) to score recall")
47
+ a.add_argument("--queries", help="optional query embeddings (m x dim) for query-side findings")
48
+ a.add_argument("--clusters", type=int, default=0,
49
+ help="attach a per-cluster report with this many clusters (0 = off)")
50
+ a.add_argument("--near-dup", type=float, default=0.99, help="cosine threshold for near-dups")
51
+ a.add_argument("--sample", type=int, default=100_000,
52
+ help="audit a seeded random subset when n exceeds this (0 = all rows)")
53
+ a.add_argument("--json", action="store_true", help="emit JSON evidence instead of text")
54
+ a.add_argument("--fail-on", choices=["warn", "fail"], default="fail",
55
+ help="exit 1 when verdict is at least this level")
56
+ s = sub.add_parser("sweep", help="sweep HNSW/IVF parameters (needs faiss-cpu)")
57
+ s.add_argument("path", help="embedding file, one vector per row")
58
+ s.add_argument("-k", type=int, default=10, help="neighbors per query (default 10)")
59
+ s.add_argument("--queries", help="optional query embeddings; default samples the corpus")
60
+ s.add_argument("--n-queries", type=int, default=1000, help="queries to time (default 1000)")
61
+ s.add_argument("--target-recall", type=float, default=0.95,
62
+ help="recall@k a configuration must reach to be recommended")
63
+ s.add_argument("--device", choices=["auto", "cpu", "gpu"], default="auto",
64
+ help="run IVF and the exact baseline on the GPU when faiss has GPU support "
65
+ "(default auto); HNSW is CPU-only in faiss")
66
+ s.add_argument("--json", action="store_true", help="emit JSON instead of text")
67
+ args = p.parse_args(argv)
68
+
69
+ x = load_vectors(args.path)
70
+ if args.cmd == "sweep":
71
+ q = load_vectors(args.queries) if args.queries else None
72
+ res = sweep(x, k=args.k, queries=q, n_queries=args.n_queries,
73
+ target_recall=args.target_recall, device=args.device)
74
+ print(json.dumps(res.to_dict(), indent=2) if args.json else render_sweep(res))
75
+ return 0
76
+
77
+ ann = load_vectors(args.ann).astype(int) if args.ann else None
78
+ q = load_vectors(args.queries) if args.queries else None
79
+ ev = audit(x, k=args.k, ann_neighbors=ann, near_dup_cos=args.near_dup,
80
+ sample=args.sample or None, queries=q, clusters=args.clusters)
81
+ print(ev.to_json() if args.json else _render_text(ev))
82
+ level = {"ok": 0, "warn": 1, "fail": 2}
83
+ return 1 if level[ev.verdict] >= level[args.fail_on] else 0
84
+
85
+
86
+ if __name__ == "__main__":
87
+ sys.exit(main())
@@ -0,0 +1,20 @@
1
+ from vecscope.diagnostics.anisotropy import anisotropy
2
+ from vecscope.diagnostics.ann_recall import ann_recall
3
+ from vecscope.diagnostics.clusters import cluster_report
4
+ from vecscope.diagnostics.collapse import collapse
5
+ from vecscope.diagnostics.geometry import (
6
+ connectivity,
7
+ distance_concentration,
8
+ metric_mismatch,
9
+ norms,
10
+ outliers,
11
+ rogue_dimensions,
12
+ )
13
+ from vecscope.diagnostics.hubness import hubness
14
+ from vecscope.diagnostics.queries import query_audit
15
+
16
+ __all__ = [
17
+ "anisotropy", "ann_recall", "cluster_report", "collapse", "connectivity",
18
+ "distance_concentration", "hubness", "metric_mismatch", "norms", "outliers",
19
+ "query_audit", "rogue_dimensions",
20
+ ]
@@ -0,0 +1,48 @@
1
+ """Anisotropy: embeddings crowd into a narrow cone, so every pair looks similar.
2
+
3
+ Evidence: mean cosine of random pairs (0 is isotropic), the norm of the mean unit vector
4
+ (the shared direction every vector carries), the same cosine after subtracting that mean
5
+ (what centering would buy), top principal-component share, effective rank (participation
6
+ ratio of the covariance spectrum).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+ from vecscope._knn import normalize
14
+
15
+
16
+ def anisotropy(x: np.ndarray, sample: int = 20000, seed: int = 0) -> dict:
17
+ rng = np.random.default_rng(seed)
18
+ q = normalize(x)
19
+ n, d = q.shape
20
+ m = min(sample, n)
21
+ a = rng.integers(0, n, m)
22
+ b = rng.integers(0, n, m)
23
+ keep = a != b
24
+ mean_cos = float((q[a[keep]] * q[b[keep]]).sum(axis=1).mean())
25
+ mean_vec = q.mean(axis=0)
26
+ qc = normalize(q - mean_vec)
27
+ mean_cos_centered = float((qc[a[keep]] * qc[b[keep]]).sum(axis=1).mean())
28
+
29
+ sub = q[rng.choice(n, min(n, 50000), replace=False)]
30
+ sub = sub - sub.mean(axis=0, keepdims=True)
31
+ s = np.linalg.svd(sub, compute_uv=False)
32
+ var = s**2
33
+ total = var.sum()
34
+ if total <= 0: # constant input
35
+ var = np.zeros_like(var)
36
+ effective_rank = 0.0
37
+ else:
38
+ var = var / total
39
+ effective_rank = float(1.0 / (var**2).sum())
40
+ return {
41
+ "dim": int(d),
42
+ "mean_random_cosine": mean_cos,
43
+ "mean_vector_norm": float(np.linalg.norm(mean_vec)),
44
+ "mean_random_cosine_after_centering": mean_cos_centered,
45
+ "top1_pc_variance_share": float(var[0]) if var.size else 0.0,
46
+ "effective_rank": effective_rank,
47
+ "effective_rank_ratio": effective_rank / float(min(d, sub.shape[0])),
48
+ }
@@ -0,0 +1,25 @@
1
+ """ANN recall: does the approximate index actually return the true neighbors?
2
+
3
+ Compare neighbors returned by an ANN index (HNSW, IVF, ...) with exact k-NN.
4
+ Evidence: recall@k, per-query worst recall, share of queries below a floor.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+
12
+ def ann_recall(exact_knn_idx: np.ndarray, ann_idx: np.ndarray, floor: float = 0.8) -> dict:
13
+ k = min(exact_knn_idx.shape[1], ann_idx.shape[1])
14
+ exact = exact_knn_idx[:, :k]
15
+ approx = ann_idx[:, :k]
16
+ hits = np.array([len(set(e.tolist()) & set(a.tolist())) for e, a in zip(exact, approx)])
17
+ per_query = hits / k
18
+ return {
19
+ "k": int(k),
20
+ "recall_at_k": float(per_query.mean()),
21
+ "recall_p05": float(np.quantile(per_query, 0.05)),
22
+ "min_recall": float(per_query.min()),
23
+ "queries_below_floor_ratio": float((per_query < floor).mean()),
24
+ "floor": floor,
25
+ }
@@ -0,0 +1,64 @@
1
+ """Per-cluster report: where in the space do the problems live?
2
+
3
+ A fixed-seed spherical k-means splits the corpus into a few regions; for each one the report
4
+ gives size, tightness, near-duplicate share, how often its items act as hubs, and sample ids
5
+ the agent can look up. No verdicts: this is a map, not a test.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ from vecscope._knn import normalize
13
+
14
+
15
+ def spherical_kmeans(q: np.ndarray, n_clusters: int, seed: int = 0, iters: int = 15) -> np.ndarray:
16
+ rng = np.random.default_rng(seed)
17
+ centers = q[rng.choice(q.shape[0], n_clusters, replace=False)].copy()
18
+ labels = np.zeros(q.shape[0], dtype=np.int64)
19
+ for _ in range(iters):
20
+ sims = q @ centers.T
21
+ new = sims.argmax(axis=1)
22
+ if np.array_equal(new, labels) and _ > 0:
23
+ break
24
+ labels = new
25
+ for c in range(n_clusters):
26
+ members = q[labels == c]
27
+ if members.size:
28
+ centers[c] = members.mean(axis=0)
29
+ centers = normalize(centers)
30
+ return labels
31
+
32
+
33
+ def cluster_report(
34
+ x: np.ndarray,
35
+ n_clusters: int,
36
+ knn_idx: np.ndarray,
37
+ knn_dist: np.ndarray,
38
+ near_dup_cos: float,
39
+ seed: int = 0,
40
+ sample_ids: int = 5,
41
+ ) -> list[dict]:
42
+ q = normalize(x)
43
+ n = q.shape[0]
44
+ n_clusters = max(1, min(n_clusters, n // 10))
45
+ labels = spherical_kmeans(q, n_clusters, seed)
46
+ nk = np.bincount(knn_idx.ravel(), minlength=n).astype(np.float64)
47
+ nn_cos = 1.0 - knn_dist[:, 0]
48
+ report = []
49
+ for c in range(n_clusters):
50
+ m = np.nonzero(labels == c)[0]
51
+ if m.size == 0:
52
+ continue
53
+ center = normalize(q[m].mean(axis=0, keepdims=True))[0]
54
+ report.append({
55
+ "cluster": int(c),
56
+ "size": int(m.size),
57
+ "share": float(m.size / n),
58
+ "mean_cosine_to_center": float((q[m] @ center).mean()),
59
+ "near_duplicate_ratio": float((nn_cos[m] >= near_dup_cos).mean()),
60
+ "hub_share": float(nk[m].sum() / nk.sum()) if nk.sum() > 0 else 0.0,
61
+ "sample_ids": [int(i) for i in m[np.argsort(-(q[m] @ center))[:sample_ids]]],
62
+ })
63
+ report.sort(key=lambda r: -r["size"])
64
+ return report
@@ -0,0 +1,32 @@
1
+ """Collapse: many vectors are (near) identical, so retrieval cannot tell them apart.
2
+
3
+ Evidence: exact duplicate ratio, near-duplicate ratio (cos > threshold), and the
4
+ share of items whose nearest neighbor is essentially itself.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ from vecscope._knn import exact_knn
12
+
13
+
14
+ def collapse(
15
+ x: np.ndarray, near_dup_cos: float = 0.99, knn_dist: np.ndarray | None = None
16
+ ) -> dict:
17
+ n = x.shape[0]
18
+ if knn_dist is None:
19
+ _, knn_dist = exact_knn(x, 1)
20
+ nn_cos = 1.0 - knn_dist[:, 0]
21
+ exact = float((nn_cos >= 1.0 - 1e-6).mean())
22
+ near = float((nn_cos >= near_dup_cos).mean())
23
+ unique_rows = np.unique(np.asarray(x, dtype=np.float32).round(6), axis=0).shape[0]
24
+ return {
25
+ "n": int(n),
26
+ "exact_duplicate_ratio": 1.0 - unique_rows / n,
27
+ "near_duplicate_ratio": near,
28
+ "nn_cosine_median": float(np.median(nn_cos)),
29
+ "nn_cosine_p95": float(np.quantile(nn_cos, 0.95)),
30
+ "near_dup_threshold": near_dup_cos,
31
+ "exact_nn_match_ratio": exact,
32
+ }
@@ -0,0 +1,120 @@
1
+ """Geometry diagnostics derived from the shared exact k-NN pass.
2
+
3
+ - rogue_dimensions: a handful of dimensions hold most of the variance, so cosine is decided
4
+ by them alone (Timkey & van Schijndel, 2021).
5
+ - norms: vector lengths vary or vanish, which breaks inner-product retrieval.
6
+ - metric_mismatch: cosine top-k and inner-product top-k disagree, so the index metric matters.
7
+ - distance_concentration: all distances look alike, the nearest neighbor is barely nearer than
8
+ a random item (relative contrast, Aggarwal et al., 2001).
9
+ - outliers: items whose nearest neighbor is unusually far, typically junk or foreign content.
10
+ - connectivity: small islands in the k-NN graph that graph-based ANN attaches badly or never reaches.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import numpy as np
16
+
17
+
18
+ def rogue_dimensions(x: np.ndarray, top: int = 3) -> dict:
19
+ var = np.asarray(x, dtype=np.float64).var(axis=0)
20
+ total = var.sum()
21
+ if total <= 0: # constant matrix: every dimension is equally (un)informative
22
+ return {"top1_dim_variance_share": 0.0, "top3_dim_variance_share": 0.0,
23
+ "expected_share": 1.0 / x.shape[1], "rogue_dims": [0, 1, 2][: x.shape[1]]}
24
+ share = var / total
25
+ order = np.argsort(share)[::-1]
26
+ return {
27
+ "top1_dim_variance_share": float(share[order[0]]),
28
+ "top3_dim_variance_share": float(share[order[:top]].sum()),
29
+ "expected_share": 1.0 / x.shape[1],
30
+ "rogue_dims": [int(i) for i in order[:top]],
31
+ }
32
+
33
+
34
+ def norms(x: np.ndarray) -> dict:
35
+ n = np.linalg.norm(np.asarray(x, dtype=np.float64), axis=1)
36
+ mean = n.mean()
37
+ return {
38
+ "norm_cv": float(n.std() / mean) if mean > 0 else 0.0,
39
+ "zero_norm_ratio": float((n == 0).mean()),
40
+ "norm_min": float(n.min()),
41
+ "norm_median": float(np.median(n)),
42
+ "norm_max": float(n.max()),
43
+ }
44
+
45
+
46
+ def metric_mismatch(cos_knn: np.ndarray, dot_knn: np.ndarray) -> dict:
47
+ k = min(cos_knn.shape[1], dot_knn.shape[1])
48
+ a = np.sort(cos_knn[:, :k], axis=1)
49
+ b = np.sort(dot_knn[:, :k], axis=1)
50
+ overlap = np.array([np.intersect1d(r, s, assume_unique=True).size for r, s in zip(a, b)]) / k
51
+ return {
52
+ "k": int(k),
53
+ "topk_overlap": float(overlap.mean()),
54
+ "queries_fully_agreeing_ratio": float((overlap == 1.0).mean()),
55
+ "min_overlap": float(overlap.min()),
56
+ }
57
+
58
+
59
+ def distance_concentration(nn_dist: np.ndarray, random_pair_cos: float) -> dict:
60
+ """Relative contrast on cosine distance: mean random distance over median NN distance."""
61
+ nn = np.asarray(nn_dist, dtype=np.float64)
62
+ distinct = nn[nn > 1e-4] # exact duplicates (float32 noise ~1e-7) would pin the median at 0
63
+ median_nn = float(np.median(distinct)) if distinct.size else 0.0
64
+ mean_random = float(max(1.0 - random_pair_cos, 1e-9))
65
+ return {
66
+ "relative_contrast": mean_random / max(median_nn, 1e-6),
67
+ "median_nn_distance": median_nn,
68
+ "mean_random_distance": mean_random,
69
+ }
70
+
71
+
72
+ def outliers(nn_dist: np.ndarray, factor: float = 3.0, sample_ids: int = 10) -> dict:
73
+ nn = np.asarray(nn_dist, dtype=np.float64)
74
+ distinct = nn[nn > 1e-4] if (nn > 1e-4).any() else nn
75
+ median = float(np.median(distinct))
76
+ mad = float(np.median(np.abs(distinct - median)))
77
+ cutoff = median + factor * 1.4826 * mad if mad > 0 else median * (1 + factor)
78
+ mask = nn > cutoff
79
+ worst = np.argsort(nn)[::-1][:sample_ids]
80
+ return {
81
+ "outlier_ratio": float(mask.mean()),
82
+ "cutoff_nn_distance": float(cutoff),
83
+ "median_nn_distance": median,
84
+ "sample_ids": [int(i) for i in worst if mask[i]],
85
+ }
86
+
87
+
88
+ def connectivity(knn: np.ndarray, min_island_frac: float = 0.005, max_iter: int = 1000) -> dict:
89
+ """Connected components of the symmetrized k-NN graph via min-label propagation.
90
+
91
+ Large components are ordinary cluster structure. The retrieval risk is small islands:
92
+ components smaller than max(k + 1, min_island_frac * n), which graph-based ANN tends to
93
+ attach to the wrong region or never reach.
94
+ """
95
+ n, k = knn.shape
96
+ src = np.repeat(np.arange(n), k)
97
+ dst = knn.ravel()
98
+ a = np.concatenate([src, dst])
99
+ b = np.concatenate([dst, src])
100
+ label = np.arange(n)
101
+ for _ in range(max_iter):
102
+ new = label.copy()
103
+ np.minimum.at(new, a, label[b])
104
+ if np.array_equal(new, label):
105
+ break
106
+ label = new
107
+ labels, counts = np.unique(label, return_counts=True)
108
+ min_size = max(k + 1, int(np.ceil(min_island_frac * n)))
109
+ small = counts < min_size
110
+ island_items = int(counts[small].sum())
111
+ island_labels = labels[small]
112
+ sample = np.nonzero(np.isin(label, island_labels))[0][:10]
113
+ return {
114
+ "island_ratio": island_items / n,
115
+ "n_components": int(counts.size),
116
+ "n_islands": int(small.sum()),
117
+ "min_island_size": int(min_size),
118
+ "largest_component_ratio": float(counts.max() / n),
119
+ "sample_ids": [int(i) for i in sample],
120
+ }
@@ -0,0 +1,33 @@
1
+ """Hubness: a few points appear in everyone's k-NN list (hubs), others in nobody's (anti-hubs).
2
+
3
+ High hubness means the same chunks top every query regardless of relevance.
4
+ Evidence: skewness of the k-occurrence distribution N_k, hub share, anti-hub ratio.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ from vecscope._knn import exact_knn
12
+
13
+
14
+ def hubness(x: np.ndarray, k: int = 10, knn: np.ndarray | None = None) -> dict:
15
+ n = x.shape[0]
16
+ if knn is None:
17
+ knn, _ = exact_knn(x, k)
18
+ k = knn.shape[1]
19
+ nk = np.bincount(knn.ravel(), minlength=n).astype(np.float64)
20
+ mean, std = nk.mean(), nk.std()
21
+ skew = float(((nk - mean) ** 3).mean() / (std**3)) if std > 0 else 0.0
22
+ top = np.sort(nk)[::-1]
23
+ top1pct = max(1, int(np.ceil(n * 0.01)))
24
+ hub_share = float(top[:top1pct].sum() / nk.sum())
25
+ antihub_ratio = float((nk == 0).mean())
26
+ return {
27
+ "k": int(k),
28
+ "skewness": skew,
29
+ "max_k_occurrence": int(nk.max()),
30
+ "expected_k_occurrence": float(k),
31
+ "hub_share_top1pct": hub_share,
32
+ "antihub_ratio": antihub_ratio,
33
+ }
@@ -0,0 +1,65 @@
1
+ """Query-side evidence: do the queries live in the same space as the corpus?
2
+
3
+ - ood_queries: queries whose nearest corpus item is farther than almost every corpus item is
4
+ from its own nearest neighbor. Typical causes: asymmetric models used without the query
5
+ prefix, a different model for queries, or queries about things the corpus does not cover.
6
+ - query_hubness: skewness of how often each corpus item appears in query top-k lists. High
7
+ values mean the same few items answer everything.
8
+ - coverage (details): share of corpus items that any query reaches in its top-k.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+ from vecscope._knn import normalize
16
+
17
+
18
+ def query_knn(corpus: np.ndarray, queries: np.ndarray, k: int, block: int = 2048):
19
+ c = normalize(corpus)
20
+ q = normalize(queries)
21
+ k = min(k, c.shape[0])
22
+ idx = np.empty((q.shape[0], k), dtype=np.int64)
23
+ dist = np.empty((q.shape[0], k), dtype=np.float32)
24
+ for s in range(0, q.shape[0], block):
25
+ e = min(s + block, q.shape[0])
26
+ d = 1.0 - q[s:e] @ c.T
27
+ part = np.argpartition(d, k - 1, axis=1)[:, :k]
28
+ pd = np.take_along_axis(d, part, axis=1)
29
+ order = np.argsort(pd, axis=1)
30
+ idx[s:e] = np.take_along_axis(part, order, axis=1)
31
+ dist[s:e] = np.take_along_axis(pd, order, axis=1)
32
+ return idx, dist
33
+
34
+
35
+ def query_audit(
36
+ corpus: np.ndarray,
37
+ queries: np.ndarray,
38
+ k: int,
39
+ corpus_nn_dist: np.ndarray,
40
+ sample_ids: int = 10,
41
+ ) -> dict:
42
+ idx, dist = query_knn(corpus, queries, k)
43
+ n_corpus = corpus.shape[0]
44
+ corpus_nn = np.asarray(corpus_nn_dist, dtype=np.float64)
45
+ corpus_nn = corpus_nn[corpus_nn > 1e-4] if (corpus_nn > 1e-4).any() else corpus_nn
46
+ cutoff = float(np.quantile(corpus_nn, 0.99))
47
+ nearest = dist[:, 0].astype(np.float64)
48
+ ood = nearest > cutoff
49
+ worst = np.argsort(nearest)[::-1][:sample_ids]
50
+
51
+ nk = np.bincount(idx.ravel(), minlength=n_corpus).astype(np.float64)
52
+ std = nk.std()
53
+ skew = float(((nk - nk.mean()) ** 3).mean() / std**3) if std > 0 else 0.0
54
+ return {
55
+ "n_queries": int(queries.shape[0]),
56
+ "k": int(idx.shape[1]),
57
+ "ood_query_ratio": float(ood.mean()),
58
+ "ood_cutoff_distance": cutoff,
59
+ "median_query_nn_distance": float(np.median(nearest)),
60
+ "median_corpus_nn_distance": float(np.median(corpus_nn)),
61
+ "ood_sample_ids": [int(i) for i in worst if ood[i]],
62
+ "query_hubness_skewness": skew,
63
+ "max_query_k_occurrence": int(nk.max()),
64
+ "coverage": float((nk > 0).mean()),
65
+ }