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,213 @@
1
+ """Metrics over a retrieved set.
2
+
3
+ The shipped :class:`~aid_runtime.evals.metrics.Metric` scores ``(case, prediction:
4
+ str)``. That signature cannot express these metrics: recall@k and MRR score a *ranked
5
+ set of passages with provenance*, and a citation-grounding score has to look at where
6
+ each passage came from. Encoding a retrieved set into one string would make every
7
+ metric re-parse it, and the parse would become the thing under test.
8
+
9
+ So the retrieval metrics get their own protocol here rather than widening the shipped
10
+ one. Widening ``Metric`` would change the meaning of an interface the eval gate
11
+ already threshold-checks, and would do so for every existing implementation. The new
12
+ protocol is additive and reuses :class:`~aid_runtime.evals.metrics.MetricResult`.
13
+
14
+ The two protocols share the part the gate actually consumes -- ``name`` and
15
+ ``deterministic`` -- which is what lets retrieval metrics be handed to the shipped
16
+ gate's ``metrics=`` parameter. See :mod:`aid_runtime.retrieval.gate` for why that
17
+ hand-off needs a cast and why the cast is sound.
18
+
19
+ ``deterministic`` is ``True`` for all three, and that is a claim the gate enforces:
20
+ none of these metrics calls a model, and every tie is broken by chunk id, so the same
21
+ retrieved set always scores the same.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Protocol, runtime_checkable
27
+
28
+ from pydantic import BaseModel, ConfigDict, Field
29
+
30
+ from aid_runtime.evals.metrics import MetricResult
31
+ from aid_runtime.retrieval.citations import RetrievalResult
32
+ from aid_runtime.retrieval.types import RetrievalMetricSpec
33
+
34
+ __all__ = [
35
+ "CitationGrounding",
36
+ "MeanReciprocalRank",
37
+ "RecallAtK",
38
+ "RetrievalCase",
39
+ "RetrievalMetric",
40
+ "retrieval_metrics",
41
+ ]
42
+
43
+
44
+ class RetrievalCase(BaseModel):
45
+ """One retrieval question and the documents that should answer it.
46
+
47
+ This mirrors the IR's ``retrieval`` metric node field for field, and it exists
48
+ because :class:`~aid_runtime.evals.dataset.EvalCase` carries ``metadata:
49
+ dict[str, str]`` -- a string map, which cannot hold ``expectDocs`` without
50
+ inventing an encoding. The expected documents, and ``k``, are what a retrieval
51
+ metric needs, so they are typed here instead of smuggled through a string.
52
+ """
53
+
54
+ model_config = ConfigDict(frozen=True, extra="forbid")
55
+
56
+ id: str = Field(min_length=1)
57
+ query: str
58
+ expect_docs: tuple[str, ...] = Field(min_length=1)
59
+ k: int = Field(gt=0)
60
+
61
+ @classmethod
62
+ def from_spec(cls, *, case_id: str, query: str, spec: RetrievalMetricSpec) -> RetrievalCase:
63
+ """Build a case from the IR's ``{kind, expectDocs, k}`` node."""
64
+ return cls(id=case_id, query=query, expect_docs=spec.expect_docs, k=spec.k)
65
+
66
+
67
+ @runtime_checkable
68
+ class RetrievalMetric(Protocol):
69
+ """Scores a ranked, cited retrieved set.
70
+
71
+ ``name`` and ``deterministic`` are part of the contract because the shipped gate
72
+ reads exactly those two properties when it checks that a thresholded metric is
73
+ allowed to gate CI.
74
+ """
75
+
76
+ @property
77
+ def name(self) -> str: ...
78
+
79
+ @property
80
+ def deterministic(self) -> bool: ...
81
+
82
+ async def score(self, case: RetrievalCase, retrieved: RetrievalResult) -> MetricResult: ...
83
+
84
+
85
+ class RecallAtK:
86
+ """Fraction of expected documents present in the top ``k``.
87
+
88
+ Duplicated passages do not inflate the score: :attr:`RetrievalResult.doc_ids` is
89
+ de-duplicated, so a document that contributed three passages counts once. Recall
90
+ is about which documents were found.
91
+ """
92
+
93
+ def __init__(self, *, name: str = "recall@k", min_score: float = 1.0) -> None:
94
+ self._name = name
95
+ self._min_score = min_score
96
+
97
+ @property
98
+ def name(self) -> str:
99
+ return self._name
100
+
101
+ @property
102
+ def deterministic(self) -> bool:
103
+ return True
104
+
105
+ async def score(self, case: RetrievalCase, retrieved: RetrievalResult) -> MetricResult:
106
+ expected = set(case.expect_docs)
107
+ found = expected & set(retrieved.doc_ids[: case.k])
108
+ score = len(found) / len(expected)
109
+ missing = sorted(expected - found)
110
+ return MetricResult(
111
+ metric=self.name,
112
+ score=score,
113
+ passed=score >= self._min_score,
114
+ detail="" if not missing else f"missing: {', '.join(missing)}",
115
+ )
116
+
117
+
118
+ class MeanReciprocalRank:
119
+ """Reciprocal rank of the first expected document, averaged over cases.
120
+
121
+ Scored per case, so the "mean" is taken by the gate across cases and runs. One
122
+ number per case keeps the metric a drop-in for the gate's per-case arithmetic.
123
+ """
124
+
125
+ def __init__(self, *, name: str = "mrr", min_score: float = 0.5) -> None:
126
+ self._name = name
127
+ self._min_score = min_score
128
+
129
+ @property
130
+ def name(self) -> str:
131
+ return self._name
132
+
133
+ @property
134
+ def deterministic(self) -> bool:
135
+ return True
136
+
137
+ async def score(self, case: RetrievalCase, retrieved: RetrievalResult) -> MetricResult:
138
+ expected = set(case.expect_docs)
139
+ for rank, doc_id in enumerate(retrieved.doc_ids[: case.k], start=1):
140
+ if doc_id in expected:
141
+ score = 1.0 / rank
142
+ return MetricResult(
143
+ metric=self.name,
144
+ score=score,
145
+ passed=score >= self._min_score,
146
+ detail=f"first expected document {doc_id!r} at rank {rank}",
147
+ )
148
+ return MetricResult(
149
+ metric=self.name,
150
+ score=0.0,
151
+ passed=self._min_score <= 0.0,
152
+ detail=f"none of {', '.join(sorted(expected))} were retrieved within k={case.k}",
153
+ )
154
+
155
+
156
+ class CitationGrounding:
157
+ """Fraction of expected documents that a returned item cites *correctly*.
158
+
159
+ Distinct from recall because it scores citations rather than document ids, and
160
+ because it checks integrity first: a citation whose ``chunk_id`` or ``doc_id``
161
+ disagrees with the passage it is attached to is worse than a missing citation,
162
+ since it sends a reader to the wrong place while looking authoritative. Any
163
+ such mismatch scores 0 regardless of what was retrieved.
164
+
165
+ This definition is authored here: the IR has no citation metric kind, so there is
166
+ no upstream semantics to match. It is stated rather than derived.
167
+ """
168
+
169
+ def __init__(self, *, name: str = "citation-grounding", min_score: float = 1.0) -> None:
170
+ self._name = name
171
+ self._min_score = min_score
172
+
173
+ @property
174
+ def name(self) -> str:
175
+ return self._name
176
+
177
+ @property
178
+ def deterministic(self) -> bool:
179
+ return True
180
+
181
+ async def score(self, case: RetrievalCase, retrieved: RetrievalResult) -> MetricResult:
182
+ broken = [
183
+ item
184
+ for item in retrieved.items
185
+ if item.citation.chunk_id != item.chunk.chunk_id
186
+ or item.citation.doc_id != item.chunk.doc_id
187
+ ]
188
+ if broken:
189
+ return MetricResult(
190
+ metric=self.name,
191
+ score=0.0,
192
+ passed=False,
193
+ detail=(
194
+ f"{len(broken)} citation(s) disagree with the passage they name, "
195
+ f"first: {broken[0].citation} vs chunk {broken[0].chunk.chunk_id}"
196
+ ),
197
+ )
198
+ expected = set(case.expect_docs)
199
+ cited = {item.citation.doc_id for item in retrieved.items}
200
+ grounded = expected & cited
201
+ score = len(grounded) / len(expected)
202
+ missing = sorted(expected - grounded)
203
+ return MetricResult(
204
+ metric=self.name,
205
+ score=score,
206
+ passed=score >= self._min_score,
207
+ detail="" if not missing else f"uncited: {', '.join(missing)}",
208
+ )
209
+
210
+
211
+ def retrieval_metrics() -> tuple[RetrievalMetric, ...]:
212
+ """The retrieval metrics this runtime ships, in a stable order."""
213
+ return (RecallAtK(), MeanReciprocalRank(), CitationGrounding())
@@ -0,0 +1,258 @@
1
+ """A pgvector-backed store, provisioned locally and never touched by CI.
2
+
3
+ The driver is an optional extra, so this module must be importable without it.
4
+ Nothing at module scope imports ``psycopg``; the import happens inside
5
+ :func:`_load_driver` on first use, and a missing driver becomes a
6
+ :class:`~aid_runtime.errors.ConfigurationError` that names the extra to install.
7
+ Importing :mod:`aid_runtime.retrieval` therefore cannot fail because a database
8
+ extra is absent, which is what lets the default dependency set stay unchanged.
9
+
10
+ Two consequences of that choice are deliberate and visible:
11
+
12
+ * The driver is loaded as an untyped module, so ``Any`` reaches this file. It is
13
+ confined to this one adapter rather than spreading a suppressed import across the
14
+ retrieval package.
15
+ * Every statement is a fixed string. The embedding column is declared
16
+ dimension-less rather than ``vector(1536)``, so no computed identifier is ever
17
+ interpolated into SQL, and the pinned width is enforced where the pin actually
18
+ lives: the caller's :class:`~aid_runtime.retrieval.types.PinnedEmbedding`.
19
+
20
+ Correctness here is expected to match the in-memory adapter, but nothing checks
21
+ that: this adapter is not gate-covered, and its SQL path runs only on a machine where
22
+ someone brought the database up themselves. See ``compose.pgvector.yml``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ from collections.abc import Sequence
30
+ from typing import Any
31
+
32
+ from aid_runtime.errors import ConfigurationError, ProviderError
33
+ from aid_runtime.retrieval.types import (
34
+ Chunk,
35
+ DistanceMetric,
36
+ SearchHit,
37
+ StoreKind,
38
+ StoreRecord,
39
+ )
40
+
41
+ __all__ = ["PgVectorStore", "resolve_uri"]
42
+
43
+ _ENV_PREFIX = "env:"
44
+
45
+ _CREATE_EXTENSION = "CREATE EXTENSION IF NOT EXISTS vector"
46
+ _CREATE_TABLE = """
47
+ CREATE TABLE IF NOT EXISTS aid_chunks (
48
+ chunk_id text PRIMARY KEY,
49
+ doc_id text NOT NULL,
50
+ uri text NOT NULL,
51
+ chunk_index integer NOT NULL,
52
+ start_offset integer NOT NULL,
53
+ end_offset integer NOT NULL,
54
+ body text NOT NULL,
55
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
56
+ embedding vector NOT NULL
57
+ )
58
+ """
59
+ _UPSERT = """
60
+ INSERT INTO aid_chunks (
61
+ chunk_id, doc_id, uri, chunk_index, start_offset, end_offset, body, metadata, embedding
62
+ ) VALUES (
63
+ %(chunk_id)s, %(doc_id)s, %(uri)s, %(chunk_index)s, %(start_offset)s, %(end_offset)s,
64
+ %(body)s, %(metadata)s::jsonb, %(embedding)s::vector
65
+ )
66
+ ON CONFLICT (chunk_id) DO UPDATE SET
67
+ doc_id = EXCLUDED.doc_id,
68
+ uri = EXCLUDED.uri,
69
+ chunk_index = EXCLUDED.chunk_index,
70
+ start_offset = EXCLUDED.start_offset,
71
+ end_offset = EXCLUDED.end_offset,
72
+ body = EXCLUDED.body,
73
+ metadata = EXCLUDED.metadata,
74
+ embedding = EXCLUDED.embedding
75
+ """
76
+ _CORPUS = (
77
+ "SELECT chunk_id, doc_id, uri, chunk_index, start_offset, end_offset, body, metadata, "
78
+ "embedding FROM aid_chunks ORDER BY chunk_id"
79
+ )
80
+ # `<=>` is cosine distance, `<#>` is negative inner product and `<->` is L2 distance.
81
+ # Each is rewritten into "higher is better" so callers see one orientation, matching
82
+ # `stores.similarity`. The tie-break on `chunk_id` matches the in-memory adapter too:
83
+ # equal scores must rank identically on both.
84
+ #
85
+ # Every statement is spelled out in full rather than assembled from a column list, so
86
+ # that no statement is ever *constructed*: the only values that reach SQL are bound
87
+ # parameters.
88
+ _SEARCH: dict[DistanceMetric, str] = {
89
+ DistanceMetric.COSINE: (
90
+ "SELECT chunk_id, doc_id, uri, chunk_index, start_offset, end_offset, body, metadata, "
91
+ "embedding, 1 - (embedding <=> %(vector)s::vector) AS score FROM aid_chunks "
92
+ "ORDER BY embedding <=> %(vector)s::vector, chunk_id LIMIT %(top_k)s"
93
+ ),
94
+ DistanceMetric.DOT: (
95
+ "SELECT chunk_id, doc_id, uri, chunk_index, start_offset, end_offset, body, metadata, "
96
+ "embedding, -(embedding <#> %(vector)s::vector) AS score FROM aid_chunks "
97
+ "ORDER BY embedding <#> %(vector)s::vector, chunk_id LIMIT %(top_k)s"
98
+ ),
99
+ DistanceMetric.L2: (
100
+ "SELECT chunk_id, doc_id, uri, chunk_index, start_offset, end_offset, body, metadata, "
101
+ "embedding, -(embedding <-> %(vector)s::vector) AS score FROM aid_chunks "
102
+ "ORDER BY embedding <-> %(vector)s::vector, chunk_id LIMIT %(top_k)s"
103
+ ),
104
+ }
105
+
106
+
107
+ def _load_driver() -> Any:
108
+ """Import the optional driver, or explain how to install it."""
109
+ try:
110
+ import psycopg
111
+ except ImportError as error:
112
+ raise ConfigurationError(
113
+ "the pgvector store needs its driver; install it with "
114
+ "`pip install aid-runtime-python[pgvector]` (the default install omits it)"
115
+ ) from error
116
+ return psycopg
117
+
118
+
119
+ def resolve_uri(uri: str | None) -> str:
120
+ """Resolve an ``env:NAME`` reference to the variable's value.
121
+
122
+ An ``env:`` reference names a variable, not an alias: nothing here reaches the
123
+ model catalog, so resolving it at run time cannot silently change which store a
124
+ run wrote to.
125
+ """
126
+ if uri is None:
127
+ raise ConfigurationError("the pgvector store requires a uri")
128
+ if not uri.startswith(_ENV_PREFIX):
129
+ return uri
130
+ name = uri[len(_ENV_PREFIX) :]
131
+ if not name:
132
+ raise ConfigurationError(f"store uri {uri!r} names an empty environment variable")
133
+ value = os.environ.get(name)
134
+ if value is None:
135
+ raise ConfigurationError(
136
+ f"store uri {uri!r} refers to environment variable {name!r}, which is unset"
137
+ )
138
+ return value
139
+
140
+
141
+ def _literal(vector: Sequence[float]) -> str:
142
+ """pgvector's text input format, so its Python package is not a dependency."""
143
+ return "[" + ",".join(repr(float(value)) for value in vector) + "]"
144
+
145
+
146
+ class PgVectorStore:
147
+ """A single-table store: one row per chunk, keyed by content hash."""
148
+
149
+ def __init__(self, *, uri: str | None, dimensions: int | None = None) -> None:
150
+ self._uri = resolve_uri(uri)
151
+ self._dimensions = dimensions
152
+ self._connection: Any = None
153
+ self._closed = False
154
+
155
+ @property
156
+ def kind(self) -> StoreKind:
157
+ return StoreKind.PGVECTOR
158
+
159
+ async def _connect(self) -> Any:
160
+ if self._closed:
161
+ raise ConfigurationError("this store has been closed")
162
+ if self._connection is None:
163
+ psycopg = _load_driver()
164
+ try:
165
+ self._connection = await psycopg.AsyncConnection.connect(self._uri)
166
+ except Exception as error:
167
+ raise ProviderError(f"cannot connect to the pgvector store: {error}") from error
168
+ await self._ensure_schema()
169
+ return self._connection
170
+
171
+ async def _ensure_schema(self) -> None:
172
+ async with self._connection.cursor() as cursor:
173
+ await cursor.execute(_CREATE_EXTENSION)
174
+ await cursor.execute(_CREATE_TABLE)
175
+ await self._connection.commit()
176
+
177
+ async def upsert(self, records: Sequence[StoreRecord]) -> int:
178
+ if self._dimensions is not None:
179
+ for record in records:
180
+ if len(record.vector) != self._dimensions:
181
+ raise ConfigurationError(
182
+ f"store is pinned to {self._dimensions} dimensions but a record carries "
183
+ f"{len(record.vector)}"
184
+ )
185
+ if not records:
186
+ return 0
187
+ connection = await self._connect()
188
+ async with connection.cursor() as cursor:
189
+ for record in records:
190
+ await cursor.execute(_UPSERT, self._params(record))
191
+ await connection.commit()
192
+ return len(records)
193
+
194
+ async def search(
195
+ self, vector: Sequence[float], *, top_k: int, metric: DistanceMetric
196
+ ) -> tuple[SearchHit, ...]:
197
+ if top_k < 1:
198
+ raise ConfigurationError("top_k must be at least 1")
199
+ statement = _SEARCH.get(metric)
200
+ if statement is None:
201
+ raise ConfigurationError(f"pgvector cannot rank by {metric.value!r}")
202
+ connection = await self._connect()
203
+ async with connection.cursor() as cursor:
204
+ await cursor.execute(statement, {"vector": _literal(vector), "top_k": top_k})
205
+ rows = await cursor.fetchall()
206
+ return tuple(SearchHit(record=self._record(row), score=float(row[9])) for row in rows)
207
+
208
+ async def corpus(self) -> tuple[StoreRecord, ...]:
209
+ connection = await self._connect()
210
+ async with connection.cursor() as cursor:
211
+ await cursor.execute(_CORPUS)
212
+ rows = await cursor.fetchall()
213
+ return tuple(self._record(row) for row in rows)
214
+
215
+ async def aclose(self) -> None:
216
+ if self._closed:
217
+ return
218
+ self._closed = True
219
+ if self._connection is not None:
220
+ await self._connection.close()
221
+ self._connection = None
222
+
223
+ def _params(self, record: StoreRecord) -> dict[str, Any]:
224
+ chunk = record.chunk
225
+ return {
226
+ "chunk_id": chunk.chunk_id,
227
+ "doc_id": chunk.doc_id,
228
+ "uri": chunk.uri,
229
+ "chunk_index": chunk.index,
230
+ "start_offset": chunk.start,
231
+ "end_offset": chunk.end,
232
+ "body": chunk.text,
233
+ "metadata": json.dumps(chunk.metadata, sort_keys=True),
234
+ "embedding": _literal(record.vector),
235
+ }
236
+
237
+ def _record(self, row: Sequence[Any]) -> StoreRecord:
238
+ return StoreRecord(
239
+ chunk=Chunk(
240
+ chunk_id=row[0],
241
+ doc_id=row[1],
242
+ uri=row[2],
243
+ index=row[3],
244
+ start=row[4],
245
+ end=row[5],
246
+ text=row[6],
247
+ metadata=row[7] or {},
248
+ ),
249
+ vector=tuple(float(value) for value in _parse_vector(row[8])),
250
+ )
251
+
252
+
253
+ def _parse_vector(value: Any) -> list[float]:
254
+ """Accept either a driver-returned sequence or pgvector's text form."""
255
+ if isinstance(value, str):
256
+ stripped = value.strip().strip("[]")
257
+ return [float(part) for part in stripped.split(",")] if stripped else []
258
+ return [float(item) for item in value]
@@ -0,0 +1,133 @@
1
+ """Reordering a retrieved set before it is returned.
2
+
3
+ Exactly one reranker exists in Phase 1, and it is lexical. That is a deliberate
4
+ limitation rather than an unfinished one: the seam is what this layer owes, and the
5
+ deterministic implementation is what lets a gate assert on ranking behaviour without
6
+ an API key or a floating cross-encoder.
7
+
8
+ Why rerank at all when hybrid search already blends two rankings? Because fusion
9
+ answers "which candidates are worth considering" and reranking answers "which of
10
+ these best answers the question", and the second question can look at the query and
11
+ one passage together instead of at ranks alone. The lexical reranker does that with
12
+ query-term coverage, which is cheap, explainable, and stable.
13
+
14
+ The reranker **replaces** each item's score instead of adding a second number. After
15
+ reranking, the number that ranks the list is the reranker's, and keeping a fused RRF
16
+ value in the same field would put two incomparable scales in one place. Ranks are
17
+ renumbered from 1 so ``RetrievalResult.context`` stays resolvable.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Sequence
23
+ from typing import Protocol, runtime_checkable
24
+
25
+ from aid_runtime.errors import ConfigurationError, FeatureDeferredError
26
+ from aid_runtime.retrieval.citations import RetrievedItem, cite
27
+ from aid_runtime.retrieval.hybrid import tokenize
28
+ from aid_runtime.retrieval.types import RerankSpec
29
+
30
+ __all__ = [
31
+ "LexicalReranker",
32
+ "Reranker",
33
+ "lexical_score",
34
+ "reranker_for",
35
+ ]
36
+
37
+ _LEXICAL_MARKER = "lexical"
38
+
39
+
40
+ def lexical_score(query: str, text: str) -> float:
41
+ """How much of ``query`` the passage actually covers, in ``[0, 1]``.
42
+
43
+ Query-term coverage, not term frequency: for "which passages answer this", a
44
+ passage containing each distinct query term once is as good a candidate as one
45
+ repeating a term, and rewarding repetition is how a keyword-stuffed chunk wins.
46
+ An exact phrase match breaks ties toward the passage quoting the query.
47
+ """
48
+ terms = set(tokenize(query))
49
+ if not terms:
50
+ return 0.0
51
+ present = set(tokenize(text))
52
+ coverage = len(terms & present) / len(terms)
53
+ if query.strip() and query.strip().lower() in text.lower():
54
+ # Small and bounded, so it can only order passages that already cover the
55
+ # query equally well; it can never promote an unrelated passage.
56
+ return min(1.0, coverage + 0.1)
57
+ return coverage
58
+
59
+
60
+ @runtime_checkable
61
+ class Reranker(Protocol):
62
+ """Reorder candidates for a query, deterministically."""
63
+
64
+ @property
65
+ def name(self) -> str: ...
66
+
67
+ async def rerank(
68
+ self,
69
+ query: str,
70
+ items: Sequence[RetrievedItem],
71
+ *,
72
+ top_n: int,
73
+ ) -> tuple[RetrievedItem, ...]: ...
74
+
75
+
76
+ class LexicalReranker:
77
+ """Coverage-based reranking, with ties broken on chunk id."""
78
+
79
+ def __init__(self, *, name: str = "lexical") -> None:
80
+ self._name = name
81
+
82
+ @property
83
+ def name(self) -> str:
84
+ return self._name
85
+
86
+ async def rerank(
87
+ self,
88
+ query: str,
89
+ items: Sequence[RetrievedItem],
90
+ *,
91
+ top_n: int,
92
+ ) -> tuple[RetrievedItem, ...]:
93
+ if top_n < 1:
94
+ raise ConfigurationError("rerank top_n must be at least 1")
95
+ scored = [
96
+ (lexical_score(query, item.chunk.text), item.chunk.chunk_id, item) for item in items
97
+ ]
98
+ # A reranker that cannot read the query leaves the incoming order alone
99
+ # rather than shuffling it: every score is 0.0, so the tie-break would be
100
+ # chunk-id order, which is an arbitrary order dressed up as a decision.
101
+ if all(score == 0.0 for score, _, _ in scored):
102
+ return tuple(items[:top_n])
103
+ scored.sort(key=lambda entry: (-entry[0], entry[1]))
104
+ return tuple(
105
+ RetrievedItem(
106
+ chunk=item.chunk,
107
+ score=score,
108
+ rank=rank,
109
+ citation=cite(item.chunk),
110
+ )
111
+ for rank, (score, _, item) in enumerate(scored[:top_n], start=1)
112
+ )
113
+
114
+
115
+ def reranker_for(spec: RerankSpec | None) -> Reranker | None:
116
+ """The reranker a retriever's ``rerank`` block asks for, if any.
117
+
118
+ ``rerank.model`` is free-form in the IR, and Phase 1 ships exactly one
119
+ implementation. A declared model outside the lexical family is refused rather
120
+ than quietly served by :class:`LexicalReranker`, for the same reason an
121
+ unimplemented source kind or store kind is refused: a retriever that appears to
122
+ rerank with a cross-encoder it never called is worse than one that says it
123
+ cannot. The guard matches on the name because the name is the only signal the
124
+ IR gives; the lexical family is what this runtime can honestly claim.
125
+ """
126
+ if spec is None:
127
+ return None
128
+ if _LEXICAL_MARKER not in spec.model.casefold():
129
+ raise FeatureDeferredError(
130
+ f"rerank model {spec.model!r} is declared but not implemented: Phase 1 ships one "
131
+ "deterministic lexical reranker, and a hosted cross-encoder is a later layer"
132
+ )
133
+ return LexicalReranker()