aid-runtime-python 0.1.39__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.
- aid_runtime/__init__.py +500 -0
- aid_runtime/agents/__init__.py +94 -0
- aid_runtime/agents/chat_model.py +351 -0
- aid_runtime/agents/deep_agents.py +600 -0
- aid_runtime/agents/engine.py +102 -0
- aid_runtime/agents/guardrails.py +519 -0
- aid_runtime/agents/ir.py +622 -0
- aid_runtime/agents/loop.py +1308 -0
- aid_runtime/agents/memory.py +241 -0
- aid_runtime/agents/policy.py +163 -0
- aid_runtime/agents/state.py +182 -0
- aid_runtime/cache.py +185 -0
- aid_runtime/concurrency.py +70 -0
- aid_runtime/control.py +329 -0
- aid_runtime/cost.py +168 -0
- aid_runtime/errors.py +337 -0
- aid_runtime/evals/__init__.py +72 -0
- aid_runtime/evals/dataset.py +77 -0
- aid_runtime/evals/gate.py +430 -0
- aid_runtime/evals/metrics.py +335 -0
- aid_runtime/evals/predicate.py +211 -0
- aid_runtime/evals/runner.py +224 -0
- aid_runtime/gateway.py +473 -0
- aid_runtime/pipeline.py +168 -0
- aid_runtime/prompts.py +185 -0
- aid_runtime/providers/__init__.py +20 -0
- aid_runtime/providers/base.py +57 -0
- aid_runtime/providers/fake.py +256 -0
- aid_runtime/providers/http.py +344 -0
- aid_runtime/py.typed +0 -0
- aid_runtime/retrieval/__init__.py +178 -0
- aid_runtime/retrieval/chunking.py +222 -0
- aid_runtime/retrieval/citations.py +131 -0
- aid_runtime/retrieval/connectors.py +131 -0
- aid_runtime/retrieval/embedding.py +293 -0
- aid_runtime/retrieval/gate.py +120 -0
- aid_runtime/retrieval/hybrid.py +164 -0
- aid_runtime/retrieval/metrics.py +213 -0
- aid_runtime/retrieval/pgvector.py +258 -0
- aid_runtime/retrieval/rerank.py +133 -0
- aid_runtime/retrieval/retriever.py +335 -0
- aid_runtime/retrieval/stores.py +161 -0
- aid_runtime/retrieval/types.py +433 -0
- aid_runtime/steps.py +284 -0
- aid_runtime/structured.py +150 -0
- aid_runtime/tools.py +271 -0
- aid_runtime/tracing.py +241 -0
- aid_runtime/types.py +332 -0
- aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
- aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
- aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
- aid_runtime_python-0.1.39.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Turning text into vectors.
|
|
2
|
+
|
|
3
|
+
Two adapters, and that is the whole Phase 1 surface: one deterministic fake and one
|
|
4
|
+
OpenAI-compatible HTTP client. The fake is not a testing afterthought -- it is the
|
|
5
|
+
adapter the eval gate runs against, because a gate that needed an API key could not
|
|
6
|
+
run offline in CI. That is the same reason the spec DSL already has
|
|
7
|
+
``provider: fake`` ("deterministic and keyless, which is what lets the eval gate run
|
|
8
|
+
offline").
|
|
9
|
+
|
|
10
|
+
The pinning rule lives in the seam, not in the adapters: ``embed`` takes a
|
|
11
|
+
:class:`~aid_runtime.retrieval.types.PinnedEmbedding` and uses its ``model_id`` and
|
|
12
|
+
``dimensions``. Nothing here resolves an alias, and the fake hashes the *pinned*
|
|
13
|
+
model id into its vector, so a silently changed pin produces different vectors
|
|
14
|
+
rather than a store full of incomparable ones.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import math
|
|
23
|
+
from collections.abc import Mapping, Sequence
|
|
24
|
+
from typing import Any, Protocol, runtime_checkable
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
from pydantic import BaseModel, ConfigDict
|
|
28
|
+
|
|
29
|
+
from aid_runtime.errors import ConfigurationError, ProviderError, ProviderResponseError
|
|
30
|
+
from aid_runtime.providers.http import DEFAULT_BASE_URL
|
|
31
|
+
from aid_runtime.retrieval.types import PinnedEmbedding
|
|
32
|
+
from aid_runtime.types import TokenUsage
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Embedder",
|
|
36
|
+
"EmbeddingBatch",
|
|
37
|
+
"FakeDeterministicEmbedder",
|
|
38
|
+
"OpenAICompatibleEmbedder",
|
|
39
|
+
"embedder_for",
|
|
40
|
+
"estimate_tokens",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
#: Statuses worth a second attempt: the request was well-formed and the server was
|
|
44
|
+
#: momentarily unwilling. Mirrors the chat provider's set so retry behaviour is one
|
|
45
|
+
#: rule in this runtime rather than one rule per adapter.
|
|
46
|
+
_RETRYABLE_STATUS = frozenset({408, 409, 425, 429, 500, 502, 503, 504})
|
|
47
|
+
|
|
48
|
+
#: Rough tokeniser. Deliberately the same arithmetic as
|
|
49
|
+
#: ``cost.estimate_prompt_cost_usd`` (4 characters per token, rounded up), so an
|
|
50
|
+
#: offline estimate and an offline charge cannot disagree about what a text costs.
|
|
51
|
+
_CHARS_PER_TOKEN = 4
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EmbeddingBatch(BaseModel):
|
|
55
|
+
"""Vectors plus the usage that prices them."""
|
|
56
|
+
|
|
57
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
58
|
+
|
|
59
|
+
vectors: tuple[tuple[float, ...], ...]
|
|
60
|
+
usage: TokenUsage
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@runtime_checkable
|
|
64
|
+
class Embedder(Protocol):
|
|
65
|
+
"""Text to vectors, with the model pinned by the caller.
|
|
66
|
+
|
|
67
|
+
``aclose`` must be idempotent, matching
|
|
68
|
+
:class:`~aid_runtime.providers.base.Provider`: a client's lifetime is owned by
|
|
69
|
+
the caller, so closing twice is a caller's mistake that must stay harmless.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def name(self) -> str: ...
|
|
74
|
+
|
|
75
|
+
async def embed(self, texts: Sequence[str], *, model: PinnedEmbedding) -> EmbeddingBatch: ...
|
|
76
|
+
|
|
77
|
+
async def aclose(self) -> None: ...
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def estimate_tokens(text: str) -> int:
|
|
81
|
+
"""The offline token estimate used for the fake's usage and for budgets."""
|
|
82
|
+
return (len(text) + _CHARS_PER_TOKEN - 1) // _CHARS_PER_TOKEN
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class FakeDeterministicEmbedder:
|
|
86
|
+
"""Keyless, offline, and byte-identical across runs and platforms.
|
|
87
|
+
|
|
88
|
+
Vectors come from SHAKE-128, a hash-based extensible output function, rather
|
|
89
|
+
than from a seeded PRNG. The reason is the same property the rest of this
|
|
90
|
+
runtime depends on: re-ingesting a corpus has to reproduce the vectors exactly,
|
|
91
|
+
and re-embedding a *query* has to reproduce what ingest stored. A PRNG seeded
|
|
92
|
+
per call would be stable too, but a hash makes "same text, same vector" true by
|
|
93
|
+
construction rather than by discipline, and it cannot drift if a vector is later
|
|
94
|
+
requested one element at a time.
|
|
95
|
+
|
|
96
|
+
Similarity is meaningless here, and that is the deliberate limitation rather
|
|
97
|
+
than a bug. Lexical retrieval still works over these vectors because the store
|
|
98
|
+
keeps the text and hybrid search reads it; what the fake proves is the
|
|
99
|
+
*pipeline*, and what it deliberately does not prove is embedding quality.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, *, name: str = "fake-embedder") -> None:
|
|
103
|
+
self._name = name
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def name(self) -> str:
|
|
107
|
+
return self._name
|
|
108
|
+
|
|
109
|
+
async def embed(self, texts: Sequence[str], *, model: PinnedEmbedding) -> EmbeddingBatch:
|
|
110
|
+
return EmbeddingBatch(
|
|
111
|
+
vectors=tuple(self._vector(text, model) for text in texts),
|
|
112
|
+
usage=TokenUsage(
|
|
113
|
+
input_tokens=sum(estimate_tokens(text) for text in texts),
|
|
114
|
+
output_tokens=0,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
async def aclose(self) -> None:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def _vector(self, text: str, model: PinnedEmbedding) -> tuple[float, ...]:
|
|
122
|
+
# The pinned model id is part of the input so the same text under a different
|
|
123
|
+
# pin embeds differently: a changed pin must invalidate stored vectors rather
|
|
124
|
+
# than silently mix two vector spaces in one table.
|
|
125
|
+
seed = f"{model.model_id}:{model.dimensions}\x00{text}"
|
|
126
|
+
raw = hashlib.shake_128(seed.encode("utf-8")).digest(model.dimensions * 2)
|
|
127
|
+
values = [
|
|
128
|
+
(int.from_bytes(raw[index * 2 : index * 2 + 2], "big") / 32767.5) - 1.0
|
|
129
|
+
for index in range(model.dimensions)
|
|
130
|
+
]
|
|
131
|
+
norm = math.sqrt(sum(value * value for value in values)) or 1.0
|
|
132
|
+
return tuple(value / norm for value in values)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class OpenAICompatibleEmbedder:
|
|
136
|
+
"""One POST to ``/embeddings`` on any OpenAI-compatible endpoint.
|
|
137
|
+
|
|
138
|
+
Deliberately narrow: the runtime has exactly one hosted embedding adapter, and
|
|
139
|
+
breadth of vendor coverage is a later concern than a correct seam. Retry,
|
|
140
|
+
timeouts, error classification and client ownership all mirror
|
|
141
|
+
:class:`~aid_runtime.providers.http.OpenAICompatibleProvider` so a caller learns
|
|
142
|
+
the rules once.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
149
|
+
api_key: str | None = None,
|
|
150
|
+
name: str = "openai-compatible-embedder",
|
|
151
|
+
timeout_seconds: float = 60.0,
|
|
152
|
+
max_attempts: int = 3,
|
|
153
|
+
backoff_seconds: float = 0.5,
|
|
154
|
+
client: httpx.AsyncClient | None = None,
|
|
155
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
if not base_url.startswith(("http://", "https://")):
|
|
158
|
+
raise ConfigurationError(f"base_url must be an absolute http(s) URL, got {base_url!r}")
|
|
159
|
+
if max_attempts < 1:
|
|
160
|
+
raise ConfigurationError("max_attempts must be at least 1")
|
|
161
|
+
self._base_url = base_url.rstrip("/")
|
|
162
|
+
self._api_key = api_key
|
|
163
|
+
self._name = name
|
|
164
|
+
self._max_attempts = max_attempts
|
|
165
|
+
self._backoff = backoff_seconds
|
|
166
|
+
self._extra_headers = dict(extra_headers or {})
|
|
167
|
+
# Eager, like the chat provider: the caller may inject a client (a test's
|
|
168
|
+
# transport, or a shared pool). Whoever constructed it closes it, so
|
|
169
|
+
# ownership is tracked rather than assumed.
|
|
170
|
+
self._owns_client = client is None
|
|
171
|
+
self._client = client or httpx.AsyncClient(timeout=timeout_seconds)
|
|
172
|
+
self._closed = False
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def name(self) -> str:
|
|
176
|
+
return self._name
|
|
177
|
+
|
|
178
|
+
async def embed(self, texts: Sequence[str], *, model: PinnedEmbedding) -> EmbeddingBatch:
|
|
179
|
+
body = await self._post("/embeddings", {"model": model.model_id, "input": list(texts)})
|
|
180
|
+
return self._parse(body, model)
|
|
181
|
+
|
|
182
|
+
async def aclose(self) -> None:
|
|
183
|
+
if self._closed:
|
|
184
|
+
return
|
|
185
|
+
self._closed = True
|
|
186
|
+
if self._owns_client:
|
|
187
|
+
await self._client.aclose()
|
|
188
|
+
|
|
189
|
+
def _headers(self) -> dict[str, str]:
|
|
190
|
+
headers = {"content-type": "application/json", **self._extra_headers}
|
|
191
|
+
if self._api_key:
|
|
192
|
+
# A real bearer token, not the chat provider's redacted placeholder: a
|
|
193
|
+
# request that claims to be authenticated and is not fails with a 401 that
|
|
194
|
+
# reads like a credentials problem at the far end.
|
|
195
|
+
headers["authorization"] = f"Bearer {self._api_key}"
|
|
196
|
+
return headers
|
|
197
|
+
|
|
198
|
+
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
199
|
+
url = f"{self._base_url}{path}"
|
|
200
|
+
last_error: Exception | None = None
|
|
201
|
+
for attempt in range(1, self._max_attempts + 1):
|
|
202
|
+
try:
|
|
203
|
+
response = await self._client.post(url, json=payload, headers=self._headers())
|
|
204
|
+
except httpx.TransportError as error:
|
|
205
|
+
last_error = error
|
|
206
|
+
else:
|
|
207
|
+
if response.status_code < 400:
|
|
208
|
+
return _as_object(response, self._name)
|
|
209
|
+
failure = ProviderError(
|
|
210
|
+
f"{self._name} returned HTTP {response.status_code}: {response.text[:200]}"
|
|
211
|
+
)
|
|
212
|
+
# A status outside the retryable set is a decision, not a hiccup:
|
|
213
|
+
# retrying a 400 only wastes the caller's time.
|
|
214
|
+
if response.status_code not in _RETRYABLE_STATUS:
|
|
215
|
+
raise failure
|
|
216
|
+
last_error = failure
|
|
217
|
+
if attempt < self._max_attempts:
|
|
218
|
+
await asyncio.sleep(self._backoff * 2 ** (attempt - 1))
|
|
219
|
+
# Chained rather than formatted away: the transport error carries the reason
|
|
220
|
+
# (DNS, TLS, refused connection), and a caller catching the ProviderError
|
|
221
|
+
# should still be able to see it, as the chat provider's callers can.
|
|
222
|
+
raise ProviderError(
|
|
223
|
+
f"{self._name} failed after {self._max_attempts} attempt(s): {last_error}"
|
|
224
|
+
) from last_error
|
|
225
|
+
|
|
226
|
+
def _parse(self, body: dict[str, Any], model: PinnedEmbedding) -> EmbeddingBatch:
|
|
227
|
+
entries = body.get("data")
|
|
228
|
+
if not isinstance(entries, list) or not entries:
|
|
229
|
+
raise ProviderResponseError(f"{self._name} returned no embeddings")
|
|
230
|
+
|
|
231
|
+
ordered: list[tuple[int, Any]] = []
|
|
232
|
+
for position, entry in enumerate(entries):
|
|
233
|
+
if not isinstance(entry, dict):
|
|
234
|
+
raise ProviderResponseError(f"{self._name} returned a non-object embedding entry")
|
|
235
|
+
index = entry.get("index", position)
|
|
236
|
+
if not isinstance(index, int):
|
|
237
|
+
raise ProviderResponseError(f"{self._name} returned a non-integer embedding index")
|
|
238
|
+
ordered.append((index, entry.get("embedding")))
|
|
239
|
+
ordered.sort(key=lambda pair: pair[0])
|
|
240
|
+
|
|
241
|
+
vectors: list[tuple[float, ...]] = []
|
|
242
|
+
for _, vector in ordered:
|
|
243
|
+
if not isinstance(vector, list):
|
|
244
|
+
raise ProviderResponseError(f"{self._name} returned a non-list embedding")
|
|
245
|
+
try:
|
|
246
|
+
values = tuple(float(value) for value in vector)
|
|
247
|
+
except (TypeError, ValueError) as error:
|
|
248
|
+
raise ProviderResponseError(
|
|
249
|
+
f"{self._name} returned a non-numeric embedding"
|
|
250
|
+
) from error
|
|
251
|
+
if len(values) != model.dimensions:
|
|
252
|
+
raise ProviderResponseError(
|
|
253
|
+
f"{self._name} returned {len(values)} dimensions for {model.model_id!r} but "
|
|
254
|
+
f"{model.dimensions} are pinned; the pin and the response must agree"
|
|
255
|
+
)
|
|
256
|
+
vectors.append(values)
|
|
257
|
+
|
|
258
|
+
usage = body.get("usage")
|
|
259
|
+
found = usage.get("prompt_tokens") if isinstance(usage, dict) else None
|
|
260
|
+
prompt_tokens = found if isinstance(found, int) else 0
|
|
261
|
+
return EmbeddingBatch(
|
|
262
|
+
vectors=tuple(vectors),
|
|
263
|
+
usage=TokenUsage(input_tokens=prompt_tokens, output_tokens=0),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _as_object(response: httpx.Response, name: str) -> dict[str, Any]:
|
|
268
|
+
try:
|
|
269
|
+
body = response.json()
|
|
270
|
+
except json.JSONDecodeError as error:
|
|
271
|
+
raise ProviderResponseError(f"{name} returned a body that is not JSON") from error
|
|
272
|
+
if not isinstance(body, dict):
|
|
273
|
+
raise ProviderResponseError(f"{name} returned a JSON body that is not an object")
|
|
274
|
+
return body
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def embedder_for(
|
|
278
|
+
model: PinnedEmbedding,
|
|
279
|
+
*,
|
|
280
|
+
api_key: str | None = None,
|
|
281
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
282
|
+
client: httpx.AsyncClient | None = None,
|
|
283
|
+
) -> Embedder:
|
|
284
|
+
"""Pick the adapter for a pinned embedding model.
|
|
285
|
+
|
|
286
|
+
This selects a *provider*, never a model: the model id and its width arrive
|
|
287
|
+
already pinned, and are passed straight through to whichever adapter is chosen.
|
|
288
|
+
``fake`` is chosen for the same reason the spec can declare it -- so a gate runs
|
|
289
|
+
keyless and offline.
|
|
290
|
+
"""
|
|
291
|
+
if model.provider == "fake":
|
|
292
|
+
return FakeDeterministicEmbedder()
|
|
293
|
+
return OpenAICompatibleEmbedder(base_url=base_url, api_key=api_key, client=client)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Running the retrieval gate through the shipped statistical gate.
|
|
2
|
+
|
|
3
|
+
No gate logic lives here. :class:`RetrievalGate` samples a retriever over retrieval
|
|
4
|
+
cases, turns each per-run mean into a real
|
|
5
|
+
:class:`~aid_runtime.evals.gate.MetricRun`, and hands a real
|
|
6
|
+
:class:`~aid_runtime.evals.gate.EvalRun` to the real
|
|
7
|
+
:class:`~aid_runtime.evals.gate.BaselineGate`. That matters for the falsification
|
|
8
|
+
test, which must show the *shipped* gate rejecting a worse retriever; a comparator
|
|
9
|
+
written for the occasion would prove only that the comparator works.
|
|
10
|
+
|
|
11
|
+
Two deliberate details:
|
|
12
|
+
|
|
13
|
+
**The metrics hand-off needs a cast.** ``BaselineGate.__init__`` annotates ``metrics``
|
|
14
|
+
as ``Sequence[Metric]``, and the shipped ``Metric`` protocol requires ``score(case:
|
|
15
|
+
EvalCase, prediction: str)``, which a retrieval metric cannot implement. What the
|
|
16
|
+
gate actually consumes from that argument is only ``.name`` and ``.deterministic`` --
|
|
17
|
+
that is the entirety of ``_check_metric_eligibility`` -- so a cast is sound here, and
|
|
18
|
+
is confined to one line with this comment rather than spread through the runtime. The
|
|
19
|
+
eligibility check it feeds is real: a retrieval metric reporting
|
|
20
|
+
``deterministic = False`` is refused, which a test asserts.
|
|
21
|
+
|
|
22
|
+
**``allow_nondeterministic`` is not exposed.** The shipped gate has the flag for
|
|
23
|
+
local exploration, and wiring it through would make it reachable from a gate. There is
|
|
24
|
+
no retrieval metric in Phase 1 that is non-deterministic; if one ever appears, the
|
|
25
|
+
metric is what needs fixing.
|
|
26
|
+
|
|
27
|
+
``runs`` is a required argument rather than a default, because it is not a tuning
|
|
28
|
+
knob: at ``runs = 1`` the run-to-run standard deviation is zero and the statistical
|
|
29
|
+
half of the gate cannot fire at all, so a gate built on one run silently degrades to a
|
|
30
|
+
mean comparison.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from collections.abc import Sequence
|
|
36
|
+
from typing import cast
|
|
37
|
+
|
|
38
|
+
from aid_runtime.errors import EvalError
|
|
39
|
+
from aid_runtime.evals.gate import (
|
|
40
|
+
BaselineGate,
|
|
41
|
+
EvalBaseline,
|
|
42
|
+
EvalRun,
|
|
43
|
+
GateResult,
|
|
44
|
+
MetricRun,
|
|
45
|
+
Threshold,
|
|
46
|
+
)
|
|
47
|
+
from aid_runtime.evals.metrics import Metric
|
|
48
|
+
from aid_runtime.retrieval.metrics import RetrievalCase, RetrievalMetric
|
|
49
|
+
from aid_runtime.retrieval.retriever import Retriever
|
|
50
|
+
|
|
51
|
+
__all__ = ["RetrievalGate"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RetrievalGate:
|
|
55
|
+
"""Samples a retriever and decides whether its retrieval regressed."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
thresholds: Sequence[Threshold],
|
|
60
|
+
*,
|
|
61
|
+
metrics: Sequence[RetrievalMetric] = (),
|
|
62
|
+
) -> None:
|
|
63
|
+
self._metrics = tuple(metrics)
|
|
64
|
+
# See the module docstring: the gate reads only `name` and `deterministic`
|
|
65
|
+
# from these, which is the part of the protocol a retrieval metric shares.
|
|
66
|
+
self._gate = BaselineGate(
|
|
67
|
+
thresholds,
|
|
68
|
+
metrics=cast("Sequence[Metric]", self._metrics),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def thresholds(self) -> tuple[Threshold, ...]:
|
|
73
|
+
return self._gate.thresholds
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def metrics(self) -> tuple[RetrievalMetric, ...]:
|
|
77
|
+
return self._metrics
|
|
78
|
+
|
|
79
|
+
async def sample(
|
|
80
|
+
self,
|
|
81
|
+
cases: Sequence[RetrievalCase],
|
|
82
|
+
retriever: Retriever,
|
|
83
|
+
*,
|
|
84
|
+
runs: int,
|
|
85
|
+
dataset: str,
|
|
86
|
+
dataset_version: str,
|
|
87
|
+
) -> EvalRun:
|
|
88
|
+
"""Run every case ``runs`` times, producing one mean per metric per run."""
|
|
89
|
+
if not cases:
|
|
90
|
+
raise EvalError("a retrieval run needs at least one case")
|
|
91
|
+
if runs < 1:
|
|
92
|
+
raise EvalError(f"a retrieval run needs at least one run, got {runs}")
|
|
93
|
+
if not self._metrics:
|
|
94
|
+
raise EvalError("a retrieval run needs at least one metric")
|
|
95
|
+
|
|
96
|
+
samples: dict[str, list[float]] = {metric.name: [] for metric in self._metrics}
|
|
97
|
+
for _ in range(runs):
|
|
98
|
+
totals: dict[str, float] = {metric.name: 0.0 for metric in self._metrics}
|
|
99
|
+
for case in cases:
|
|
100
|
+
# k comes from the case, because a case is what says how many
|
|
101
|
+
# passages count as an answer; a retriever's own topK is a default.
|
|
102
|
+
retrieved = await retriever.retrieve(case.query, top_k=case.k)
|
|
103
|
+
for metric in self._metrics:
|
|
104
|
+
result = await metric.score(case, retrieved)
|
|
105
|
+
totals[metric.name] += result.score
|
|
106
|
+
for name, total in totals.items():
|
|
107
|
+
samples[name].append(total / len(cases))
|
|
108
|
+
|
|
109
|
+
return EvalRun(
|
|
110
|
+
dataset=dataset,
|
|
111
|
+
dataset_version=dataset_version,
|
|
112
|
+
runs=runs,
|
|
113
|
+
metrics=tuple(
|
|
114
|
+
MetricRun(metric=name, values=tuple(values)) for name, values in samples.items()
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def evaluate(self, run: EvalRun, baseline: EvalBaseline) -> GateResult:
|
|
119
|
+
"""Delegate to the shipped gate; there is no verdict logic in this module."""
|
|
120
|
+
return self._gate.evaluate(run, baseline)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Lexical scoring and rank fusion.
|
|
2
|
+
|
|
3
|
+
Vector similarity cannot carry retrieval on its own, and the reason is not
|
|
4
|
+
aesthetic: cosine similarity is dominated by embedding geometry while exact terms --
|
|
5
|
+
an identifier, an error code, a flag name -- are what a developer actually greps for.
|
|
6
|
+
So a retriever fuses a vector ranking with a lexical one.
|
|
7
|
+
|
|
8
|
+
The lexical half is BM25, implemented here rather than delegated to a search engine
|
|
9
|
+
so that the same scoring applies to both store adapters and to a store that has no
|
|
10
|
+
database behind it.
|
|
11
|
+
|
|
12
|
+
The fusion is Reciprocal Rank Fusion, and it fuses *ranks* rather than scores. That
|
|
13
|
+
is the whole reason to pick it: BM25 scores and cosine similarities are unbounded and
|
|
14
|
+
on incomparable scales, so any weighted score sum would need a per-corpus
|
|
15
|
+
normalisation constant that nobody can justify. Ranks are already comparable, and
|
|
16
|
+
RRF's ``1 / (k + rank)`` says something defensible -- being ranked highly by either
|
|
17
|
+
retriever is evidence, being ranked highly by both is stronger evidence.
|
|
18
|
+
|
|
19
|
+
Everything here is a pure function of its inputs, so retrieval order is reproducible
|
|
20
|
+
and a re-ingested corpus ranks identically.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
import re
|
|
27
|
+
from collections import Counter
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
|
|
30
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"RankedId",
|
|
34
|
+
"bm25_scores",
|
|
35
|
+
"fuse",
|
|
36
|
+
"lexical_ranking",
|
|
37
|
+
"rrf_score",
|
|
38
|
+
"tokenize",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
#: The RRF constant from the original formulation: it damps the influence of the
|
|
42
|
+
#: very top ranks so one retriever's confident first hit cannot alone decide the
|
|
43
|
+
#: fused order.
|
|
44
|
+
_RRF_K = 60
|
|
45
|
+
|
|
46
|
+
#: BM25 saturation and length-normalisation. The usual defaults, and defaults are
|
|
47
|
+
#: the right call here: they are tuned against judged-relevance corpora, and this
|
|
48
|
+
#: runtime has no such corpus to tune against.
|
|
49
|
+
_BM25_K1 = 1.5
|
|
50
|
+
_BM25_B = 0.75
|
|
51
|
+
|
|
52
|
+
_TOKEN = re.compile(r"[a-z0-9]+")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RankedId(BaseModel):
|
|
56
|
+
"""A ranked chunk id and the score that ranked it."""
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
59
|
+
|
|
60
|
+
chunk_id: str = Field(min_length=1)
|
|
61
|
+
score: float
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def tokenize(text: str) -> tuple[str, ...]:
|
|
65
|
+
"""Lowercase alphanumeric runs.
|
|
66
|
+
|
|
67
|
+
No stemming and no stop-word list: both are language-specific, and a retrieval
|
|
68
|
+
runtime that silently mangles identifiers by stemming them is worse at its
|
|
69
|
+
actual job than one that keeps the tokens it was given.
|
|
70
|
+
"""
|
|
71
|
+
return tuple(_TOKEN.findall(text.lower()))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def bm25_scores(
|
|
75
|
+
documents: Sequence[str],
|
|
76
|
+
query: str,
|
|
77
|
+
*,
|
|
78
|
+
k1: float = _BM25_K1,
|
|
79
|
+
b: float = _BM25_B,
|
|
80
|
+
) -> tuple[float, ...]:
|
|
81
|
+
"""BM25 score of every document for ``query``, in the order given.
|
|
82
|
+
|
|
83
|
+
Saturated term frequency (a term repeated ten times is not ten times as
|
|
84
|
+
informative) and length normalisation (a long chunk should not win merely by
|
|
85
|
+
containing more words) -- those two properties are the reason to use BM25 rather
|
|
86
|
+
than a raw term count.
|
|
87
|
+
"""
|
|
88
|
+
tokenised = [tokenize(document) for document in documents]
|
|
89
|
+
terms = tokenize(query)
|
|
90
|
+
if not tokenised or not terms:
|
|
91
|
+
return tuple(0.0 for _ in documents)
|
|
92
|
+
|
|
93
|
+
total = len(tokenised)
|
|
94
|
+
lengths = [len(tokens) for tokens in tokenised]
|
|
95
|
+
average_length = sum(lengths) / total
|
|
96
|
+
counts = [Counter(tokens) for tokens in tokenised]
|
|
97
|
+
|
|
98
|
+
scores: list[float] = []
|
|
99
|
+
for index, frequencies in enumerate(counts):
|
|
100
|
+
length = lengths[index]
|
|
101
|
+
score = 0.0
|
|
102
|
+
for term in terms:
|
|
103
|
+
frequency = frequencies.get(term, 0)
|
|
104
|
+
if frequency == 0:
|
|
105
|
+
continue
|
|
106
|
+
document_frequency = sum(1 for other in counts if term in other)
|
|
107
|
+
inverse_frequency = math.log(
|
|
108
|
+
1.0 + (total - document_frequency + 0.5) / (document_frequency + 0.5)
|
|
109
|
+
)
|
|
110
|
+
norm = 1.0 - b + b * (length / average_length) if average_length else 1.0
|
|
111
|
+
score += inverse_frequency * (frequency * (k1 + 1.0)) / (frequency + k1 * norm)
|
|
112
|
+
scores.append(score)
|
|
113
|
+
return tuple(scores)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def lexical_ranking(
|
|
117
|
+
documents: Sequence[str],
|
|
118
|
+
ids: Sequence[str],
|
|
119
|
+
query: str,
|
|
120
|
+
*,
|
|
121
|
+
top_k: int,
|
|
122
|
+
) -> tuple[RankedId, ...]:
|
|
123
|
+
"""The ``top_k`` documents with a non-zero lexical score, best first.
|
|
124
|
+
|
|
125
|
+
Ties break on chunk id so that equal-scoring matches rank identically on every
|
|
126
|
+
run and in both store adapters.
|
|
127
|
+
"""
|
|
128
|
+
if len(documents) != len(ids):
|
|
129
|
+
raise ValueError("documents and ids must be parallel sequences")
|
|
130
|
+
scored = [
|
|
131
|
+
RankedId(chunk_id=chunk_id, score=score)
|
|
132
|
+
for chunk_id, score in zip(ids, bm25_scores(documents, query), strict=True)
|
|
133
|
+
if score > 0.0
|
|
134
|
+
]
|
|
135
|
+
scored.sort(key=lambda entry: (-entry.score, entry.chunk_id))
|
|
136
|
+
return tuple(scored[:top_k])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def rrf_score(rank: int, *, k: int = _RRF_K) -> float:
|
|
140
|
+
"""The reciprocal-rank contribution of a 1-based ``rank``."""
|
|
141
|
+
if rank < 1:
|
|
142
|
+
raise ValueError("rank is 1-based")
|
|
143
|
+
return 1.0 / (k + rank)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def fuse(
|
|
147
|
+
rankings: Sequence[Sequence[str]],
|
|
148
|
+
*,
|
|
149
|
+
k: int = _RRF_K,
|
|
150
|
+
) -> tuple[RankedId, ...]:
|
|
151
|
+
"""Reciprocal Rank Fusion of rank-ordered id lists.
|
|
152
|
+
|
|
153
|
+
Each ranking contributes ``1 / (k + rank)`` per id; an id absent from a ranking
|
|
154
|
+
contributes nothing from it. An id that appears in several rankings therefore
|
|
155
|
+
outranks one that is top of a single ranking, which is the behaviour that makes
|
|
156
|
+
fusion worth doing.
|
|
157
|
+
"""
|
|
158
|
+
totals: dict[str, float] = {}
|
|
159
|
+
for ranking in rankings:
|
|
160
|
+
for rank, chunk_id in enumerate(ranking, start=1):
|
|
161
|
+
totals[chunk_id] = totals.get(chunk_id, 0.0) + rrf_score(rank, k=k)
|
|
162
|
+
fused = [RankedId(chunk_id=chunk_id, score=score) for chunk_id, score in totals.items()]
|
|
163
|
+
fused.sort(key=lambda entry: (-entry.score, entry.chunk_id))
|
|
164
|
+
return tuple(fused)
|