vecadvisor 0.1.0a1__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.
vecadvisor/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """VecAdvisor package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0a1"
@@ -0,0 +1 @@
1
+ """Benchmark and ground-truth helpers for VecAdvisor."""
@@ -0,0 +1,292 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from dataclasses import dataclass
5
+
6
+ from psycopg import Connection
7
+
8
+ from ..models import CalibrationProfile
9
+ from .datasets import generate_synthetic_dataset, generate_synthetic_queries
10
+ from .db_runner import run_postgres_synthetic_benchmark
11
+ from .runner import (
12
+ STRATEGY_EXACT,
13
+ STRATEGY_POSTFILTER,
14
+ BenchmarkReport,
15
+ StrategyMetrics,
16
+ run_synthetic_benchmark,
17
+ )
18
+
19
+ DEFAULT_EF_SWEEP = (10, 20, 40, 80, 160)
20
+ MIN_RECALL = 1e-6
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class CalibrationFit:
25
+ profile: CalibrationProfile
26
+ reports: tuple[BenchmarkReport, ...]
27
+ notes: tuple[str, ...]
28
+
29
+
30
+ def parse_ef_sweep(value: str | None) -> tuple[int, ...]:
31
+ if value is None or not value.strip():
32
+ return DEFAULT_EF_SWEEP
33
+ points: list[int] = []
34
+ for item in value.split(","):
35
+ stripped = item.strip()
36
+ if not stripped:
37
+ continue
38
+ try:
39
+ point = int(stripped)
40
+ except ValueError as exc:
41
+ raise ValueError("ef sweep values must be positive integers") from exc
42
+ if point <= 0:
43
+ raise ValueError("ef sweep values must be positive integers")
44
+ points.append(point)
45
+ if not points:
46
+ raise ValueError("ef sweep must contain at least one point")
47
+ return tuple(sorted(set(points)))
48
+
49
+
50
+ def run_synthetic_calibration(
51
+ *,
52
+ rows: int = 5_000,
53
+ dim: int = 64,
54
+ queries: int = 50,
55
+ clusters: int = 16,
56
+ filter_selectivity: float = 0.1,
57
+ correlation: float = 0.0,
58
+ limit: int = 10,
59
+ metric: str = "l2",
60
+ block_rows: int | None = None,
61
+ seed: int = 0,
62
+ ef_sweep: tuple[int, ...] = DEFAULT_EF_SWEEP,
63
+ dataset_id: str = "synthetic-simulated",
64
+ hardware_id: str = "local-synthetic-cpu",
65
+ index_method: str = "hnsw",
66
+ ) -> CalibrationFit:
67
+ """Calibrate a profile from the synthetic benchmark semantics runner."""
68
+
69
+ if not ef_sweep:
70
+ raise ValueError("ef_sweep must not be empty")
71
+ dataset = generate_synthetic_dataset(
72
+ n_rows=rows,
73
+ dim=dim,
74
+ n_clusters=clusters,
75
+ filter_selectivity=filter_selectivity,
76
+ correlation=correlation,
77
+ seed=seed,
78
+ )
79
+ query_set = generate_synthetic_queries(
80
+ dataset,
81
+ n_queries=queries,
82
+ seed=seed + 1,
83
+ cluster_policy="uniform",
84
+ )
85
+ reports = tuple(
86
+ run_synthetic_benchmark(
87
+ dataset=dataset,
88
+ queries=query_set,
89
+ k=limit,
90
+ metric=metric,
91
+ strategies=(STRATEGY_EXACT, STRATEGY_POSTFILTER),
92
+ ef_search=ef,
93
+ block_rows=block_rows,
94
+ )
95
+ for ef in ef_sweep
96
+ )
97
+ profile = fit_profile_from_reports(
98
+ reports,
99
+ dataset_id=dataset_id,
100
+ hardware_id=hardware_id,
101
+ index_method=index_method,
102
+ )
103
+ return CalibrationFit(
104
+ profile=profile,
105
+ reports=reports,
106
+ notes=(
107
+ "profile fitted from synthetic strategy-semantics benchmark measurements",
108
+ "c_h reflects exact-candidate simulation, not real pgvector HNSW latency",
109
+ "run DB-backed calibration before using this profile for production planning",
110
+ ),
111
+ )
112
+
113
+
114
+ def run_postgres_calibration(
115
+ conn: Connection[object],
116
+ *,
117
+ rows: int = 1_000,
118
+ dim: int = 32,
119
+ queries: int = 10,
120
+ clusters: int = 8,
121
+ filter_selectivity: float = 0.1,
122
+ correlation: float = 0.0,
123
+ limit: int = 10,
124
+ metric: str = "l2",
125
+ block_rows: int | None = None,
126
+ seed: int = 0,
127
+ ef_sweep: tuple[int, ...] = DEFAULT_EF_SWEEP,
128
+ hnsw_m: int = 8,
129
+ hnsw_ef_construction: int = 32,
130
+ statement_timeout_ms: int = 30_000,
131
+ dataset_id: str = "postgres-synthetic",
132
+ hardware_id: str = "local-postgres-pgvector",
133
+ index_method: str = "hnsw",
134
+ ) -> CalibrationFit:
135
+ """Calibrate a profile from actual PostgreSQL/pgvector benchmark measurements."""
136
+
137
+ if not ef_sweep:
138
+ raise ValueError("ef_sweep must not be empty")
139
+ dataset = generate_synthetic_dataset(
140
+ n_rows=rows,
141
+ dim=dim,
142
+ n_clusters=clusters,
143
+ filter_selectivity=filter_selectivity,
144
+ correlation=correlation,
145
+ seed=seed,
146
+ )
147
+ query_set = generate_synthetic_queries(
148
+ dataset,
149
+ n_queries=queries,
150
+ seed=seed + 1,
151
+ cluster_policy="uniform",
152
+ )
153
+ reports = tuple(
154
+ run_postgres_synthetic_benchmark(
155
+ conn,
156
+ dataset=dataset,
157
+ queries=query_set,
158
+ k=limit,
159
+ metric=metric,
160
+ strategies=(STRATEGY_EXACT, STRATEGY_POSTFILTER),
161
+ ef_search=ef,
162
+ hnsw_m=hnsw_m,
163
+ hnsw_ef_construction=hnsw_ef_construction,
164
+ block_rows=block_rows,
165
+ statement_timeout_ms=statement_timeout_ms,
166
+ )
167
+ for ef in ef_sweep
168
+ )
169
+ profile = fit_profile_from_reports(
170
+ reports,
171
+ dataset_id=dataset_id,
172
+ hardware_id=hardware_id,
173
+ index_method=index_method,
174
+ )
175
+ return CalibrationFit(
176
+ profile=profile,
177
+ reports=reports,
178
+ notes=(
179
+ "profile fitted from actual PostgreSQL/pgvector benchmark measurements",
180
+ "postfilter latency comes from real HNSW SQL with scalar post-filter",
181
+ "ground truth for recall still comes from bounded exact in-process computation",
182
+ ),
183
+ )
184
+
185
+
186
+ def fit_profile_from_reports(
187
+ reports: tuple[BenchmarkReport, ...],
188
+ *,
189
+ dataset_id: str,
190
+ hardware_id: str,
191
+ index_method: str = "hnsw",
192
+ ) -> CalibrationProfile:
193
+ if not reports:
194
+ raise ValueError("at least one benchmark report is required")
195
+ first = reports[0]
196
+ exact = _strategy(first, STRATEGY_EXACT)
197
+ rows = _positive_float(first.dataset["rows"], "rows")
198
+ selectivity = max(
199
+ _positive_float(first.dataset["observed_filter_selectivity"], "selectivity"),
200
+ 1 / rows,
201
+ )
202
+ k = _positive_int(first.ground_truth["k"], "k")
203
+ qualifying_rows = max(1.0, rows * selectivity)
204
+ exact_latency_us = max(exact.latency_ms_mean * 1000.0, MIN_RECALL)
205
+ exact_denominator = qualifying_rows * (2.0 + math.log2(max(k, 2)))
206
+ c_scan = max(MIN_RECALL, exact_latency_us / max(exact_denominator, MIN_RECALL))
207
+ c_d = c_scan
208
+ c_h_values: list[float] = []
209
+ recall_points: list[tuple[int, float]] = []
210
+ running_recall = 0.0
211
+ for report in reports:
212
+ postfilter = _strategy(report, STRATEGY_POSTFILTER)
213
+ ef = _positive_int(postfilter.params["ef_search"], "ef_search")
214
+ latency_us = max(postfilter.latency_ms_mean * 1000.0, MIN_RECALL)
215
+ denominator = c_d * ef * math.log(max(rows, 2.0))
216
+ c_h_values.append(max(MIN_RECALL, latency_us / max(denominator, MIN_RECALL)))
217
+ running_recall = max(running_recall, postfilter.recall_at_k)
218
+ recall_points.append((ef, min(1.0, max(MIN_RECALL, running_recall))))
219
+
220
+ return CalibrationProfile(
221
+ dataset_id=dataset_id,
222
+ hardware_id=hardware_id,
223
+ index_method=index_method,
224
+ c_d=c_d,
225
+ c_scan=c_scan,
226
+ c_h=_median(tuple(c_h_values)),
227
+ delta_strict=0.0,
228
+ recall_curve=tuple(sorted(recall_points)),
229
+ )
230
+
231
+
232
+ def calibration_fit_to_json(fit: CalibrationFit) -> dict[str, object]:
233
+ from ..calibration import calibration_profile_to_json
234
+
235
+ return {
236
+ "profile": calibration_profile_to_json(fit.profile),
237
+ "ef_sweep": [ef for ef, _ in fit.profile.recall_curve],
238
+ "fit_reports": [
239
+ {
240
+ "ef_search": _strategy(report, STRATEGY_POSTFILTER).params["ef_search"],
241
+ "postfilter_recall_at_k": _strategy(report, STRATEGY_POSTFILTER).recall_at_k,
242
+ "postfilter_latency_ms_mean": _strategy(
243
+ report,
244
+ STRATEGY_POSTFILTER,
245
+ ).latency_ms_mean,
246
+ "exact_latency_ms_mean": _strategy(report, STRATEGY_EXACT).latency_ms_mean,
247
+ }
248
+ for report in fit.reports
249
+ ],
250
+ "notes": list(fit.notes),
251
+ }
252
+
253
+
254
+ def _strategy(report: BenchmarkReport, strategy: str) -> StrategyMetrics:
255
+ for metrics in report.strategies:
256
+ if metrics.strategy == strategy:
257
+ return metrics
258
+ raise ValueError(f"benchmark report does not contain strategy: {strategy}")
259
+
260
+
261
+ def _positive_float(value: object, name: str) -> float:
262
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
263
+ raise ValueError(f"{name} must be numeric")
264
+ try:
265
+ number = float(value)
266
+ except ValueError as exc:
267
+ raise ValueError(f"{name} must be numeric") from exc
268
+ if not math.isfinite(number) or number <= 0.0:
269
+ raise ValueError(f"{name} must be positive")
270
+ return number
271
+
272
+
273
+ def _positive_int(value: object, name: str) -> int:
274
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
275
+ raise ValueError(f"{name} must be a positive integer")
276
+ try:
277
+ number = int(value)
278
+ except ValueError as exc:
279
+ raise ValueError(f"{name} must be a positive integer") from exc
280
+ if number <= 0:
281
+ raise ValueError(f"{name} must be a positive integer")
282
+ return number
283
+
284
+
285
+ def _median(values: tuple[float, ...]) -> float:
286
+ if not values:
287
+ raise ValueError("cannot compute median of empty values")
288
+ ordered = sorted(values)
289
+ middle = len(ordered) // 2
290
+ if len(ordered) % 2:
291
+ return ordered[middle]
292
+ return (ordered[middle - 1] + ordered[middle]) / 2.0