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.
Files changed (52) hide show
  1. aid_runtime/__init__.py +500 -0
  2. aid_runtime/agents/__init__.py +94 -0
  3. aid_runtime/agents/chat_model.py +351 -0
  4. aid_runtime/agents/deep_agents.py +600 -0
  5. aid_runtime/agents/engine.py +102 -0
  6. aid_runtime/agents/guardrails.py +519 -0
  7. aid_runtime/agents/ir.py +622 -0
  8. aid_runtime/agents/loop.py +1308 -0
  9. aid_runtime/agents/memory.py +241 -0
  10. aid_runtime/agents/policy.py +163 -0
  11. aid_runtime/agents/state.py +182 -0
  12. aid_runtime/cache.py +185 -0
  13. aid_runtime/concurrency.py +70 -0
  14. aid_runtime/control.py +329 -0
  15. aid_runtime/cost.py +168 -0
  16. aid_runtime/errors.py +337 -0
  17. aid_runtime/evals/__init__.py +72 -0
  18. aid_runtime/evals/dataset.py +77 -0
  19. aid_runtime/evals/gate.py +430 -0
  20. aid_runtime/evals/metrics.py +335 -0
  21. aid_runtime/evals/predicate.py +211 -0
  22. aid_runtime/evals/runner.py +224 -0
  23. aid_runtime/gateway.py +473 -0
  24. aid_runtime/pipeline.py +168 -0
  25. aid_runtime/prompts.py +185 -0
  26. aid_runtime/providers/__init__.py +20 -0
  27. aid_runtime/providers/base.py +57 -0
  28. aid_runtime/providers/fake.py +256 -0
  29. aid_runtime/providers/http.py +344 -0
  30. aid_runtime/py.typed +0 -0
  31. aid_runtime/retrieval/__init__.py +178 -0
  32. aid_runtime/retrieval/chunking.py +222 -0
  33. aid_runtime/retrieval/citations.py +131 -0
  34. aid_runtime/retrieval/connectors.py +131 -0
  35. aid_runtime/retrieval/embedding.py +293 -0
  36. aid_runtime/retrieval/gate.py +120 -0
  37. aid_runtime/retrieval/hybrid.py +164 -0
  38. aid_runtime/retrieval/metrics.py +213 -0
  39. aid_runtime/retrieval/pgvector.py +258 -0
  40. aid_runtime/retrieval/rerank.py +133 -0
  41. aid_runtime/retrieval/retriever.py +335 -0
  42. aid_runtime/retrieval/stores.py +161 -0
  43. aid_runtime/retrieval/types.py +433 -0
  44. aid_runtime/steps.py +284 -0
  45. aid_runtime/structured.py +150 -0
  46. aid_runtime/tools.py +271 -0
  47. aid_runtime/tracing.py +241 -0
  48. aid_runtime/types.py +332 -0
  49. aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
  50. aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
  51. aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
  52. 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)