vecshift 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.
Files changed (62) hide show
  1. vecshift/__init__.py +15 -0
  2. vecshift/assets/bench.css +39 -0
  3. vecshift/assets/eval.css +91 -0
  4. vecshift/assets/report.css +252 -0
  5. vecshift/assets/report.js +50 -0
  6. vecshift/bench/__init__.py +18 -0
  7. vecshift/bench/corpus.py +175 -0
  8. vecshift/bench/generate.py +106 -0
  9. vecshift/bench/html.py +325 -0
  10. vecshift/bench/metrics.py +50 -0
  11. vecshift/bench/runner.py +183 -0
  12. vecshift/cli.py +236 -0
  13. vecshift/cli_apply.py +283 -0
  14. vecshift/cli_bench.py +354 -0
  15. vecshift/cli_cutover.py +431 -0
  16. vecshift/cli_eval.py +591 -0
  17. vecshift/cli_plan.py +335 -0
  18. vecshift/cli_style.py +57 -0
  19. vecshift/connectors/__init__.py +1 -0
  20. vecshift/connectors/pgvector/__init__.py +29 -0
  21. vecshift/connectors/pgvector/connection.py +155 -0
  22. vecshift/connectors/pgvector/documents.py +96 -0
  23. vecshift/connectors/pgvector/inspect.py +427 -0
  24. vecshift/connectors/pgvector/search.py +240 -0
  25. vecshift/connectors/pgvector/switch.py +481 -0
  26. vecshift/connectors/pgvector/target.py +195 -0
  27. vecshift/connectors/pgvector/writer.py +431 -0
  28. vecshift/core/__init__.py +4 -0
  29. vecshift/core/capabilities.py +33 -0
  30. vecshift/core/contracts.py +57 -0
  31. vecshift/core/fingerprint.py +57 -0
  32. vecshift/core/record.py +75 -0
  33. vecshift/doctor/__init__.py +15 -0
  34. vecshift/doctor/checks.py +490 -0
  35. vecshift/doctor/findings.py +78 -0
  36. vecshift/doctor/html.py +493 -0
  37. vecshift/doctor/profile.py +69 -0
  38. vecshift/embeddings/__init__.py +22 -0
  39. vecshift/embeddings/cache.py +86 -0
  40. vecshift/embeddings/providers.py +244 -0
  41. vecshift/embeddings/spec.py +240 -0
  42. vecshift/eval/__init__.py +20 -0
  43. vecshift/eval/html.py +444 -0
  44. vecshift/eval/metrics.py +81 -0
  45. vecshift/eval/queries.py +97 -0
  46. vecshift/eval/runner.py +394 -0
  47. vecshift/html_kit.py +143 -0
  48. vecshift/jobs/__init__.py +5 -0
  49. vecshift/jobs/spec.py +202 -0
  50. vecshift/migrate/__init__.py +6 -0
  51. vecshift/migrate/engine.py +272 -0
  52. vecshift/migrate/state.py +50 -0
  53. vecshift/planning/__init__.py +14 -0
  54. vecshift/planning/plan.py +87 -0
  55. vecshift/planning/planner.py +493 -0
  56. vecshift/py.typed +0 -0
  57. vecshift-0.1.0.dist-info/METADATA +264 -0
  58. vecshift-0.1.0.dist-info/RECORD +62 -0
  59. vecshift-0.1.0.dist-info/WHEEL +4 -0
  60. vecshift-0.1.0.dist-info/entry_points.txt +2 -0
  61. vecshift-0.1.0.dist-info/licenses/LICENSE +202 -0
  62. vecshift-0.1.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,244 @@
