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 +5 -0
- vecadvisor/bench/__init__.py +1 -0
- vecadvisor/bench/calibrate.py +292 -0
- vecadvisor/bench/crossover.py +440 -0
- vecadvisor/bench/datasets.py +246 -0
- vecadvisor/bench/db_runner.py +409 -0
- vecadvisor/bench/groundtruth.py +220 -0
- vecadvisor/bench/plots.py +1034 -0
- vecadvisor/bench/proof.py +248 -0
- vecadvisor/bench/runner.py +456 -0
- vecadvisor/bench/sweep.py +724 -0
- vecadvisor/bench/validate.py +413 -0
- vecadvisor/calibration.py +196 -0
- vecadvisor/cli.py +2606 -0
- vecadvisor/costmodel.py +153 -0
- vecadvisor/diagnostics.py +406 -0
- vecadvisor/introspect.py +389 -0
- vecadvisor/local_cache.py +178 -0
- vecadvisor/local_probe.py +598 -0
- vecadvisor/models.py +246 -0
- vecadvisor/pgversion.py +102 -0
- vecadvisor/plan.py +240 -0
- vecadvisor/planner_observer.py +125 -0
- vecadvisor/py.typed +1 -0
- vecadvisor/query_spec.py +231 -0
- vecadvisor/recommend.py +818 -0
- vecadvisor/selectivity.py +205 -0
- vecadvisor/statistics_advisor.py +113 -0
- vecadvisor/stats_health.py +130 -0
- vecadvisor-0.1.0a1.dist-info/METADATA +460 -0
- vecadvisor-0.1.0a1.dist-info/RECORD +34 -0
- vecadvisor-0.1.0a1.dist-info/WHEEL +4 -0
- vecadvisor-0.1.0a1.dist-info/entry_points.txt +2 -0
- vecadvisor-0.1.0a1.dist-info/licenses/LICENSE +176 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .sweep import SweepReport, sweep_report_to_json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class StrategyPointSummary:
|
|
16
|
+
strategy: str
|
|
17
|
+
recall_at_k: float
|
|
18
|
+
returns_k_rate: float
|
|
19
|
+
latency_ms_mean: float
|
|
20
|
+
latency_ms_p95: float
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SweepAnalysisPoint:
|
|
25
|
+
target_filter_selectivity: float
|
|
26
|
+
target_correlation: float
|
|
27
|
+
observed_filter_selectivity: float
|
|
28
|
+
s_local_p10: float
|
|
29
|
+
s_local_median: float
|
|
30
|
+
measured_best: str
|
|
31
|
+
predicted_best: str | None
|
|
32
|
+
prediction_match: bool | None
|
|
33
|
+
postfilter_recall_at_k: float | None
|
|
34
|
+
postfilter_returns_k_rate: float | None
|
|
35
|
+
postfilter_viable: bool | None
|
|
36
|
+
strategies: tuple[StrategyPointSummary, ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class WinnerRegion:
|
|
41
|
+
kind: str
|
|
42
|
+
correlation: float
|
|
43
|
+
strategy: str
|
|
44
|
+
selectivity_min: float
|
|
45
|
+
selectivity_max: float
|
|
46
|
+
point_count: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class WinnerCrossover:
|
|
51
|
+
kind: str
|
|
52
|
+
correlation: float
|
|
53
|
+
lower_selectivity: float
|
|
54
|
+
upper_selectivity: float
|
|
55
|
+
estimated_selectivity: float
|
|
56
|
+
from_strategy: str
|
|
57
|
+
to_strategy: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class CrossoverAnalysis:
|
|
62
|
+
backend: str
|
|
63
|
+
point_count: int
|
|
64
|
+
correlations: tuple[float, ...]
|
|
65
|
+
recall_target: float
|
|
66
|
+
returns_k_target: float
|
|
67
|
+
prediction_match_rate: float | None
|
|
68
|
+
measured_win_counts: Mapping[str, int]
|
|
69
|
+
predicted_win_counts: Mapping[str, int]
|
|
70
|
+
regions: tuple[WinnerRegion, ...]
|
|
71
|
+
measured_crossovers: tuple[WinnerCrossover, ...]
|
|
72
|
+
predicted_crossovers: tuple[WinnerCrossover, ...]
|
|
73
|
+
postfilter_failure_count: int
|
|
74
|
+
points: tuple[SweepAnalysisPoint, ...]
|
|
75
|
+
notes: tuple[str, ...] = ()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_sweep_payload(path: Path) -> Mapping[str, Any]:
|
|
79
|
+
"""Load a JSON sweep report produced by benchmark-sweep or benchmark-sweep-db."""
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
83
|
+
except OSError as exc:
|
|
84
|
+
raise ValueError(f"could not read sweep report: {path}") from exc
|
|
85
|
+
except json.JSONDecodeError as exc:
|
|
86
|
+
raise ValueError(f"sweep report JSON is invalid: {exc}") from exc
|
|
87
|
+
if not isinstance(raw, Mapping):
|
|
88
|
+
raise ValueError("sweep report must be a JSON object")
|
|
89
|
+
return raw
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def analyze_sweep_report(report: SweepReport) -> CrossoverAnalysis:
|
|
93
|
+
"""Analyze an in-memory sweep report."""
|
|
94
|
+
|
|
95
|
+
return analyze_sweep_payload(sweep_report_to_json(report, calibration_source="in-memory"))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def analyze_sweep_payload(payload: Mapping[str, Any]) -> CrossoverAnalysis:
|
|
99
|
+
"""Compute winner regions, crossovers, and prediction quality from sweep JSON."""
|
|
100
|
+
|
|
101
|
+
sweep = _mapping_value(payload, "sweep")
|
|
102
|
+
backend = _str_value(sweep, "backend")
|
|
103
|
+
recall_target = _float_value(sweep, "recall_target")
|
|
104
|
+
returns_k_target = _float_value(sweep, "returns_k_target")
|
|
105
|
+
points = tuple(
|
|
106
|
+
_analysis_point(point, recall_target=recall_target, returns_k_target=returns_k_target)
|
|
107
|
+
for point in _sequence_value(payload, "points")
|
|
108
|
+
)
|
|
109
|
+
if not points:
|
|
110
|
+
raise ValueError("sweep report must contain at least one point")
|
|
111
|
+
|
|
112
|
+
correlations = tuple(sorted({point.target_correlation for point in points}))
|
|
113
|
+
predicted_points = [point for point in points if point.predicted_best is not None]
|
|
114
|
+
prediction_match_rate = (
|
|
115
|
+
sum(1 for point in predicted_points if point.prediction_match) / len(predicted_points)
|
|
116
|
+
if predicted_points
|
|
117
|
+
else None
|
|
118
|
+
)
|
|
119
|
+
measured_win_counts = dict(Counter(point.measured_best for point in points))
|
|
120
|
+
predicted_winners: list[str] = []
|
|
121
|
+
for point in predicted_points:
|
|
122
|
+
assert point.predicted_best is not None
|
|
123
|
+
predicted_winners.append(point.predicted_best)
|
|
124
|
+
predicted_win_counts = dict(Counter(predicted_winners))
|
|
125
|
+
postfilter_failure_count = sum(1 for point in points if point.postfilter_viable is False)
|
|
126
|
+
|
|
127
|
+
measured_regions = _winner_regions(points, kind="measured")
|
|
128
|
+
predicted_regions = _winner_regions(points, kind="predicted")
|
|
129
|
+
measured_crossovers = _winner_crossovers(points, kind="measured")
|
|
130
|
+
predicted_crossovers = _winner_crossovers(points, kind="predicted")
|
|
131
|
+
|
|
132
|
+
return CrossoverAnalysis(
|
|
133
|
+
backend=backend,
|
|
134
|
+
point_count=len(points),
|
|
135
|
+
correlations=correlations,
|
|
136
|
+
recall_target=recall_target,
|
|
137
|
+
returns_k_target=returns_k_target,
|
|
138
|
+
prediction_match_rate=prediction_match_rate,
|
|
139
|
+
measured_win_counts=measured_win_counts,
|
|
140
|
+
predicted_win_counts=predicted_win_counts,
|
|
141
|
+
regions=(*measured_regions, *predicted_regions),
|
|
142
|
+
measured_crossovers=measured_crossovers,
|
|
143
|
+
predicted_crossovers=predicted_crossovers,
|
|
144
|
+
postfilter_failure_count=postfilter_failure_count,
|
|
145
|
+
points=points,
|
|
146
|
+
notes=(
|
|
147
|
+
"crossovers are estimated between adjacent sampled selectivity points",
|
|
148
|
+
"estimated_selectivity uses the geometric midpoint for positive selectivities",
|
|
149
|
+
"postfilter_failure_count uses the sweep recall and returns-k targets",
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def crossover_analysis_to_json(analysis: CrossoverAnalysis) -> dict[str, object]:
|
|
155
|
+
return {
|
|
156
|
+
"analysis": {
|
|
157
|
+
"backend": analysis.backend,
|
|
158
|
+
"point_count": analysis.point_count,
|
|
159
|
+
"correlations": list(analysis.correlations),
|
|
160
|
+
"recall_target": analysis.recall_target,
|
|
161
|
+
"returns_k_target": analysis.returns_k_target,
|
|
162
|
+
"prediction_match_rate": analysis.prediction_match_rate,
|
|
163
|
+
"postfilter_failure_count": analysis.postfilter_failure_count,
|
|
164
|
+
},
|
|
165
|
+
"measured_win_counts": dict(analysis.measured_win_counts),
|
|
166
|
+
"predicted_win_counts": dict(analysis.predicted_win_counts),
|
|
167
|
+
"regions": [_region_to_json(region) for region in analysis.regions],
|
|
168
|
+
"measured_crossovers": [
|
|
169
|
+
_crossover_to_json(crossover) for crossover in analysis.measured_crossovers
|
|
170
|
+
],
|
|
171
|
+
"predicted_crossovers": [
|
|
172
|
+
_crossover_to_json(crossover) for crossover in analysis.predicted_crossovers
|
|
173
|
+
],
|
|
174
|
+
"points": [_point_to_json(point) for point in analysis.points],
|
|
175
|
+
"notes": list(analysis.notes),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def write_crossover_analysis(analysis: CrossoverAnalysis, path: Path) -> None:
|
|
180
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
path.write_text(
|
|
182
|
+
json.dumps(crossover_analysis_to_json(analysis), indent=2) + "\n",
|
|
183
|
+
encoding="utf-8",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _analysis_point(
|
|
188
|
+
raw: object,
|
|
189
|
+
*,
|
|
190
|
+
recall_target: float,
|
|
191
|
+
returns_k_target: float,
|
|
192
|
+
) -> SweepAnalysisPoint:
|
|
193
|
+
point = _as_mapping(raw, "sweep point")
|
|
194
|
+
dataset = _mapping_value(point, "dataset")
|
|
195
|
+
local = _mapping_value(point, "local_selectivity")
|
|
196
|
+
strategies = tuple(
|
|
197
|
+
_strategy_summary(strategy) for strategy in _sequence_value(point, "strategies")
|
|
198
|
+
)
|
|
199
|
+
postfilter = next(
|
|
200
|
+
(strategy for strategy in strategies if strategy.strategy == "postfilter"),
|
|
201
|
+
None,
|
|
202
|
+
)
|
|
203
|
+
postfilter_viable = (
|
|
204
|
+
postfilter.recall_at_k >= recall_target
|
|
205
|
+
and postfilter.returns_k_rate >= returns_k_target
|
|
206
|
+
if postfilter is not None
|
|
207
|
+
else None
|
|
208
|
+
)
|
|
209
|
+
return SweepAnalysisPoint(
|
|
210
|
+
target_filter_selectivity=_float_value(point, "target_filter_selectivity"),
|
|
211
|
+
target_correlation=_float_value(point, "target_correlation"),
|
|
212
|
+
observed_filter_selectivity=_float_value(dataset, "observed_filter_selectivity"),
|
|
213
|
+
s_local_p10=_float_value(local, "s_local_p10"),
|
|
214
|
+
s_local_median=_float_value(local, "s_local_median"),
|
|
215
|
+
measured_best=_str_value(point, "measured_best"),
|
|
216
|
+
predicted_best=_optional_str_value(point, "predicted_best"),
|
|
217
|
+
prediction_match=_optional_bool_value(point, "prediction_match"),
|
|
218
|
+
postfilter_recall_at_k=None if postfilter is None else postfilter.recall_at_k,
|
|
219
|
+
postfilter_returns_k_rate=None if postfilter is None else postfilter.returns_k_rate,
|
|
220
|
+
postfilter_viable=postfilter_viable,
|
|
221
|
+
strategies=strategies,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _strategy_summary(raw: object) -> StrategyPointSummary:
|
|
226
|
+
strategy = _as_mapping(raw, "strategy")
|
|
227
|
+
latency = _mapping_value(strategy, "latency_ms")
|
|
228
|
+
return StrategyPointSummary(
|
|
229
|
+
strategy=_str_value(strategy, "strategy"),
|
|
230
|
+
recall_at_k=_float_value(strategy, "recall_at_k"),
|
|
231
|
+
returns_k_rate=_float_value(strategy, "returns_k_rate"),
|
|
232
|
+
latency_ms_mean=_float_value(latency, "mean"),
|
|
233
|
+
latency_ms_p95=_float_value(latency, "p95"),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _winner_regions(
|
|
238
|
+
points: Sequence[SweepAnalysisPoint],
|
|
239
|
+
*,
|
|
240
|
+
kind: str,
|
|
241
|
+
) -> tuple[WinnerRegion, ...]:
|
|
242
|
+
regions: list[WinnerRegion] = []
|
|
243
|
+
for correlation in sorted({point.target_correlation for point in points}):
|
|
244
|
+
group = _points_for_correlation(points, correlation=correlation, kind=kind)
|
|
245
|
+
if not group:
|
|
246
|
+
continue
|
|
247
|
+
start = group[0]
|
|
248
|
+
current_strategy = _winner(start, kind=kind)
|
|
249
|
+
assert current_strategy is not None
|
|
250
|
+
current_points = [start]
|
|
251
|
+
for point in group[1:]:
|
|
252
|
+
strategy = _winner(point, kind=kind)
|
|
253
|
+
if strategy == current_strategy:
|
|
254
|
+
current_points.append(point)
|
|
255
|
+
continue
|
|
256
|
+
regions.append(_region_from_points(kind, correlation, current_strategy, current_points))
|
|
257
|
+
assert strategy is not None
|
|
258
|
+
current_strategy = strategy
|
|
259
|
+
current_points = [point]
|
|
260
|
+
regions.append(_region_from_points(kind, correlation, current_strategy, current_points))
|
|
261
|
+
return tuple(regions)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _winner_crossovers(
|
|
265
|
+
points: Sequence[SweepAnalysisPoint],
|
|
266
|
+
*,
|
|
267
|
+
kind: str,
|
|
268
|
+
) -> tuple[WinnerCrossover, ...]:
|
|
269
|
+
crossovers: list[WinnerCrossover] = []
|
|
270
|
+
for correlation in sorted({point.target_correlation for point in points}):
|
|
271
|
+
group = _points_for_correlation(points, correlation=correlation, kind=kind)
|
|
272
|
+
for lower, upper in zip(group, group[1:], strict=False):
|
|
273
|
+
lower_winner = _winner(lower, kind=kind)
|
|
274
|
+
upper_winner = _winner(upper, kind=kind)
|
|
275
|
+
if lower_winner is None or upper_winner is None or lower_winner == upper_winner:
|
|
276
|
+
continue
|
|
277
|
+
crossovers.append(
|
|
278
|
+
WinnerCrossover(
|
|
279
|
+
kind=kind,
|
|
280
|
+
correlation=correlation,
|
|
281
|
+
lower_selectivity=lower.target_filter_selectivity,
|
|
282
|
+
upper_selectivity=upper.target_filter_selectivity,
|
|
283
|
+
estimated_selectivity=_midpoint_selectivity(
|
|
284
|
+
lower.target_filter_selectivity,
|
|
285
|
+
upper.target_filter_selectivity,
|
|
286
|
+
),
|
|
287
|
+
from_strategy=lower_winner,
|
|
288
|
+
to_strategy=upper_winner,
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
return tuple(crossovers)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _points_for_correlation(
|
|
295
|
+
points: Sequence[SweepAnalysisPoint],
|
|
296
|
+
*,
|
|
297
|
+
correlation: float,
|
|
298
|
+
kind: str,
|
|
299
|
+
) -> list[SweepAnalysisPoint]:
|
|
300
|
+
return sorted(
|
|
301
|
+
(
|
|
302
|
+
point
|
|
303
|
+
for point in points
|
|
304
|
+
if point.target_correlation == correlation and _winner(point, kind=kind) is not None
|
|
305
|
+
),
|
|
306
|
+
key=lambda point: point.target_filter_selectivity,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _winner(point: SweepAnalysisPoint, *, kind: str) -> str | None:
|
|
311
|
+
if kind == "measured":
|
|
312
|
+
return point.measured_best
|
|
313
|
+
if kind == "predicted":
|
|
314
|
+
return point.predicted_best
|
|
315
|
+
raise ValueError("kind must be measured or predicted")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _region_from_points(
|
|
319
|
+
kind: str,
|
|
320
|
+
correlation: float,
|
|
321
|
+
strategy: str,
|
|
322
|
+
points: Sequence[SweepAnalysisPoint],
|
|
323
|
+
) -> WinnerRegion:
|
|
324
|
+
selectivities = [point.target_filter_selectivity for point in points]
|
|
325
|
+
return WinnerRegion(
|
|
326
|
+
kind=kind,
|
|
327
|
+
correlation=correlation,
|
|
328
|
+
strategy=strategy,
|
|
329
|
+
selectivity_min=min(selectivities),
|
|
330
|
+
selectivity_max=max(selectivities),
|
|
331
|
+
point_count=len(points),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _midpoint_selectivity(lower: float, upper: float) -> float:
|
|
336
|
+
if lower > 0.0 and upper > 0.0:
|
|
337
|
+
return math.sqrt(lower * upper)
|
|
338
|
+
return (lower + upper) / 2.0
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _point_to_json(point: SweepAnalysisPoint) -> dict[str, object]:
|
|
342
|
+
return {
|
|
343
|
+
"target_filter_selectivity": point.target_filter_selectivity,
|
|
344
|
+
"target_correlation": point.target_correlation,
|
|
345
|
+
"observed_filter_selectivity": point.observed_filter_selectivity,
|
|
346
|
+
"s_local_p10": point.s_local_p10,
|
|
347
|
+
"s_local_median": point.s_local_median,
|
|
348
|
+
"measured_best": point.measured_best,
|
|
349
|
+
"predicted_best": point.predicted_best,
|
|
350
|
+
"prediction_match": point.prediction_match,
|
|
351
|
+
"postfilter_recall_at_k": point.postfilter_recall_at_k,
|
|
352
|
+
"postfilter_returns_k_rate": point.postfilter_returns_k_rate,
|
|
353
|
+
"postfilter_viable": point.postfilter_viable,
|
|
354
|
+
"strategies": [_strategy_to_json(strategy) for strategy in point.strategies],
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _strategy_to_json(strategy: StrategyPointSummary) -> dict[str, object]:
|
|
359
|
+
return {
|
|
360
|
+
"strategy": strategy.strategy,
|
|
361
|
+
"recall_at_k": strategy.recall_at_k,
|
|
362
|
+
"returns_k_rate": strategy.returns_k_rate,
|
|
363
|
+
"latency_ms_mean": strategy.latency_ms_mean,
|
|
364
|
+
"latency_ms_p95": strategy.latency_ms_p95,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _region_to_json(region: WinnerRegion) -> dict[str, object]:
|
|
369
|
+
return {
|
|
370
|
+
"kind": region.kind,
|
|
371
|
+
"correlation": region.correlation,
|
|
372
|
+
"strategy": region.strategy,
|
|
373
|
+
"selectivity_min": region.selectivity_min,
|
|
374
|
+
"selectivity_max": region.selectivity_max,
|
|
375
|
+
"point_count": region.point_count,
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _crossover_to_json(crossover: WinnerCrossover) -> dict[str, object]:
|
|
380
|
+
return {
|
|
381
|
+
"kind": crossover.kind,
|
|
382
|
+
"correlation": crossover.correlation,
|
|
383
|
+
"lower_selectivity": crossover.lower_selectivity,
|
|
384
|
+
"upper_selectivity": crossover.upper_selectivity,
|
|
385
|
+
"estimated_selectivity": crossover.estimated_selectivity,
|
|
386
|
+
"from_strategy": crossover.from_strategy,
|
|
387
|
+
"to_strategy": crossover.to_strategy,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _mapping_value(payload: Mapping[str, Any], key: str) -> Mapping[str, Any]:
|
|
392
|
+
return _as_mapping(payload.get(key), key)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _sequence_value(payload: Mapping[str, Any], key: str) -> Sequence[object]:
|
|
396
|
+
value = payload.get(key)
|
|
397
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
|
|
398
|
+
raise ValueError(f"{key} must be a list")
|
|
399
|
+
return value
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _as_mapping(value: object, name: str) -> Mapping[str, Any]:
|
|
403
|
+
if not isinstance(value, Mapping):
|
|
404
|
+
raise ValueError(f"{name} must be an object")
|
|
405
|
+
return value
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _str_value(payload: Mapping[str, Any], key: str) -> str:
|
|
409
|
+
value = payload.get(key)
|
|
410
|
+
if not isinstance(value, str) or not value:
|
|
411
|
+
raise ValueError(f"{key} must be a non-empty string")
|
|
412
|
+
return value
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _optional_str_value(payload: Mapping[str, Any], key: str) -> str | None:
|
|
416
|
+
value = payload.get(key)
|
|
417
|
+
if value is None:
|
|
418
|
+
return None
|
|
419
|
+
if not isinstance(value, str) or not value:
|
|
420
|
+
raise ValueError(f"{key} must be null or a non-empty string")
|
|
421
|
+
return value
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _optional_bool_value(payload: Mapping[str, Any], key: str) -> bool | None:
|
|
425
|
+
value = payload.get(key)
|
|
426
|
+
if value is None:
|
|
427
|
+
return None
|
|
428
|
+
if not isinstance(value, bool):
|
|
429
|
+
raise ValueError(f"{key} must be null or boolean")
|
|
430
|
+
return value
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _float_value(payload: Mapping[str, Any], key: str) -> float:
|
|
434
|
+
value = payload.get(key)
|
|
435
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
436
|
+
raise ValueError(f"{key} must be numeric")
|
|
437
|
+
number = float(value)
|
|
438
|
+
if not math.isfinite(number):
|
|
439
|
+
raise ValueError(f"{key} must be finite")
|
|
440
|
+
return number
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class SyntheticDataset:
|
|
11
|
+
vectors: Any
|
|
12
|
+
filter_mask: Any
|
|
13
|
+
cluster_ids: Any
|
|
14
|
+
centers: Any
|
|
15
|
+
filter_probabilities: tuple[float, ...]
|
|
16
|
+
filter_selectivity: float
|
|
17
|
+
correlation: float
|
|
18
|
+
seed: int
|
|
19
|
+
dataset_id: str = "synthetic"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def n_rows(self) -> int:
|
|
23
|
+
return int(self.vectors.shape[0])
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def dim(self) -> int:
|
|
27
|
+
return int(self.vectors.shape[1])
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def observed_selectivity(self) -> float:
|
|
31
|
+
return float(self.filter_mask.mean())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class SyntheticQueries:
|
|
36
|
+
vectors: Any
|
|
37
|
+
cluster_ids: Any
|
|
38
|
+
seed: int
|
|
39
|
+
cluster_policy: str
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def n_queries(self) -> int:
|
|
43
|
+
return int(self.vectors.shape[0])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def generate_synthetic_dataset(
|
|
47
|
+
*,
|
|
48
|
+
n_rows: int = 10_000,
|
|
49
|
+
dim: int = 64,
|
|
50
|
+
n_clusters: int = 16,
|
|
51
|
+
filter_selectivity: float = 0.1,
|
|
52
|
+
correlation: float = 0.0,
|
|
53
|
+
seed: int = 0,
|
|
54
|
+
cluster_std: float = 0.08,
|
|
55
|
+
) -> SyntheticDataset:
|
|
56
|
+
"""Generate clustered vectors with a tunable correlated boolean filter."""
|
|
57
|
+
|
|
58
|
+
if n_rows <= 0:
|
|
59
|
+
raise ValueError("n_rows must be positive")
|
|
60
|
+
if dim <= 0:
|
|
61
|
+
raise ValueError("dim must be positive")
|
|
62
|
+
if n_clusters <= 0:
|
|
63
|
+
raise ValueError("n_clusters must be positive")
|
|
64
|
+
if not 0.0 < filter_selectivity < 1.0:
|
|
65
|
+
raise ValueError("filter_selectivity must be in (0, 1)")
|
|
66
|
+
if not -1.0 <= correlation <= 1.0:
|
|
67
|
+
raise ValueError("correlation must be in [-1, 1]")
|
|
68
|
+
if cluster_std <= 0.0:
|
|
69
|
+
raise ValueError("cluster_std must be positive")
|
|
70
|
+
|
|
71
|
+
np = _numpy()
|
|
72
|
+
rng = np.random.default_rng(seed)
|
|
73
|
+
centers = rng.normal(0.0, 1.0, size=(n_clusters, dim)).astype("float32")
|
|
74
|
+
centers /= np.maximum(np.linalg.norm(centers, axis=1, keepdims=True), 1e-12)
|
|
75
|
+
|
|
76
|
+
cluster_ids = rng.integers(0, n_clusters, size=n_rows, dtype="int32")
|
|
77
|
+
vectors = centers[cluster_ids] + rng.normal(0.0, cluster_std, size=(n_rows, dim)).astype(
|
|
78
|
+
"float32"
|
|
79
|
+
)
|
|
80
|
+
probabilities = _cluster_filter_probabilities(
|
|
81
|
+
filter_selectivity=filter_selectivity,
|
|
82
|
+
correlation=correlation,
|
|
83
|
+
n_clusters=n_clusters,
|
|
84
|
+
)
|
|
85
|
+
probability_array = np.asarray(probabilities, dtype="float64")
|
|
86
|
+
filter_mask = rng.random(n_rows) < probability_array[cluster_ids]
|
|
87
|
+
if not bool(filter_mask.any()):
|
|
88
|
+
filter_mask[int(rng.integers(0, n_rows))] = True
|
|
89
|
+
if bool(filter_mask.all()):
|
|
90
|
+
filter_mask[int(rng.integers(0, n_rows))] = False
|
|
91
|
+
|
|
92
|
+
return SyntheticDataset(
|
|
93
|
+
vectors=vectors.astype("float32", copy=False),
|
|
94
|
+
filter_mask=filter_mask,
|
|
95
|
+
cluster_ids=cluster_ids,
|
|
96
|
+
centers=centers,
|
|
97
|
+
filter_probabilities=probabilities,
|
|
98
|
+
filter_selectivity=filter_selectivity,
|
|
99
|
+
correlation=correlation,
|
|
100
|
+
seed=seed,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def generate_synthetic_queries(
|
|
105
|
+
dataset: SyntheticDataset,
|
|
106
|
+
*,
|
|
107
|
+
n_queries: int = 100,
|
|
108
|
+
seed: int = 1,
|
|
109
|
+
cluster_policy: str = "uniform",
|
|
110
|
+
query_std: float = 0.04,
|
|
111
|
+
) -> SyntheticQueries:
|
|
112
|
+
"""Generate query vectors around dataset clusters."""
|
|
113
|
+
|
|
114
|
+
if n_queries <= 0:
|
|
115
|
+
raise ValueError("n_queries must be positive")
|
|
116
|
+
if query_std <= 0.0:
|
|
117
|
+
raise ValueError("query_std must be positive")
|
|
118
|
+
|
|
119
|
+
np = _numpy()
|
|
120
|
+
rng = np.random.default_rng(seed)
|
|
121
|
+
n_clusters = int(dataset.centers.shape[0])
|
|
122
|
+
if cluster_policy == "uniform":
|
|
123
|
+
probabilities = None
|
|
124
|
+
elif cluster_policy == "filter_hot":
|
|
125
|
+
weights = np.asarray(dataset.filter_probabilities, dtype="float64")
|
|
126
|
+
probabilities = weights / weights.sum()
|
|
127
|
+
elif cluster_policy == "filter_cold":
|
|
128
|
+
weights = 1.0 - np.asarray(dataset.filter_probabilities, dtype="float64")
|
|
129
|
+
probabilities = weights / weights.sum()
|
|
130
|
+
else:
|
|
131
|
+
raise ValueError("cluster_policy must be one of: uniform, filter_hot, filter_cold")
|
|
132
|
+
|
|
133
|
+
cluster_ids = rng.choice(n_clusters, size=n_queries, replace=True, p=probabilities).astype(
|
|
134
|
+
"int32"
|
|
135
|
+
)
|
|
136
|
+
vectors = dataset.centers[cluster_ids] + rng.normal(
|
|
137
|
+
0.0,
|
|
138
|
+
query_std,
|
|
139
|
+
size=(n_queries, dataset.dim),
|
|
140
|
+
).astype("float32")
|
|
141
|
+
return SyntheticQueries(
|
|
142
|
+
vectors=vectors.astype("float32", copy=False),
|
|
143
|
+
cluster_ids=cluster_ids,
|
|
144
|
+
seed=seed,
|
|
145
|
+
cluster_policy=cluster_policy,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def load_file_dataset(
|
|
150
|
+
*,
|
|
151
|
+
vectors_path: Path,
|
|
152
|
+
filter_mask_path: Path,
|
|
153
|
+
dataset_id: str = "file",
|
|
154
|
+
) -> SyntheticDataset:
|
|
155
|
+
"""Load vectors and a boolean filter mask from .npy files."""
|
|
156
|
+
|
|
157
|
+
np = _numpy()
|
|
158
|
+
vectors = np.load(vectors_path, mmap_mode="r")
|
|
159
|
+
if int(vectors.ndim) != 2:
|
|
160
|
+
raise ValueError("vectors .npy must be a 2D array")
|
|
161
|
+
if int(vectors.shape[0]) <= 0 or int(vectors.shape[1]) <= 0:
|
|
162
|
+
raise ValueError("vectors .npy must have at least one row and one dimension")
|
|
163
|
+
if vectors.dtype.kind not in {"f", "i", "u"}:
|
|
164
|
+
raise ValueError("vectors .npy must contain numeric values")
|
|
165
|
+
|
|
166
|
+
raw_mask = np.load(filter_mask_path, mmap_mode="r")
|
|
167
|
+
if int(raw_mask.ndim) != 1:
|
|
168
|
+
raise ValueError("filter mask .npy must be a 1D array")
|
|
169
|
+
if int(raw_mask.shape[0]) != int(vectors.shape[0]):
|
|
170
|
+
raise ValueError("filter mask length must match vector row count")
|
|
171
|
+
if raw_mask.dtype.kind not in {"b", "i", "u", "f"}:
|
|
172
|
+
raise ValueError("filter mask .npy must contain boolean or numeric values")
|
|
173
|
+
|
|
174
|
+
filter_mask = np.asarray(raw_mask, dtype="bool")
|
|
175
|
+
observed_selectivity = float(filter_mask.mean())
|
|
176
|
+
cluster_ids = np.zeros(int(vectors.shape[0]), dtype="int32")
|
|
177
|
+
centers = np.zeros((1, int(vectors.shape[1])), dtype="float32")
|
|
178
|
+
return SyntheticDataset(
|
|
179
|
+
vectors=vectors,
|
|
180
|
+
filter_mask=filter_mask,
|
|
181
|
+
cluster_ids=cluster_ids,
|
|
182
|
+
centers=centers,
|
|
183
|
+
filter_probabilities=(observed_selectivity,),
|
|
184
|
+
filter_selectivity=observed_selectivity,
|
|
185
|
+
correlation=0.0,
|
|
186
|
+
seed=0,
|
|
187
|
+
dataset_id=dataset_id,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def load_file_queries(
|
|
192
|
+
*,
|
|
193
|
+
query_vectors_path: Path,
|
|
194
|
+
expected_dim: int,
|
|
195
|
+
) -> SyntheticQueries:
|
|
196
|
+
"""Load benchmark query vectors from a .npy file."""
|
|
197
|
+
|
|
198
|
+
np = _numpy()
|
|
199
|
+
vectors = np.load(query_vectors_path, mmap_mode="r")
|
|
200
|
+
if int(vectors.ndim) != 2:
|
|
201
|
+
raise ValueError("query vectors .npy must be a 2D array")
|
|
202
|
+
if int(vectors.shape[0]) <= 0:
|
|
203
|
+
raise ValueError("query vectors .npy must contain at least one query")
|
|
204
|
+
if int(vectors.shape[1]) != expected_dim:
|
|
205
|
+
raise ValueError("query vector dimension must match dataset vector dimension")
|
|
206
|
+
if vectors.dtype.kind not in {"f", "i", "u"}:
|
|
207
|
+
raise ValueError("query vectors .npy must contain numeric values")
|
|
208
|
+
return SyntheticQueries(
|
|
209
|
+
vectors=vectors,
|
|
210
|
+
cluster_ids=np.zeros(int(vectors.shape[0]), dtype="int32"),
|
|
211
|
+
seed=0,
|
|
212
|
+
cluster_policy="file",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _cluster_filter_probabilities(
|
|
217
|
+
*,
|
|
218
|
+
filter_selectivity: float,
|
|
219
|
+
correlation: float,
|
|
220
|
+
n_clusters: int,
|
|
221
|
+
) -> tuple[float, ...]:
|
|
222
|
+
np = _numpy()
|
|
223
|
+
if abs(correlation) < 1e-12:
|
|
224
|
+
return tuple(float(filter_selectivity) for _ in range(n_clusters))
|
|
225
|
+
|
|
226
|
+
scores = np.linspace(1.0, -1.0, n_clusters, dtype="float64")
|
|
227
|
+
if correlation < 0.0:
|
|
228
|
+
scores = -scores
|
|
229
|
+
weights = np.exp(abs(correlation) * 4.0 * scores)
|
|
230
|
+
low = 0.0
|
|
231
|
+
high = max(1.0, filter_selectivity / float(weights.mean()))
|
|
232
|
+
while float(np.minimum(1.0, high * weights).mean()) < filter_selectivity:
|
|
233
|
+
high *= 2.0
|
|
234
|
+
for _ in range(64):
|
|
235
|
+
mid = (low + high) / 2.0
|
|
236
|
+
mean = float(np.minimum(1.0, mid * weights).mean())
|
|
237
|
+
if mean < filter_selectivity:
|
|
238
|
+
low = mid
|
|
239
|
+
else:
|
|
240
|
+
high = mid
|
|
241
|
+
probabilities = np.minimum(1.0, high * weights)
|
|
242
|
+
return tuple(float(value) for value in probabilities)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _numpy() -> Any:
|
|
246
|
+
return importlib.import_module("numpy")
|