1
+ """Embedding providers that satisfy :class:`vecshift.core.contracts.EmbeddingProvider`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import math
8
+ import os
9
+ import random
10
+ import re
11
+ import time
12
+ from collections.abc import Sequence
13
+ from typing import Any, Literal
14
+
15
+ import httpx
16
+
17
+ from vecshift.core.fingerprint import EmbeddingFingerprint
18
+ from vecshift.embeddings.spec import ModelSpec
19
+
20
+ EmbedMode = Literal["document", "query"]
21
+
22
+ RETRY_STATUSES = {408, 409, 429, 500, 502, 503, 504}
23
+ MAX_ATTEMPTS = 6
24
+ CONCURRENCY = 4
25
+
26
+ _STOPWORDS = (
27
+ "a an and are as at be by for from has have in is it its of on or that the this to was "
28
+ "were will with which you your can not but if then than so such these those there their "
29
+ "they them we our may also into when where how what"
30
+ )
31
+
32
+
33
+ class EmbeddingError(Exception):
34
+ """A provider call failed for good. The message never contains credentials."""
35
+
36
+
37
+ def api_key(spec: ModelSpec, error: type[Exception]) -> str | None:
38
+ """The API key for ``spec``, after checking it can be sent safely."""
39
+ key = os.environ.get(spec.key_env) if spec.key_env else None
40
+ if spec.provider == "openai" and not key:
41
+ raise error(
42
+ f"{spec.name}: set {spec.key_env} to your OpenAI API key "
43
+ "(or key_env=NAME in the spec to read it from another variable)"
44
+ )
45
+ if key and spec.sends_plaintext:
46
+ raise error(
47
+ f"{spec.name}: refusing to send an API key over unencrypted http:// to a remote "
48
+ "host. Use an https:// URL."
49
+ )
50
+ return key
51
+
52
+
53
+ class _Base:
54
+ def __init__(self, spec: ModelSpec) -> None:
55
+ self.spec = spec
56
+ self.tokens = 0
57
+ """Tokens billed so far, as reported by the provider."""
58
+ self.tokens_estimated = False
59
+ """Whether any token count had to be estimated."""
60
+ self.request_seconds: list[float] = []
61
+ self._dims: int | None = spec.dimensions
62
+
63
+ @property
64
+ def dimensions(self) -> int | None:
65
+ return self._dims
66
+
67
+ @property
68
+ def fingerprint(self) -> EmbeddingFingerprint:
69
+ if not self._dims:
70
+ raise EmbeddingError(f"{self.spec.name}: dimensions are unknown until the first call")
71
+ return EmbeddingFingerprint(
72
+ provider=self.spec.provider,
73
+ model=self.spec.model,
74
+ dimensions=self._dims,
75
+ task=None,
76
+ prefix=(self.spec.query_prefix + "|" + self.spec.doc_prefix) or None,
77
+ normalized=True,
78
+ )
79
+
80
+ def _prefixed(self, texts: Sequence[str], mode: EmbedMode) -> list[str]:
81
+ prefix = self.spec.query_prefix if mode == "query" else self.spec.doc_prefix
82
+ return [prefix + t for t in texts] if prefix else list(texts)
83
+
84
+ async def aclose(self) -> None:
85
+ return None
86
+
87
+
88
+ class HashingEmbedder(_Base):
89
+ """A lexical baseline: hashed bag of words, L2-normalized.
90
+
91
+ Free, offline, and deterministic. It captures word overlap only, so it's a floor
92
+ that real models should beat, and it makes a first run cost nothing.
93
+ """
94
+
95
+ _WORD = re.compile(r"\w+", re.UNICODE)
96
+ _STOP = frozenset(_STOPWORDS.split())
97
+
98
+ def __init__(self, spec: ModelSpec) -> None:
99
+ super().__init__(spec)
100
+ if not spec.dimensions:
101
+ raise EmbeddingError(f"{spec.name}: hash models need a size, e.g. hash/512")
102
+ self._dims = spec.dimensions
103
+
104
+ def _vector(self, text: str) -> list[float]:
105
+ dims = self._dims or 1
106
+ counts: dict[int, float] = {}
107
+ words = self._WORD.findall(text.lower())
108
+ self.tokens += len(words)
109
+ words = [w for w in words if w not in self._STOP] or words
110
+ for word in words:
111
+ h = hashlib.blake2b(word.encode(), digest_size=8).digest()
112
+ index = int.from_bytes(h[:4], "little") % dims
113
+ sign = 1.0 if h[4] & 1 else -1.0
114
+ counts[index] = counts.get(index, 0.0) + sign
115
+ vec = [0.0] * dims
116
+ for i, c in counts.items():
117
+ vec[i] = math.copysign(1 + math.log(abs(c)), c) if c else 0.0
118
+ norm = math.sqrt(sum(v * v for v in vec)) or 1.0
119
+ return [v / norm for v in vec]
120
+
121
+ async def embed(self, texts: Sequence[str], mode: EmbedMode = "document") -> list[list[float]]:
122
+ start = time.perf_counter()
123
+ out = [self._vector(t) for t in self._prefixed(texts, mode)]
124
+ self.request_seconds.append(time.perf_counter() - start)
125
+ return out
126
+
127
+
128
+ class OpenAICompatEmbedder(_Base):
129
+ """Any server that speaks OpenAI's ``POST /embeddings``: OpenAI itself, Ollama, vLLM,
130
+ TEI, LM Studio, LiteLLM, and most hosted inference platforms."""
131
+
132
+ def __init__(
133
+ self,
134
+ spec: ModelSpec,
135
+ client: httpx.AsyncClient | None = None,
136
+ sleep: Any = asyncio.sleep,
137
+ ) -> None:
138
+ super().__init__(spec)
139
+ if not spec.url:
140
+ raise EmbeddingError(f"{spec.name}: no URL configured")
141
+ key = api_key(spec, EmbeddingError)
142
+ headers = {"Authorization": f"Bearer {key}"} if key else {}
143
+ # TLS certificates are always verified, and redirects are never followed, so a
144
+ # key can't be carried to a host other than the one in the spec.
145
+ self._client = client or httpx.AsyncClient(
146
+ timeout=httpx.Timeout(120, connect=15),
147
+ headers=headers,
148
+ verify=True,
149
+ follow_redirects=False,
150
+ )
151
+ if client is not None and key:
152
+ self._client.headers.update(headers)
153
+ self._owns_client = client is None
154
+ self._sleep = sleep
155
+ self._limit = asyncio.Semaphore(CONCURRENCY)
156
+
157
+ async def aclose(self) -> None:
158
+ if self._owns_client:
159
+ await self._client.aclose()
160
+
161
+ def _describe(self, response: httpx.Response) -> str:
162
+ try:
163
+ body: Any = response.json()
164
+ message = body.get("error", {})
165
+ message = message.get("message") if isinstance(message, dict) else message
166
+ message = message or body.get("detail") or response.text
167
+ except ValueError:
168
+ message = response.text
169
+ message = " ".join(str(message).split())[:300]
170
+ return f"{self.spec.name}: HTTP {response.status_code}: {message}"
171
+
172
+ async def _post(self, batch: list[str]) -> list[list[float]]:
173
+ payload: dict[str, Any] = {"model": self.spec.model, "input": batch}
174
+ if self.spec.dimensions:
175
+ payload["dimensions"] = self.spec.dimensions
176
+ delay = 1.0
177
+ for attempt in range(1, MAX_ATTEMPTS + 1):
178
+ start = time.perf_counter()
179
+ try:
180
+ response = await self._client.post(f"{self.spec.url}/embeddings", json=payload)
181
+ except httpx.TransportError as exc:
182
+ if attempt == MAX_ATTEMPTS:
183
+ raise EmbeddingError(
184
+ f"{self.spec.name}: couldn't reach {self.spec.url} ({type(exc).__name__})"
185
+ ) from exc
186
+ await self._sleep(delay + random.uniform(0, delay / 2))
187
+ delay = min(delay * 2, 30)
188
+ continue
189
+ elapsed = time.perf_counter() - start
190
+ if response.status_code in RETRY_STATUSES and attempt < MAX_ATTEMPTS:
191
+ retry_after = response.headers.get("retry-after", "")
192
+ wait = float(retry_after) if retry_after.replace(".", "", 1).isdigit() else delay
193
+ await self._sleep(min(wait, 60) + random.uniform(0, 0.5))
194
+ delay = min(delay * 2, 30)
195
+ continue
196
+ if response.status_code >= 400:
197
+ raise EmbeddingError(self._describe(response))
198
+ self.request_seconds.append(elapsed)
199
+ return self._parse(response, batch)
200
+ raise EmbeddingError(
201
+ f"{self.spec.name}: gave up after {MAX_ATTEMPTS} attempts"
202
+ ) # pragma: no cover
203
+
204
+ def _parse(self, response: httpx.Response, batch: list[str]) -> list[list[float]]:
205
+ try:
206
+ body = response.json()
207
+ items = sorted(body["data"], key=lambda d: d.get("index", 0))
208
+ vectors = [list(map(float, item["embedding"])) for item in items]
209
+ except (ValueError, KeyError, TypeError) as exc:
210
+ raise EmbeddingError(f"{self.spec.name}: unexpected response shape") from exc
211
+ if len(vectors) != len(batch):
212
+ raise EmbeddingError(
213
+ f"{self.spec.name}: sent {len(batch)} texts, got {len(vectors)} vectors"
214
+ )
215
+ usage = body.get("usage") or {}
216
+ tokens = usage.get("prompt_tokens") or usage.get("total_tokens")
217
+ if isinstance(tokens, int):
218
+ self.tokens += tokens
219
+ else:
220
+ self.tokens += sum(max(1, len(t) // 4) for t in batch)
221
+ self.tokens_estimated = True
222
+ if vectors:
223
+ self._dims = len(vectors[0])
224
+ return vectors
225
+
226
+ async def _batch(self, batch: list[str]) -> list[list[float]]:
227
+ async with self._limit:
228
+ return await self._post(batch)
229
+
230
+ async def embed(self, texts: Sequence[str], mode: EmbedMode = "document") -> list[list[float]]:
231
+ prepared = self._prefixed(texts, mode)
232
+ size = self.spec.batch_size
233
+ batches = [prepared[i : i + size] for i in range(0, len(prepared), size)]
234
+ results = await asyncio.gather(*(self._batch(b) for b in batches))
235
+ return [vec for batch in results for vec in batch]
236
+
237
+
238
+ Embedder = HashingEmbedder | OpenAICompatEmbedder
239
+
240
+
241
+ def create_embedder(spec: ModelSpec, client: httpx.AsyncClient | None = None) -> Embedder:
242
+ if spec.provider == "hash":
243
+ return HashingEmbedder(spec)
244
+ return OpenAICompatEmbedder(spec, client=client)
@@ -0,0 +1,240 @@
1
+ """Model specs: one string that says which model to call and how.
2
+
3
+ provider/model[,key=value,...]
4
+
5
+ Examples:
6
+
7
+ openai/text-embedding-3-small
8
+ openai/text-embedding-3-large,dims=256
9
+ ollama/nomic-embed-text
10
+ compat/BAAI/bge-m3,url=http://localhost:8080/v1,price=0
11
+ hash/512
12
+
13
+ Options: ``url``, ``dims``, ``price`` (USD per million tokens), ``key_env`` (the
14
+ environment variable holding the API key), ``query_prefix``, ``doc_prefix``, and
15
+ ``batch`` (texts per request).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass
22
+ from urllib.parse import urlparse
23
+
24
+ # Hosts that keep traffic on this machine. ("0.0.0.0" is a destination here, not a bind.)
25
+ LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0", "host.docker.internal"} # noqa: S104
26
+
27
+
28
+ class SpecError(ValueError):
29
+ """A model spec couldn't be understood."""
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Preset:
34
+ url: str | None
35
+ key_env: str | None
36
+ note: str
37
+
38
+
39
+ PRESETS = {
40
+ "openai": Preset("https://api.openai.com/v1", "OPENAI_API_KEY", "OpenAI"),
41
+ "ollama": Preset("http://localhost:11434/v1", None, "Ollama on this machine"),
42
+ # No default key: a key is only sent to a compat server when key_env= names one, so
43
+ # pointing a spec at a new URL never hands it a key meant for another server.
44
+ "compat": Preset(None, None, "any OpenAI-compatible server"),
45
+ "hash": Preset(None, None, "built-in hashing baseline, free and offline"),
46
+ }
47
+
48
+ # USD per million input tokens for OpenAI's embedding models. Prices change; pass
49
+ # price= in the spec to override.
50
+ KNOWN_PRICES = {
51
+ "text-embedding-3-small": 0.02,
52
+ "text-embedding-3-large": 0.13,
53
+ "text-embedding-ada-002": 0.10,
54
+ }
55
+
56
+ # Native output size of well-known models, so plans know the target column size without
57
+ # calling the API. Matched on the model's base name, without any path prefix or tag.
58
+ KNOWN_DIMENSIONS = {
59
+ "text-embedding-3-small": 1536,
60
+ "text-embedding-3-large": 3072,
61
+ "text-embedding-ada-002": 1536,
62
+ "nomic-embed-text": 768,
63
+ "mxbai-embed-large": 1024,
64
+ "bge-m3": 1024,
65
+ "all-minilm": 384,
66
+ }
67
+
68
+ # Models known to accept a smaller ``dimensions`` request (Matryoshka truncation).
69
+ SUPPORTS_DIMENSIONS = {"text-embedding-3-small", "text-embedding-3-large"}
70
+
71
+ _SEARCH_INSTRUCTION = "Represent this sentence for searching relevant passages: "
72
+
73
+ # (pattern, query prefix, document prefix) for models trained with prompts. Using the
74
+ # wrong prefix quietly costs these models a lot of quality.
75
+ KNOWN_PREFIXES: tuple[tuple[re.Pattern[str], str, str], ...] = (
76
+ (re.compile(r"nomic-embed-text"), "search_query: ", "search_document: "),
77
+ (re.compile(r"(^|[/_-])e5-"), "query: ", "passage: "),
78
+ (re.compile(r"bge-(small|base|large)-(en|zh)"), _SEARCH_INSTRUCTION, ""),
79
+ (re.compile(r"mxbai-embed-large"), _SEARCH_INSTRUCTION, ""),
80
+ )
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class ModelSpec:
85
+ raw: str
86
+ provider: str
87
+ model: str
88
+ url: str | None = None
89
+ dimensions: int | None = None
90
+ price: float | None = None
91
+ """USD per million tokens, or ``None`` if unknown."""
92
+ key_env: str | None = None
93
+ query_prefix: str = ""
94
+ doc_prefix: str = ""
95
+ batch_size: int = 64
96
+
97
+ @property
98
+ def name(self) -> str:
99
+ """Short display name, such as ``openai/text-embedding-3-large (256d)``."""
100
+ base = f"{self.provider}/{self.model}"
101
+ explicit = self.dimensions and self.provider != "hash"
102
+ return f"{base} ({self.dimensions}d)" if explicit else base
103
+
104
+ @property
105
+ def is_local(self) -> bool:
106
+ """Whether text stays on this machine."""
107
+ if self.provider == "hash":
108
+ return True
109
+ host = urlparse(self.url or "").hostname or ""
110
+ return host in LOCAL_HOSTS
111
+
112
+ @property
113
+ def safe_raw(self) -> str:
114
+ """The spec as written, minus any URL query string, which can carry secrets."""
115
+ if not self.url or "?" not in self.url:
116
+ return self.raw
117
+ return self.raw.replace(self.url, self.url.split("?", 1)[0] + "?…")
118
+
119
+ @property
120
+ def sends_plaintext(self) -> bool:
121
+ """Whether requests would cross the network unencrypted."""
122
+ return (self.url or "").startswith("http://") and not self.is_local
123
+
124
+ @property
125
+ def base_model(self) -> str:
126
+ """``BAAI/bge-m3`` → ``bge-m3``; ``nomic-embed-text:latest`` → ``nomic-embed-text``."""
127
+ return self.model.rsplit("/", 1)[-1].split(":", 1)[0].lower()
128
+
129
+ @property
130
+ def native_dimensions(self) -> int | None:
131
+ return KNOWN_DIMENSIONS.get(self.base_model)
132
+
133
+ @property
134
+ def output_dimensions(self) -> int | None:
135
+ """What this spec will produce, if known without calling the model."""
136
+ return self.dimensions or self.native_dimensions
137
+
138
+ @property
139
+ def identity(self) -> str:
140
+ """Everything that changes the vectors this spec produces, for caching."""
141
+ return "|".join(
142
+ str(x)
143
+ for x in (
144
+ self.provider,
145
+ self.model,
146
+ self.url,
147
+ self.dimensions,
148
+ self.query_prefix,
149
+ self.doc_prefix,
150
+ )
151
+ )
152
+
153
+
154
+ def _number(key: str, value: str, kind: type[int] | type[float]) -> float:
155
+ try:
156
+ number = kind(value)
157
+ except ValueError:
158
+ raise SpecError(f"{key}= must be a number, got {value!r}") from None
159
+ if number < 0 or (kind is int and number == 0):
160
+ raise SpecError(f"{key}= must be positive, got {value!r}")
161
+ return number
162
+
163
+
164
+ def parse_spec(raw: str) -> ModelSpec:
165
+ """Parse ``provider/model[,key=value,...]``."""
166
+ head, *options = [part.strip() for part in raw.split(",")]
167
+ provider, sep, model = head.partition("/")
168
+ provider = provider.lower()
169
+ if not sep or not model:
170
+ raise SpecError(
171
+ f"Expected provider/model, got {raw!r}. For example: openai/text-embedding-3-small"
172
+ )
173
+ if provider not in PRESETS:
174
+ raise SpecError(f"Unknown provider {provider!r}. Use one of: {', '.join(sorted(PRESETS))}.")
175
+ preset = PRESETS[provider]
176
+ values: dict[str, str] = {}
177
+ for option in options:
178
+ key, eq, value = option.partition("=")
179
+ key = key.strip().lower()
180
+ if not eq or key not in {
181
+ "url",
182
+ "dims",
183
+ "price",
184
+ "key_env",
185
+ "query_prefix",
186
+ "doc_prefix",
187
+ "batch",
188
+ }:
189
+ raise SpecError(f"Unknown option {option!r} in {raw!r}")
190
+ values[key] = value
191
+
192
+ url = values.get("url", preset.url)
193
+ if provider == "compat" and not url:
194
+ raise SpecError(
195
+ f"compat models need a url=, for example {raw},url=http://localhost:8080/v1"
196
+ )
197
+ if url:
198
+ url = url.rstrip("/")
199
+ parsed = urlparse(url)
200
+ if parsed.scheme not in {"http", "https"}:
201
+ raise SpecError(f"url= must start with http:// or https://, got {url!r}")
202
+ if parsed.username or parsed.password:
203
+ raise SpecError(
204
+ "Don't put credentials in url=: they would end up in reports. Put the key in "
205
+ "an environment variable and name it with key_env=."
206
+ )
207
+
208
+ dimensions = int(_number("dims", values["dims"], int)) if "dims" in values else None
209
+ if provider == "hash":
210
+ dimensions = int(_number("hash dimensions", model, int))
211
+
212
+ price: float | None
213
+ if "price" in values:
214
+ price = float(_number("price", values["price"], float))
215
+ elif provider == "openai":
216
+ price = KNOWN_PRICES.get(model)
217
+ else:
218
+ price = 0.0 if provider in {"hash", "ollama"} else None
219
+
220
+ query_prefix, doc_prefix = "", ""
221
+ for pattern, qp, dp in KNOWN_PREFIXES:
222
+ if pattern.search(model.lower()):
223
+ query_prefix, doc_prefix = qp, dp
224
+ break
225
+ query_prefix = values.get("query_prefix", query_prefix)
226
+ doc_prefix = values.get("doc_prefix", doc_prefix)
227
+
228
+ batch = int(_number("batch", values["batch"], int)) if "batch" in values else 64
229
+ return ModelSpec(
230
+ raw=raw,
231
+ provider=provider,
232
+ model=model,
233
+ url=url,
234
+ dimensions=dimensions,
235
+ price=price,
236
+ key_env=values.get("key_env", preset.key_env),
237
+ query_prefix=query_prefix,
238
+ doc_prefix=doc_prefix,
239
+ batch_size=batch,
240
+ )
@@ -0,0 +1,20 @@
1
+ """Compare a migration's old and new vectors before cutover: quality and latency."""
2
+
3
+ from vecshift.eval.metrics import Latency, Scores
4
+ from vecshift.eval.queries import EvalQuery, proxy_queries, script
5
+ from vecshift.eval.runner import EvalReport, Gates, SideReport, SweepPoint, Verdict, decide, run
6
+
7
+ __all__ = [
8
+ "EvalQuery",
9
+ "EvalReport",
10
+ "Gates",
11
+ "Latency",
12
+ "Scores",
13
+ "SideReport",
14
+ "SweepPoint",
15
+ "Verdict",
16
+ "decide",
17
+ "proxy_queries",
18
+ "run",
19
+ "script",
20
+ ]