raglab 0.2.6__tar.gz → 0.2.8__tar.gz
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.
- {raglab-0.2.6 → raglab-0.2.8}/.claude/CLAUDE.md +4 -1
- {raglab-0.2.6 → raglab-0.2.8}/PKG-INFO +15 -10
- {raglab-0.2.6 → raglab-0.2.8}/README.md +13 -8
- {raglab-0.2.6 → raglab-0.2.8}/pyproject.toml +1 -1
- {raglab-0.2.6 → raglab-0.2.8}/raglab/__init__.py +20 -0
- {raglab-0.2.6 → raglab-0.2.8}/raglab/agent.py +108 -5
- raglab-0.2.8/raglab/judging.py +325 -0
- {raglab-0.2.6 → raglab-0.2.8}/tests/test_fanin.py +141 -0
- raglab-0.2.8/tests/test_judging.py +220 -0
- {raglab-0.2.6 → raglab-0.2.8}/.editorconfig +0 -0
- {raglab-0.2.6 → raglab-0.2.8}/.gitattributes +0 -0
- {raglab-0.2.6 → raglab-0.2.8}/.github/workflows/ci.yml +0 -0
- {raglab-0.2.6 → raglab-0.2.8}/.gitignore +0 -0
- {raglab-0.2.6 → raglab-0.2.8}/CHANGELOG.md +0 -0
- {raglab-0.2.6 → raglab-0.2.8}/LICENSE +0 -0
- {raglab-0.2.6 → raglab-0.2.8}/raglab/llm.py +0 -0
- {raglab-0.2.6 → raglab-0.2.8}/tests/test_agent.py +0 -0
- {raglab-0.2.6 → raglab-0.2.8}/tests/test_llm_roles.py +0 -0
|
@@ -29,7 +29,10 @@ control loop, with concrete tools injected at the leaves.
|
|
|
29
29
|
cyclic-graph runner. This back-edge is what makes it an agent vs a DAG.
|
|
30
30
|
- **Budget governor** (`max_rounds` / `max_sources_per_task` / `max_results_per_task`);
|
|
31
31
|
termination as a **separately injected policy** (ir_09 §9), not folded into the
|
|
32
|
-
evaluator.
|
|
32
|
+
evaluator. Pool bounding is an injected `pool_cap` policy (default
|
|
33
|
+
`rank_fair_cap`: per-source best-first, round-robin). It must return hits
|
|
34
|
+
untouched — never pre-rank the pool with the RRF reranker (fused scores must not
|
|
35
|
+
re-enter the pool).
|
|
33
36
|
- **Source registry** = a live `Mapping[name, Retriever]` across *heterogeneous*
|
|
34
37
|
backends (ir corpora + web/SQL/graph); cross-source merge + global rerank at
|
|
35
38
|
the fan-in point.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: raglab
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.8
|
|
4
4
|
Summary: A medley of tools to make RAG-based applications.
|
|
5
5
|
Project-URL: Homepage, https://thorwhalen.github.io/raglab/
|
|
6
6
|
Project-URL: Repository, https://github.com/thorwhalen/raglab
|
|
@@ -68,7 +68,7 @@ import raglab
|
|
|
68
68
|
# Register named ir corpora as the agent's sources, then search across them.
|
|
69
69
|
sources = raglab.ir_sources("skills", "reports", mode="hybrid")
|
|
70
70
|
agent = raglab.make_search_agent(sources)
|
|
71
|
-
results = agent("how do I deploy the app")
|
|
71
|
+
results = agent("how do I deploy the app") # -> list[ir.SearchHit], ranked
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
A self-contained, runnable example (build a tiny corpus, search it):
|
|
@@ -80,18 +80,18 @@ import raglab
|
|
|
80
80
|
|
|
81
81
|
docs = {
|
|
82
82
|
"deploy": "deploy the app to the server with systemd units",
|
|
83
|
-
"embed":
|
|
83
|
+
"embed": "embed text with a model and cache the vectors",
|
|
84
84
|
"search": "vector similarity search with metadata filters",
|
|
85
85
|
}
|
|
86
86
|
corpus = ir.build(
|
|
87
87
|
ir.CorpusSource.from_mapping(docs, name="t", strategy=ir.WholeText()),
|
|
88
88
|
store=CorpusStore.memory(),
|
|
89
|
-
embedder="light",
|
|
89
|
+
embedder="light", # numpy-only, no model download
|
|
90
90
|
)
|
|
91
91
|
|
|
92
92
|
agent = raglab.make_search_agent({"t": ir.as_retriever(corpus, k=3)})
|
|
93
93
|
results = agent("deploy the app to the server")
|
|
94
|
-
results[0].artifact_id
|
|
94
|
+
results[0].artifact_id # -> "deploy"
|
|
95
95
|
```
|
|
96
96
|
|
|
97
97
|
`sources` is any `Mapping[name, Retriever]`. Build it with
|
|
@@ -147,8 +147,8 @@ import raglab
|
|
|
147
147
|
|
|
148
148
|
agent = raglab.make_search_agent(
|
|
149
149
|
sources,
|
|
150
|
-
formulator=raglab.make_llm_formulator(),
|
|
151
|
-
evaluator=raglab.make_llm_evaluator(),
|
|
150
|
+
formulator=raglab.make_llm_formulator(), # rewrite/expand the query (HyDE)
|
|
151
|
+
evaluator=raglab.make_llm_evaluator(), # sufficiency + the back-edge
|
|
152
152
|
)
|
|
153
153
|
results = agent("how do I deploy the app")
|
|
154
154
|
```
|
|
@@ -210,14 +210,19 @@ Every role is just a callable matching a `Protocol`, so you can inject your own:
|
|
|
210
210
|
```python
|
|
211
211
|
from raglab import SubTask, LowLevelQuery, Judgement, make_search_agent
|
|
212
212
|
|
|
213
|
+
|
|
213
214
|
def my_planner(query, sources):
|
|
214
215
|
# split into two sub-goals over all sources
|
|
215
|
-
return [
|
|
216
|
-
|
|
216
|
+
return [
|
|
217
|
+
SubTask(goal=g, sources=tuple(sources))
|
|
218
|
+
for g in (query.text, query.text + " examples")
|
|
219
|
+
]
|
|
220
|
+
|
|
217
221
|
|
|
218
222
|
def my_formulator(task, source):
|
|
219
223
|
return [LowLevelQuery(source=source, query=task.goal, params={"k": 10})]
|
|
220
224
|
|
|
225
|
+
|
|
221
226
|
agent = make_search_agent(sources, planner=my_planner, formulator=my_formulator)
|
|
222
227
|
```
|
|
223
228
|
|
|
@@ -34,7 +34,7 @@ import raglab
|
|
|
34
34
|
# Register named ir corpora as the agent's sources, then search across them.
|
|
35
35
|
sources = raglab.ir_sources("skills", "reports", mode="hybrid")
|
|
36
36
|
agent = raglab.make_search_agent(sources)
|
|
37
|
-
results = agent("how do I deploy the app")
|
|
37
|
+
results = agent("how do I deploy the app") # -> list[ir.SearchHit], ranked
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
A self-contained, runnable example (build a tiny corpus, search it):
|
|
@@ -46,18 +46,18 @@ import raglab
|
|
|
46
46
|
|
|
47
47
|
docs = {
|
|
48
48
|
"deploy": "deploy the app to the server with systemd units",
|
|
49
|
-
"embed":
|
|
49
|
+
"embed": "embed text with a model and cache the vectors",
|
|
50
50
|
"search": "vector similarity search with metadata filters",
|
|
51
51
|
}
|
|
52
52
|
corpus = ir.build(
|
|
53
53
|
ir.CorpusSource.from_mapping(docs, name="t", strategy=ir.WholeText()),
|
|
54
54
|
store=CorpusStore.memory(),
|
|
55
|
-
embedder="light",
|
|
55
|
+
embedder="light", # numpy-only, no model download
|
|
56
56
|
)
|
|
57
57
|
|
|
58
58
|
agent = raglab.make_search_agent({"t": ir.as_retriever(corpus, k=3)})
|
|
59
59
|
results = agent("deploy the app to the server")
|
|
60
|
-
results[0].artifact_id
|
|
60
|
+
results[0].artifact_id # -> "deploy"
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
`sources` is any `Mapping[name, Retriever]`. Build it with
|
|
@@ -113,8 +113,8 @@ import raglab
|
|
|
113
113
|
|
|
114
114
|
agent = raglab.make_search_agent(
|
|
115
115
|
sources,
|
|
116
|
-
formulator=raglab.make_llm_formulator(),
|
|
117
|
-
evaluator=raglab.make_llm_evaluator(),
|
|
116
|
+
formulator=raglab.make_llm_formulator(), # rewrite/expand the query (HyDE)
|
|
117
|
+
evaluator=raglab.make_llm_evaluator(), # sufficiency + the back-edge
|
|
118
118
|
)
|
|
119
119
|
results = agent("how do I deploy the app")
|
|
120
120
|
```
|
|
@@ -176,14 +176,19 @@ Every role is just a callable matching a `Protocol`, so you can inject your own:
|
|
|
176
176
|
```python
|
|
177
177
|
from raglab import SubTask, LowLevelQuery, Judgement, make_search_agent
|
|
178
178
|
|
|
179
|
+
|
|
179
180
|
def my_planner(query, sources):
|
|
180
181
|
# split into two sub-goals over all sources
|
|
181
|
-
return [
|
|
182
|
-
|
|
182
|
+
return [
|
|
183
|
+
SubTask(goal=g, sources=tuple(sources))
|
|
184
|
+
for g in (query.text, query.text + " examples")
|
|
185
|
+
]
|
|
186
|
+
|
|
183
187
|
|
|
184
188
|
def my_formulator(task, source):
|
|
185
189
|
return [LowLevelQuery(source=source, query=task.goal, params={"k": 10})]
|
|
186
190
|
|
|
191
|
+
|
|
187
192
|
agent = make_search_agent(sources, planner=my_planner, formulator=my_formulator)
|
|
188
193
|
```
|
|
189
194
|
|
|
@@ -30,32 +30,44 @@ from .agent import (
|
|
|
30
30
|
Citer,
|
|
31
31
|
Evaluator,
|
|
32
32
|
Formulator,
|
|
33
|
+
GradedLabel,
|
|
33
34
|
Judgement,
|
|
34
35
|
LowLevelQuery,
|
|
35
36
|
Planner,
|
|
37
|
+
PoolCap,
|
|
36
38
|
Query,
|
|
37
39
|
Reranker,
|
|
38
40
|
Result,
|
|
39
41
|
Retriever,
|
|
40
42
|
SingleContextAgent,
|
|
41
43
|
SubTask,
|
|
44
|
+
arrival_order_cap,
|
|
42
45
|
identity_citer,
|
|
43
46
|
identity_formulator,
|
|
44
47
|
ir_sources,
|
|
45
48
|
make_rrf_reranker,
|
|
46
49
|
make_search_agent,
|
|
47
50
|
passthrough_evaluator,
|
|
51
|
+
rank_fair_cap,
|
|
48
52
|
rrf_reranker,
|
|
49
53
|
score_reranker,
|
|
50
54
|
single_subtask_planner,
|
|
51
55
|
)
|
|
52
56
|
from .llm import EVALUATION_PROMPT, make_llm_evaluator, make_llm_formulator
|
|
57
|
+
from .judging import (
|
|
58
|
+
DEFAULT_LEVELS,
|
|
59
|
+
DEFAULT_TOPICAL_RUBRIC,
|
|
60
|
+
GRADING_PROMPT,
|
|
61
|
+
apply_gates,
|
|
62
|
+
make_graded_rubric_evaluator,
|
|
63
|
+
)
|
|
53
64
|
|
|
54
65
|
__all__ = [
|
|
55
66
|
"Query",
|
|
56
67
|
"SubTask",
|
|
57
68
|
"LowLevelQuery",
|
|
58
69
|
"Judgement",
|
|
70
|
+
"GradedLabel",
|
|
59
71
|
"Result",
|
|
60
72
|
"Retriever",
|
|
61
73
|
"Planner",
|
|
@@ -64,6 +76,9 @@ __all__ = [
|
|
|
64
76
|
"Reranker",
|
|
65
77
|
"Citer",
|
|
66
78
|
"Budget",
|
|
79
|
+
"PoolCap",
|
|
80
|
+
"rank_fair_cap",
|
|
81
|
+
"arrival_order_cap",
|
|
67
82
|
"SingleContextAgent",
|
|
68
83
|
"make_search_agent",
|
|
69
84
|
"ir_sources",
|
|
@@ -77,4 +92,9 @@ __all__ = [
|
|
|
77
92
|
"make_llm_formulator",
|
|
78
93
|
"make_llm_evaluator",
|
|
79
94
|
"EVALUATION_PROMPT",
|
|
95
|
+
"DEFAULT_LEVELS",
|
|
96
|
+
"DEFAULT_TOPICAL_RUBRIC",
|
|
97
|
+
"GRADING_PROMPT",
|
|
98
|
+
"apply_gates",
|
|
99
|
+
"make_graded_rubric_evaluator",
|
|
80
100
|
]
|
|
@@ -31,6 +31,7 @@ Dependency direction is one-way: `raglab` imports `ir`; `ir` never imports
|
|
|
31
31
|
|
|
32
32
|
from __future__ import annotations
|
|
33
33
|
|
|
34
|
+
import math
|
|
34
35
|
from collections.abc import Mapping, Sequence
|
|
35
36
|
from dataclasses import dataclass, field
|
|
36
37
|
from typing import Any, Protocol, runtime_checkable
|
|
@@ -51,6 +52,7 @@ __all__ = [
|
|
|
51
52
|
"SubTask",
|
|
52
53
|
"LowLevelQuery",
|
|
53
54
|
"Judgement",
|
|
55
|
+
"GradedLabel",
|
|
54
56
|
"Result",
|
|
55
57
|
"Retriever",
|
|
56
58
|
"Planner",
|
|
@@ -59,6 +61,9 @@ __all__ = [
|
|
|
59
61
|
"Reranker",
|
|
60
62
|
"Citer",
|
|
61
63
|
"Budget",
|
|
64
|
+
"PoolCap",
|
|
65
|
+
"rank_fair_cap",
|
|
66
|
+
"arrival_order_cap",
|
|
62
67
|
"SingleContextAgent",
|
|
63
68
|
"make_search_agent",
|
|
64
69
|
"ir_sources",
|
|
@@ -107,6 +112,25 @@ class LowLevelQuery:
|
|
|
107
112
|
params: Mapping[str, Any] = field(default_factory=dict)
|
|
108
113
|
|
|
109
114
|
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class GradedLabel:
|
|
117
|
+
"""A graded verdict on one candidate (raglab#8): a level, three axes, the evidence.
|
|
118
|
+
|
|
119
|
+
``level`` is one of the evaluator's ordered levels (best first). The axes are
|
|
120
|
+
Self-RAG's: ``is_about`` (topical, ISREL), ``is_supported`` (the content
|
|
121
|
+
actually establishes it, not a lexical mention: ISSUP) and ``is_useful``
|
|
122
|
+
(ISUSE). ``evidence_quote`` is a verbatim span of the candidate's content
|
|
123
|
+
backing the level; ``facet`` names the sub-goal it was judged against.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
level: str
|
|
127
|
+
is_about: bool
|
|
128
|
+
is_supported: bool
|
|
129
|
+
is_useful: bool
|
|
130
|
+
evidence_quote: str = ""
|
|
131
|
+
facet: str | None = None
|
|
132
|
+
|
|
133
|
+
|
|
110
134
|
@dataclass(frozen=True)
|
|
111
135
|
class Judgement:
|
|
112
136
|
"""An evaluator's verdict over a round's results.
|
|
@@ -114,12 +138,15 @@ class Judgement:
|
|
|
114
138
|
``relevant`` is the kept subset; ``sufficient`` says whether to stop; a
|
|
115
139
|
non-``None`` ``refinement`` is the **back-edge** — the next sub-task to
|
|
116
140
|
re-query with. The pass-through default returns ``sufficient=True`` and no
|
|
117
|
-
refinement (no loop).
|
|
141
|
+
refinement (no loop). ``graded`` is optional and additive: a graded
|
|
142
|
+
evaluator maps each judged ``artifact_id`` to its :class:`GradedLabel`;
|
|
143
|
+
binary evaluators leave it empty.
|
|
118
144
|
"""
|
|
119
145
|
|
|
120
146
|
relevant: Sequence[Result]
|
|
121
147
|
sufficient: bool
|
|
122
148
|
refinement: SubTask | None = None
|
|
149
|
+
graded: Mapping[str, GradedLabel] = field(default_factory=dict)
|
|
123
150
|
|
|
124
151
|
|
|
125
152
|
# --------------------------------------------------------------------------- #
|
|
@@ -178,6 +205,70 @@ class Budget:
|
|
|
178
205
|
max_results_per_task: int = 50
|
|
179
206
|
|
|
180
207
|
|
|
208
|
+
@runtime_checkable
|
|
209
|
+
class PoolCap(Protocol):
|
|
210
|
+
"""Bound the pool the evaluator sees to at most ``limit`` results.
|
|
211
|
+
|
|
212
|
+
A governor decision (which hits survive the budget), injected like every other
|
|
213
|
+
role so a strategy can be swapped without touching the loop. It must return
|
|
214
|
+
hits **unchanged** — never re-scored — because the pool keeps raw per-source
|
|
215
|
+
magnitudes until the fan-in reranker.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __call__(self, results: Sequence[Result], limit: int) -> list[Result]: ...
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _rank_key(hit: Result) -> float:
|
|
222
|
+
"""A hit's raw score for ordering *within its source*; a missing or NaN score is last."""
|
|
223
|
+
score = hit.score
|
|
224
|
+
if isinstance(score, (int, float)) and not math.isnan(score):
|
|
225
|
+
return float(score)
|
|
226
|
+
return -math.inf
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def rank_fair_cap(results: Sequence[Result], limit: int) -> list[Result]:
|
|
230
|
+
"""Keep at most ``limit`` hits, taking the best of each source in turn (the default).
|
|
231
|
+
|
|
232
|
+
Groups the pool by ``hit.source`` (in first-arrival order; untagged hits are one
|
|
233
|
+
group), collapses each group to one hit per artifact (its best surface, as
|
|
234
|
+
:func:`ir.base.best_per_artifact` does), orders the group best-first by its own raw
|
|
235
|
+
score -- sound, since one source is one score scale -- and deals hits round-robin
|
|
236
|
+
across the groups. So a late source's strong hits are never cut before the evaluator
|
|
237
|
+
sees them, a duplicate never takes a slot, and no score ever crosses a source
|
|
238
|
+
boundary or is rewritten (unlike pre-ranking with the RRF reranker, whose fused
|
|
239
|
+
ordinal scores must never re-enter the pool). The output is in that dealt order
|
|
240
|
+
whatever the pool's size. A missing or NaN score sorts last in its source.
|
|
241
|
+
"""
|
|
242
|
+
if limit <= 0:
|
|
243
|
+
return []
|
|
244
|
+
groups: dict[str | None, dict[str, Result]] = {}
|
|
245
|
+
for hit in results:
|
|
246
|
+
best = groups.setdefault(hit.source, {})
|
|
247
|
+
cur = best.get(hit.artifact_id)
|
|
248
|
+
if cur is None or _rank_key(hit) > _rank_key(cur):
|
|
249
|
+
best[hit.artifact_id] = hit
|
|
250
|
+
queues = [
|
|
251
|
+
sorted(best.values(), key=_rank_key, reverse=True) for best in groups.values()
|
|
252
|
+
]
|
|
253
|
+
capped: list[Result] = []
|
|
254
|
+
for depth in range(max((len(q) for q in queues), default=0)):
|
|
255
|
+
for queue in queues:
|
|
256
|
+
if depth < len(queue):
|
|
257
|
+
capped.append(queue[depth])
|
|
258
|
+
if len(capped) == limit:
|
|
259
|
+
return capped
|
|
260
|
+
return capped
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def arrival_order_cap(results: Sequence[Result], limit: int) -> list[Result]:
|
|
264
|
+
"""Keep the first ``limit`` hits in arrival order (source order x query order).
|
|
265
|
+
|
|
266
|
+
The pre-governor behaviour: a late source's strong hits can be cut before the
|
|
267
|
+
evaluator sees them. Kept as an explicit opt-in (``pool_cap=arrival_order_cap``).
|
|
268
|
+
"""
|
|
269
|
+
return list(results[: max(0, limit)])
|
|
270
|
+
|
|
271
|
+
|
|
181
272
|
# --------------------------------------------------------------------------- #
|
|
182
273
|
# Default role implementations (the no-LLM thin slice) — ir_09 §8 step 2
|
|
183
274
|
# --------------------------------------------------------------------------- #
|
|
@@ -306,6 +397,7 @@ class SingleContextAgent:
|
|
|
306
397
|
reranker: Reranker = rrf_reranker
|
|
307
398
|
citer: Citer = identity_citer
|
|
308
399
|
budget: Budget = field(default_factory=Budget)
|
|
400
|
+
pool_cap: PoolCap = rank_fair_cap
|
|
309
401
|
|
|
310
402
|
def __call__(self, query: str | Query) -> list[Result]:
|
|
311
403
|
"""Run the agent for *query*; returns the final ranked, cited results."""
|
|
@@ -317,9 +409,15 @@ class SingleContextAgent:
|
|
|
317
409
|
return list(self.citer(ranked))
|
|
318
410
|
|
|
319
411
|
def _run_task(self, task: SubTask) -> list[Result]:
|
|
320
|
-
|
|
412
|
+
# `kept` is what the evaluator judged relevant last round: governor-approved,
|
|
413
|
+
# carried into the next round untouched. Only a round's *new* hits compete for
|
|
414
|
+
# the rest of the budget (`pool_cap`), so a re-query can never evict a hit the
|
|
415
|
+
# evaluator already kept, nor spend a slot re-offering it.
|
|
416
|
+
kept: list[Result] = []
|
|
321
417
|
current = task
|
|
418
|
+
limit = self.budget.max_results_per_task
|
|
322
419
|
for _ in range(max(1, self.budget.max_rounds)):
|
|
420
|
+
found: list[Result] = []
|
|
323
421
|
for source in current.sources[: self.budget.max_sources_per_task]:
|
|
324
422
|
retriever = self.sources.get(source)
|
|
325
423
|
if retriever is None:
|
|
@@ -333,12 +431,15 @@ class SingleContextAgent:
|
|
|
333
431
|
found.extend(
|
|
334
432
|
tag_source(retriever(llq.query, **dict(llq.params)), source)
|
|
335
433
|
)
|
|
336
|
-
|
|
337
|
-
|
|
434
|
+
seen = {(h.source, h.artifact_id) for h in kept}
|
|
435
|
+
fresh = [h for h in found if (h.source, h.artifact_id) not in seen]
|
|
436
|
+
pool = kept + self.pool_cap(fresh, max(0, limit - len(kept)))
|
|
437
|
+
judged = self.evaluator(current, pool)
|
|
438
|
+
kept = list(judged.relevant)
|
|
338
439
|
if judged.sufficient or judged.refinement is None:
|
|
339
440
|
break
|
|
340
441
|
current = judged.refinement # the back-edge: re-query
|
|
341
|
-
return
|
|
442
|
+
return kept
|
|
342
443
|
|
|
343
444
|
|
|
344
445
|
def make_search_agent(
|
|
@@ -350,6 +451,7 @@ def make_search_agent(
|
|
|
350
451
|
reranker: Reranker | None = None,
|
|
351
452
|
citer: Citer | None = None,
|
|
352
453
|
budget: Budget | None = None,
|
|
454
|
+
pool_cap: PoolCap | None = None,
|
|
353
455
|
) -> SingleContextAgent:
|
|
354
456
|
"""Build a :class:`SingleContextAgent` over *sources* with smart defaults.
|
|
355
457
|
|
|
@@ -372,6 +474,7 @@ def make_search_agent(
|
|
|
372
474
|
reranker=reranker or rrf_reranker,
|
|
373
475
|
citer=citer or identity_citer,
|
|
374
476
|
budget=budget or Budget(),
|
|
477
|
+
pool_cap=pool_cap or rank_fair_cap,
|
|
375
478
|
)
|
|
376
479
|
|
|
377
480
|
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Graded rubric judging — every candidate gets a level, three axes and evidence (raglab#8).
|
|
2
|
+
|
|
3
|
+
A binary relevant/not cannot say "mentions the word but is not about it". The
|
|
4
|
+
evaluator built by :func:`make_graded_rubric_evaluator` asks an injected judge,
|
|
5
|
+
for **each** candidate in the round's pool, for the three Self-RAG axes
|
|
6
|
+
(``is_about``, ``is_supported``, ``is_useful``), a level on an ordered scale and
|
|
7
|
+
a verbatim ``evidence_quote`` from the candidate's disclosed content. The judge
|
|
8
|
+
proposes; deterministic gates here dispose:
|
|
9
|
+
|
|
10
|
+
- a level above ``unsupported_cap`` needs ``is_supported`` — topical vocabulary
|
|
11
|
+
without supporting content is capped (the direct fix for on-word distractors);
|
|
12
|
+
- with ``require_evidence_quote``, it also needs a quote found verbatim in the
|
|
13
|
+
content the judge was shown (whitespace-insensitive);
|
|
14
|
+
- ``calibration_offset`` (levels, learned from a gold slice) shifts every level
|
|
15
|
+
down (positive: the judge over-grades) or up;
|
|
16
|
+
- the level options are shown in a fresh random order on every call, against
|
|
17
|
+
position bias.
|
|
18
|
+
|
|
19
|
+
The result is a :class:`~raglab.agent.Judgement` whose additive ``graded`` map
|
|
20
|
+
carries every label, whose ``relevant`` keeps the candidates at or above
|
|
21
|
+
``relevant_level``, and whose back-edge comes from an injected ``refine`` policy
|
|
22
|
+
(none by default: the loop stops). As in :mod:`raglab.llm`, the default judge is
|
|
23
|
+
built lazily on :mod:`oa`, so ``import raglab`` stays offline, and a judge that
|
|
24
|
+
fails on a candidate leaves it ungraded rather than guessing.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import math
|
|
32
|
+
import random
|
|
33
|
+
import re
|
|
34
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
import ir
|
|
38
|
+
from ir.base import best_per_artifact
|
|
39
|
+
|
|
40
|
+
from .agent import Evaluator, GradedLabel, Judgement, Result, SubTask
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"DEFAULT_LEVELS",
|
|
44
|
+
"DEFAULT_TOPICAL_RUBRIC",
|
|
45
|
+
"GRADING_PROMPT",
|
|
46
|
+
"make_graded_rubric_evaluator",
|
|
47
|
+
"apply_gates",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
#: Ordered levels, best first.
|
|
51
|
+
DEFAULT_LEVELS: tuple[str, ...] = ("core", "strong", "uses-tools", "tangential", "none")
|
|
52
|
+
|
|
53
|
+
#: What the default levels mean, for a "is this package about TOPIC" question.
|
|
54
|
+
DEFAULT_TOPICAL_RUBRIC = """\
|
|
55
|
+
- core: the candidate's main purpose IS the topic.
|
|
56
|
+
- strong: a substantial, central part of the candidate is about the topic.
|
|
57
|
+
- uses-tools: the candidate builds on tools or libraries of the topic (its dependencies or imports establish it) without being about it.
|
|
58
|
+
- tangential: the topic is mentioned, or its vocabulary appears, but the content does not establish it.
|
|
59
|
+
- none: unrelated.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
#: The judge's prompt; ``{levels}`` is shown in a fresh random order each call.
|
|
63
|
+
GRADING_PROMPT = """\
|
|
64
|
+
Goal: {goal}
|
|
65
|
+
|
|
66
|
+
Rubric (levels, in no particular order):
|
|
67
|
+
{levels}
|
|
68
|
+
|
|
69
|
+
Definitions:
|
|
70
|
+
{rubric}
|
|
71
|
+
|
|
72
|
+
Candidate {artifact_id}:
|
|
73
|
+
---
|
|
74
|
+
{content}
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
Answer with one JSON object and nothing else, with keys:
|
|
78
|
+
"is_about" (bool: is the candidate topically about the goal?),
|
|
79
|
+
"is_supported" (bool: does its content - code, dependencies, imports, full text - actually establish it, beyond a mention of the words?),
|
|
80
|
+
"is_useful" (bool: would it help someone pursuing the goal?),
|
|
81
|
+
"evidence_quote" (a short span copied VERBATIM from the candidate's content that supports your level),
|
|
82
|
+
"level" (one of the rubric's levels).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
#: A graded judge: keyword arguments ``goal, artifact_id, content, levels, rubric``
|
|
86
|
+
#: -> a mapping with the label's keys, or the raw JSON text of one.
|
|
87
|
+
GradedJudge = Callable[..., "Mapping[str, Any] | str"]
|
|
88
|
+
|
|
89
|
+
#: A refinement policy: ``(task, graded) -> the next SubTask, or None to stop``.
|
|
90
|
+
Refine = Callable[[SubTask, Mapping[str, GradedLabel]], "SubTask | None"]
|
|
91
|
+
|
|
92
|
+
#: Truncate each candidate's disclosed content to this many chars in the prompt.
|
|
93
|
+
DFLT_MAX_CONTENT_CHARS = 4000
|
|
94
|
+
|
|
95
|
+
#: A shorter evidence quote ("a", "the") would be found in almost any content.
|
|
96
|
+
DFLT_MIN_QUOTE_CHARS = 8
|
|
97
|
+
|
|
98
|
+
_log = logging.getLogger(__name__)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _normalize_space(text: str) -> str:
|
|
102
|
+
return re.sub(r"\s+", " ", text).strip().casefold()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def apply_gates(
|
|
106
|
+
proposed: GradedLabel,
|
|
107
|
+
content: str,
|
|
108
|
+
*,
|
|
109
|
+
levels: Sequence[str] = DEFAULT_LEVELS,
|
|
110
|
+
unsupported_cap: str = "tangential",
|
|
111
|
+
require_evidence_quote: bool = True,
|
|
112
|
+
calibration_offset: float | None = None,
|
|
113
|
+
min_quote_chars: int = DFLT_MIN_QUOTE_CHARS,
|
|
114
|
+
) -> GradedLabel:
|
|
115
|
+
"""The level a judge's proposal is allowed, after calibration and the gates.
|
|
116
|
+
|
|
117
|
+
The quote must be at least ``min_quote_chars`` long and found in ``content``,
|
|
118
|
+
ignoring case and runs of whitespace (judges re-case and re-wrap what they
|
|
119
|
+
copy). ``calibration_offset`` is in levels, rounded half up, so a shift is
|
|
120
|
+
the same at every level.
|
|
121
|
+
|
|
122
|
+
>>> label = GradedLabel('core', True, False, True, 'a DAG of tasks')
|
|
123
|
+
>>> apply_gates(label, 'Runs a DAG of tasks asynchronously.').level
|
|
124
|
+
'tangential'
|
|
125
|
+
>>> ok = GradedLabel('core', True, True, True, 'graph library')
|
|
126
|
+
>>> apply_gates(ok, 'A graph library.').level, apply_gates(ok, 'Nothing here.').level
|
|
127
|
+
('core', 'tangential')
|
|
128
|
+
>>> apply_gates(ok, 'A graph library.', calibration_offset=1.4).level
|
|
129
|
+
'strong'
|
|
130
|
+
"""
|
|
131
|
+
cap = levels.index(unsupported_cap)
|
|
132
|
+
rank = levels.index(proposed.level)
|
|
133
|
+
if calibration_offset:
|
|
134
|
+
rank = min(max(math.floor(rank + calibration_offset + 0.5), 0), len(levels) - 1)
|
|
135
|
+
quote = _normalize_space(proposed.evidence_quote)
|
|
136
|
+
quoted = len(quote) >= min_quote_chars and quote in _normalize_space(content)
|
|
137
|
+
if rank < cap and (
|
|
138
|
+
not proposed.is_supported or (require_evidence_quote and not quoted)
|
|
139
|
+
):
|
|
140
|
+
rank = cap
|
|
141
|
+
return GradedLabel(
|
|
142
|
+
level=levels[rank],
|
|
143
|
+
is_about=proposed.is_about,
|
|
144
|
+
is_supported=proposed.is_supported,
|
|
145
|
+
is_useful=proposed.is_useful,
|
|
146
|
+
evidence_quote=proposed.evidence_quote if quoted else "",
|
|
147
|
+
facet=proposed.facet,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse_label(out: Mapping[str, Any] | str, levels: Sequence[str]) -> GradedLabel:
|
|
152
|
+
"""A judge's reply as a :class:`GradedLabel`; raises ``ValueError`` when unusable."""
|
|
153
|
+
if isinstance(out, str):
|
|
154
|
+
match = re.search(r"\{.*\}", out, flags=re.DOTALL)
|
|
155
|
+
if not match:
|
|
156
|
+
raise ValueError(f"no JSON object in the judge's reply: {out[:200]!r}")
|
|
157
|
+
out = json.loads(match.group(0))
|
|
158
|
+
by_name = {lv.casefold(): lv for lv in levels}
|
|
159
|
+
level = by_name.get(str(out.get("level", "")).strip().casefold())
|
|
160
|
+
if level is None:
|
|
161
|
+
raise ValueError(f"level {out.get('level')!r} is not one of {list(levels)}")
|
|
162
|
+
|
|
163
|
+
def flag(key: str) -> bool:
|
|
164
|
+
value = out.get(key)
|
|
165
|
+
if isinstance(value, str):
|
|
166
|
+
return value.strip().lower() in ("true", "yes")
|
|
167
|
+
return bool(value)
|
|
168
|
+
|
|
169
|
+
return GradedLabel(
|
|
170
|
+
level=level,
|
|
171
|
+
is_about=flag("is_about"),
|
|
172
|
+
is_supported=flag("is_supported"),
|
|
173
|
+
is_useful=flag("is_useful"),
|
|
174
|
+
evidence_quote=str(out.get("evidence_quote") or ""),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _contents(
|
|
179
|
+
hits: Sequence[Result], level: str, disclose_kwargs: Mapping[str, Any]
|
|
180
|
+
) -> list[str]:
|
|
181
|
+
"""Each hit's disclosed content at ``level``, its snippet when nothing loads."""
|
|
182
|
+
selection = ir.Selection(
|
|
183
|
+
selected=list(hits), candidates=list(hits), abstained=False, reason="graded"
|
|
184
|
+
)
|
|
185
|
+
disclosed = ir.disclose(selection, level=level, **dict(disclose_kwargs))
|
|
186
|
+
return [d.body or h.text for h, d in zip(hits, disclosed, strict=True)]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def make_graded_rubric_evaluator(
|
|
190
|
+
*,
|
|
191
|
+
levels: Sequence[str] = DEFAULT_LEVELS,
|
|
192
|
+
rubric: str = DEFAULT_TOPICAL_RUBRIC,
|
|
193
|
+
judge: GradedJudge | None = None,
|
|
194
|
+
disclose_level: str = "body",
|
|
195
|
+
disclose_kwargs: Mapping[str, Any] | None = None,
|
|
196
|
+
require_evidence_quote: bool = True,
|
|
197
|
+
unsupported_cap: str = "tangential",
|
|
198
|
+
calibration_offset: float | None = None,
|
|
199
|
+
min_quote_chars: int = DFLT_MIN_QUOTE_CHARS,
|
|
200
|
+
relevant_level: str = "uses-tools",
|
|
201
|
+
refine: Refine | None = None,
|
|
202
|
+
rng: random.Random | None = None,
|
|
203
|
+
prompt: str = GRADING_PROMPT,
|
|
204
|
+
max_content_chars: int = DFLT_MAX_CONTENT_CHARS,
|
|
205
|
+
**prompt_function_kwargs: Any,
|
|
206
|
+
) -> Evaluator:
|
|
207
|
+
"""An :class:`~raglab.agent.Evaluator` that grades every candidate on a rubric.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
levels: the ordered levels, best first.
|
|
211
|
+
rubric: what each level means, shown to the judge.
|
|
212
|
+
judge: an injectable graded judge (a test double, or your own router);
|
|
213
|
+
built lazily on :mod:`oa` when omitted.
|
|
214
|
+
disclose_level, disclose_kwargs: what the judge reads — each candidate's
|
|
215
|
+
content through :func:`ir.disclose` at that level (``store=`` /
|
|
216
|
+
``loader=`` go in ``disclose_kwargs``); a candidate with nothing to
|
|
217
|
+
load is judged on its snippet.
|
|
218
|
+
require_evidence_quote, unsupported_cap, calibration_offset,
|
|
219
|
+
min_quote_chars: the gates, see :func:`apply_gates`.
|
|
220
|
+
relevant_level: the lowest level kept in ``Judgement.relevant``.
|
|
221
|
+
refine: the back-edge policy ``(task, graded) -> SubTask | None``; by
|
|
222
|
+
default none, so the loop stops after one round.
|
|
223
|
+
rng: the source of the per-call level order (seed it for reproducible
|
|
224
|
+
prompts).
|
|
225
|
+
|
|
226
|
+
Every candidate is judged once per goal: one judge call per (source,
|
|
227
|
+
artifact) the first time it appears in a pool for that goal, its label
|
|
228
|
+
remembered for later rounds, so a kept hit is not re-judged (nor lost to a
|
|
229
|
+
later failing call). ``Judgement.graded`` is keyed by ``artifact_id``; an id
|
|
230
|
+
found in several sources carries its best label. A reply that does not parse
|
|
231
|
+
leaves the candidate ungraded (logged); when every judge call of a round
|
|
232
|
+
raises, the first error is re-raised rather than returning an empty,
|
|
233
|
+
"sufficient" round. This evaluator is for labelling a pool, not for cheap
|
|
234
|
+
filtering.
|
|
235
|
+
"""
|
|
236
|
+
levels = tuple(levels)
|
|
237
|
+
for name, value in (
|
|
238
|
+
("unsupported_cap", unsupported_cap),
|
|
239
|
+
("relevant_level", relevant_level),
|
|
240
|
+
):
|
|
241
|
+
if value not in levels:
|
|
242
|
+
raise ValueError(f"{name}={value!r} is not one of {list(levels)}")
|
|
243
|
+
keep = levels.index(relevant_level)
|
|
244
|
+
rng = rng if rng is not None else random.Random()
|
|
245
|
+
disclose_kwargs = dict(disclose_kwargs or {})
|
|
246
|
+
state: dict[str, GradedJudge] = {}
|
|
247
|
+
memo: dict[tuple[str, str | None, str], GradedLabel] = {}
|
|
248
|
+
|
|
249
|
+
def ask(**kwargs: Any) -> Mapping[str, Any] | str:
|
|
250
|
+
if judge is not None:
|
|
251
|
+
return judge(**kwargs)
|
|
252
|
+
if "judge" not in state:
|
|
253
|
+
state["judge"] = _default_graded_judge(prompt, **prompt_function_kwargs)
|
|
254
|
+
return state["judge"](**kwargs)
|
|
255
|
+
|
|
256
|
+
def evaluator(task: SubTask, results: Sequence[Result]) -> Judgement:
|
|
257
|
+
hits = best_per_artifact(results)
|
|
258
|
+
todo = [h for h in hits if (task.goal, h.source, h.artifact_id) not in memo]
|
|
259
|
+
contents = _contents(todo, disclose_level, disclose_kwargs) if todo else []
|
|
260
|
+
errors: list[Exception] = []
|
|
261
|
+
for hit, content in zip(todo, contents, strict=True):
|
|
262
|
+
content = str(content)[:max_content_chars]
|
|
263
|
+
shown = list(levels)
|
|
264
|
+
rng.shuffle(shown)
|
|
265
|
+
try:
|
|
266
|
+
reply = ask(
|
|
267
|
+
goal=task.goal,
|
|
268
|
+
artifact_id=hit.artifact_id,
|
|
269
|
+
content=content,
|
|
270
|
+
levels="\n".join(f"- {lv}" for lv in shown),
|
|
271
|
+
rubric=rubric,
|
|
272
|
+
)
|
|
273
|
+
except Exception as error: # the judge itself failed: auth, network, limits
|
|
274
|
+
_log.warning("judge failed on %s: %r", hit.artifact_id, error)
|
|
275
|
+
errors.append(error)
|
|
276
|
+
continue
|
|
277
|
+
try:
|
|
278
|
+
proposed = _parse_label(reply, levels)
|
|
279
|
+
except (ValueError, TypeError, AttributeError) as error:
|
|
280
|
+
_log.warning("unusable judge reply on %s: %s", hit.artifact_id, error)
|
|
281
|
+
continue # left ungraded: never guess a level
|
|
282
|
+
memo[(task.goal, hit.source, hit.artifact_id)] = apply_gates(
|
|
283
|
+
proposed,
|
|
284
|
+
content,
|
|
285
|
+
levels=levels,
|
|
286
|
+
unsupported_cap=unsupported_cap,
|
|
287
|
+
require_evidence_quote=require_evidence_quote,
|
|
288
|
+
calibration_offset=calibration_offset,
|
|
289
|
+
min_quote_chars=min_quote_chars,
|
|
290
|
+
)
|
|
291
|
+
if todo and len(errors) == len(todo):
|
|
292
|
+
raise errors[0]
|
|
293
|
+
labels = {
|
|
294
|
+
(h.source, h.artifact_id): memo[key]
|
|
295
|
+
for h in hits
|
|
296
|
+
if (key := (task.goal, h.source, h.artifact_id)) in memo
|
|
297
|
+
}
|
|
298
|
+
graded: dict[str, GradedLabel] = {}
|
|
299
|
+
for (_, aid), label in labels.items():
|
|
300
|
+
if aid not in graded or levels.index(label.level) < levels.index(
|
|
301
|
+
graded[aid].level
|
|
302
|
+
):
|
|
303
|
+
graded[aid] = label
|
|
304
|
+
relevant = [
|
|
305
|
+
h
|
|
306
|
+
for h in hits
|
|
307
|
+
if (label := labels.get((h.source, h.artifact_id))) is not None
|
|
308
|
+
and levels.index(label.level) <= keep
|
|
309
|
+
]
|
|
310
|
+
refinement = refine(task, graded) if refine is not None else None
|
|
311
|
+
return Judgement(
|
|
312
|
+
relevant=relevant,
|
|
313
|
+
sufficient=refinement is None,
|
|
314
|
+
refinement=refinement,
|
|
315
|
+
graded=graded,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
return evaluator
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _default_graded_judge(prompt: str, **prompt_function_kwargs: Any) -> GradedJudge:
|
|
322
|
+
"""Build the default graded judge on :mod:`oa` (lazy import)."""
|
|
323
|
+
import oa
|
|
324
|
+
|
|
325
|
+
return oa.prompt_function(prompt, name="grade_candidate", **prompt_function_kwargs)
|
|
@@ -286,3 +286,144 @@ def test_score_reranker_remains_the_homogeneous_opt_in():
|
|
|
286
286
|
pytest.importorskip("ir")
|
|
287
287
|
pool = _hits(("a", 0.3), source="s1") + _hits(("b", 0.9), source="s2")
|
|
288
288
|
assert [h.artifact_id for h in raglab.score_reranker(pool)] == ["b", "a"]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ----- the budget cap: rank-fair, scale-safe (raglab#5) ---------------------- #
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _capturing_evaluator(seen):
|
|
295
|
+
def evaluator(task, results):
|
|
296
|
+
seen.append(list(results))
|
|
297
|
+
return Judgement(list(results), sufficient=True)
|
|
298
|
+
|
|
299
|
+
return evaluator
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_small_cap_lets_both_disparate_sources_top_hits_through():
|
|
303
|
+
# source "big" arrives first with many BM25-scale hits; "cos" arrives last with
|
|
304
|
+
# cosine-scale hits. Arrival-order truncation would never show "cos" at all.
|
|
305
|
+
big = _hits(*[(f"b{i}", 40.0 - i) for i in range(10)], source="big")
|
|
306
|
+
cos = _hits(("c_best", 0.95), ("c2", 0.4), source="cos")
|
|
307
|
+
seen = []
|
|
308
|
+
make_search_agent(
|
|
309
|
+
{"big": _fake_retriever(big), "cos": _fake_retriever(cos)},
|
|
310
|
+
evaluator=_capturing_evaluator(seen),
|
|
311
|
+
budget=raglab.Budget(max_results_per_task=4),
|
|
312
|
+
)("q")
|
|
313
|
+
pool = seen[0]
|
|
314
|
+
assert len(pool) == 4
|
|
315
|
+
ids = [h.artifact_id for h in pool]
|
|
316
|
+
assert ids[:2] == ["b0", "c_best"] # each source's best, dealt round-robin
|
|
317
|
+
assert set(ids) == {"b0", "b1", "c_best", "c2"}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_capped_pool_keeps_raw_per_source_magnitudes():
|
|
321
|
+
big = _hits(*[(f"b{i}", 40.0 - i) for i in range(10)], source="big")
|
|
322
|
+
cos = _hits(("c_best", 0.95), ("c2", 0.4), source="cos")
|
|
323
|
+
originals = {(h.source, h.artifact_id): h for h in big + cos}
|
|
324
|
+
seen = []
|
|
325
|
+
make_search_agent(
|
|
326
|
+
{"big": _fake_retriever(big), "cos": _fake_retriever(cos)},
|
|
327
|
+
evaluator=_capturing_evaluator(seen),
|
|
328
|
+
budget=raglab.Budget(max_results_per_task=3),
|
|
329
|
+
)("q")
|
|
330
|
+
for hit in seen[0]:
|
|
331
|
+
assert hit == originals[(hit.source, hit.artifact_id)] # untouched
|
|
332
|
+
assert "source_rank" not in hit.metadata
|
|
333
|
+
assert "source_score" not in hit.metadata # no fused-score stamp either
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_rank_fair_cap_orders_within_source_by_its_own_score():
|
|
337
|
+
# one source, scores out of arrival order (e.g. two formulator queries)
|
|
338
|
+
pool = _hits(("lo", 0.1), ("hi", 0.9), ("mid", 0.5), source="s")
|
|
339
|
+
assert [h.artifact_id for h in raglab.rank_fair_cap(pool, 2)] == ["hi", "mid"]
|
|
340
|
+
# untagged hits are one group of their own
|
|
341
|
+
mixed = _hits(("u1", 3.0), ("u2", 2.0)) + _hits(("t1", 0.5), source="t")
|
|
342
|
+
assert [h.artifact_id for h in raglab.rank_fair_cap(mixed, 2)] == ["u1", "t1"]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_rank_fair_cap_within_limit_and_degenerate_limits():
|
|
346
|
+
pool = _hits(("a", 0.1), ("b", 0.9), source="s")
|
|
347
|
+
# within budget nothing is cut, and the order is the same dealt order as above it
|
|
348
|
+
assert [h.artifact_id for h in raglab.rank_fair_cap(pool, 5)] == ["b", "a"]
|
|
349
|
+
assert raglab.rank_fair_cap(pool, 0) == []
|
|
350
|
+
assert raglab.rank_fair_cap(pool, -1) == []
|
|
351
|
+
uneven = _hits(*[(f"x{i}", 1.0 - i / 10) for i in range(5)], source="x") + _hits(
|
|
352
|
+
("y0", 9.0), source="y"
|
|
353
|
+
)
|
|
354
|
+
# a short source runs dry; the long one fills the rest
|
|
355
|
+
assert [h.artifact_id for h in raglab.rank_fair_cap(uneven, 4)] == [
|
|
356
|
+
"x0",
|
|
357
|
+
"y0",
|
|
358
|
+
"x1",
|
|
359
|
+
"x2",
|
|
360
|
+
]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_pool_cap_is_injectable_and_arrival_order_is_the_opt_out():
|
|
364
|
+
big = _hits(*[(f"b{i}", 40.0 - i) for i in range(10)], source="big")
|
|
365
|
+
cos = _hits(("c_best", 0.95), source="cos")
|
|
366
|
+
seen = []
|
|
367
|
+
make_search_agent(
|
|
368
|
+
{"big": _fake_retriever(big), "cos": _fake_retriever(cos)},
|
|
369
|
+
evaluator=_capturing_evaluator(seen),
|
|
370
|
+
budget=raglab.Budget(max_results_per_task=3),
|
|
371
|
+
pool_cap=raglab.arrival_order_cap,
|
|
372
|
+
)("q")
|
|
373
|
+
assert [h.artifact_id for h in seen[0]] == ["b0", "b1", "b2"]
|
|
374
|
+
assert isinstance(raglab.rank_fair_cap, raglab.PoolCap)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def test_cap_collapses_duplicates_and_tolerates_missing_scores():
|
|
378
|
+
dup = _hits(("a", 0.9), ("a", 0.5), ("b", 0.8), ("c", 0.7), source="s")
|
|
379
|
+
assert [h.artifact_id for h in raglab.rank_fair_cap(dup, 3)] == ["a", "b", "c"]
|
|
380
|
+
assert raglab.rank_fair_cap(dup, 3)[0].score == 0.9 # the best surface survives
|
|
381
|
+
odd = _hits(("nan", float("nan")), ("none", None), ("ok", 0.1), source="s")
|
|
382
|
+
assert raglab.rank_fair_cap(odd, 1)[0].artifact_id == "ok"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _refine_once(seen, keep):
|
|
386
|
+
"""An evaluator that keeps only `keep` in round one and asks for a re-query."""
|
|
387
|
+
|
|
388
|
+
def evaluator(task, results):
|
|
389
|
+
seen.append(list(results))
|
|
390
|
+
if len(seen) == 1:
|
|
391
|
+
kept = [h for h in results if h.artifact_id in keep]
|
|
392
|
+
return Judgement(
|
|
393
|
+
kept, sufficient=False, refinement=SubTask("again", task.sources)
|
|
394
|
+
)
|
|
395
|
+
return Judgement(list(results), sufficient=True)
|
|
396
|
+
|
|
397
|
+
return evaluator
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _rounds(*per_round):
|
|
401
|
+
"""A retriever returning a different canned hit list on each call."""
|
|
402
|
+
calls = iter(per_round)
|
|
403
|
+
return lambda query, **kw: list(next(calls))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def test_back_edge_never_evicts_a_hit_the_evaluator_kept():
|
|
407
|
+
round1 = _hits(
|
|
408
|
+
("weak_but_relevant", 0.1), ("junk1", 0.9), ("junk2", 0.8), source="s"
|
|
409
|
+
)
|
|
410
|
+
round2 = _hits(("new1", 0.95), ("new2", 0.85), ("new3", 0.7), source="s")
|
|
411
|
+
seen = []
|
|
412
|
+
out = make_search_agent(
|
|
413
|
+
{"s": _rounds(round1, round2)},
|
|
414
|
+
evaluator=_refine_once(seen, {"weak_but_relevant"}),
|
|
415
|
+
budget=raglab.Budget(max_results_per_task=3),
|
|
416
|
+
)("q")
|
|
417
|
+
assert [h.artifact_id for h in seen[1]] == ["weak_but_relevant", "new1", "new2"]
|
|
418
|
+
assert "weak_but_relevant" in {h.artifact_id for h in out}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def test_back_edge_does_not_spend_slots_on_re_retrieved_hits():
|
|
422
|
+
same = _hits(("a", 0.9), ("b", 0.8), ("c", 0.7), source="s")
|
|
423
|
+
seen = []
|
|
424
|
+
make_search_agent(
|
|
425
|
+
{"s": _rounds(same, same)},
|
|
426
|
+
evaluator=_refine_once(seen, {"a"}),
|
|
427
|
+
budget=raglab.Budget(max_results_per_task=3),
|
|
428
|
+
)("q")
|
|
429
|
+
assert [h.artifact_id for h in seen[1]] == ["a", "b", "c"] # no second "a"
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Tests for the graded rubric evaluator (raglab#8).
|
|
2
|
+
|
|
3
|
+
Hermetic: the judge is a scripted test double keyed by artifact id; no model,
|
|
4
|
+
no network. Content comes from each hit's snippet (nothing to load from disk).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import random
|
|
9
|
+
|
|
10
|
+
from ir import SearchHit
|
|
11
|
+
|
|
12
|
+
from raglab import (
|
|
13
|
+
DEFAULT_LEVELS,
|
|
14
|
+
Budget,
|
|
15
|
+
GradedLabel,
|
|
16
|
+
Judgement,
|
|
17
|
+
SubTask,
|
|
18
|
+
make_graded_rubric_evaluator,
|
|
19
|
+
make_search_agent,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
TASK = SubTask(goal="graph library", sources=("pkgs",))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _hit(aid, text, score=1.0):
|
|
26
|
+
return SearchHit(aid, "k", score, text, {})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _judge(replies):
|
|
30
|
+
"""A judge returning ``replies[artifact_id]`` and recording what it was shown."""
|
|
31
|
+
calls = []
|
|
32
|
+
|
|
33
|
+
def judge(**kw):
|
|
34
|
+
calls.append(kw)
|
|
35
|
+
reply = replies[kw["artifact_id"]]
|
|
36
|
+
if isinstance(reply, Exception):
|
|
37
|
+
raise reply
|
|
38
|
+
return reply
|
|
39
|
+
|
|
40
|
+
judge.calls = calls
|
|
41
|
+
return judge
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _reply(level, *, about=True, supported=True, useful=True, quote=""):
|
|
45
|
+
return {
|
|
46
|
+
"level": level,
|
|
47
|
+
"is_about": about,
|
|
48
|
+
"is_supported": supported,
|
|
49
|
+
"is_useful": useful,
|
|
50
|
+
"evidence_quote": quote,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
POOL = [
|
|
55
|
+
_hit("graphy", "A graph library: nodes, edges, traversals."),
|
|
56
|
+
_hit("au", "An async task framework; jobs form a DAG."),
|
|
57
|
+
_hit("plotter", "Draws charts with networkx graph layouts."),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_every_candidate_is_graded_and_the_supported_gate_caps_on_word_distractors():
|
|
62
|
+
judge = _judge(
|
|
63
|
+
{
|
|
64
|
+
"graphy": _reply("core", quote="A graph library"),
|
|
65
|
+
# on-word but not supported: the judge over-grades, the gate caps it
|
|
66
|
+
"au": _reply("strong", supported=False, quote="jobs form a DAG"),
|
|
67
|
+
"plotter": _reply("uses-tools", quote="networkx graph layouts"),
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
judged = make_graded_rubric_evaluator(judge=judge)(TASK, POOL)
|
|
71
|
+
assert isinstance(judged, Judgement)
|
|
72
|
+
assert {aid: g.level for aid, g in judged.graded.items()} == {
|
|
73
|
+
"graphy": "core",
|
|
74
|
+
"au": "tangential",
|
|
75
|
+
"plotter": "uses-tools",
|
|
76
|
+
}
|
|
77
|
+
assert [h.artifact_id for h in judged.relevant] == ["graphy", "plotter"]
|
|
78
|
+
assert judged.sufficient and judged.refinement is None
|
|
79
|
+
assert len(judge.calls) == 3
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_a_quote_not_found_verbatim_caps_the_level_unless_quotes_are_optional():
|
|
83
|
+
judge = _judge({"graphy": _reply("core", quote="the best graph toolkit")})
|
|
84
|
+
strict = make_graded_rubric_evaluator(judge=judge)(TASK, POOL[:1])
|
|
85
|
+
assert strict.graded["graphy"].level == "tangential"
|
|
86
|
+
assert strict.graded["graphy"].evidence_quote == ""
|
|
87
|
+
lax = make_graded_rubric_evaluator(judge=judge, require_evidence_quote=False)(
|
|
88
|
+
TASK, POOL[:1]
|
|
89
|
+
)
|
|
90
|
+
assert lax.graded["graphy"].level == "core"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_the_calibration_offset_demotes_an_over_grading_judge():
|
|
94
|
+
judge = _judge({"graphy": _reply("core", quote="graph library")})
|
|
95
|
+
ev = make_graded_rubric_evaluator(judge=judge, calibration_offset=1.0)
|
|
96
|
+
assert ev(TASK, POOL[:1]).graded["graphy"].level == "strong"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_a_failing_or_unparseable_judge_leaves_the_candidate_ungraded():
|
|
100
|
+
judge = _judge(
|
|
101
|
+
{
|
|
102
|
+
"graphy": json.dumps(_reply("core", quote="graph library")), # raw text ok
|
|
103
|
+
"au": RuntimeError("rate limited"),
|
|
104
|
+
"plotter": '{"level": "excellent"}', # not a level
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
judged = make_graded_rubric_evaluator(judge=judge)(TASK, POOL)
|
|
108
|
+
assert set(judged.graded) == {"graphy"}
|
|
109
|
+
assert [h.artifact_id for h in judged.relevant] == ["graphy"]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_level_options_are_shown_in_a_fresh_order_each_call():
|
|
113
|
+
judge = _judge({h.artifact_id: _reply("none") for h in POOL})
|
|
114
|
+
ev = make_graded_rubric_evaluator(judge=judge, rng=random.Random(0))
|
|
115
|
+
for _ in range(4):
|
|
116
|
+
ev(TASK, POOL)
|
|
117
|
+
orders = {c["levels"] for c in judge.calls}
|
|
118
|
+
assert len(orders) > 1
|
|
119
|
+
assert all(sorted(o.split("\n")) == sorted(f"- {lv}" for lv in DEFAULT_LEVELS) for o in orders)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_the_refine_policy_drives_the_back_edge_through_the_agent():
|
|
123
|
+
corpus = {
|
|
124
|
+
"graph library": [_hit("au", "An async task framework; jobs form a DAG.")],
|
|
125
|
+
"graph library nodes edges": [_hit("graphy", "A graph library: nodes, edges.")],
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
def retriever(query, **kw):
|
|
129
|
+
return list(corpus.get(query, []))
|
|
130
|
+
|
|
131
|
+
judge = _judge(
|
|
132
|
+
{
|
|
133
|
+
"au": _reply("core", supported=False, quote="jobs form a DAG"),
|
|
134
|
+
"graphy": _reply("core", quote="A graph library"),
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def refine(task, graded):
|
|
139
|
+
if any(g.level in ("core", "strong") for g in graded.values()):
|
|
140
|
+
return None
|
|
141
|
+
return SubTask(goal=task.goal + " nodes edges", sources=task.sources)
|
|
142
|
+
|
|
143
|
+
agent = make_search_agent(
|
|
144
|
+
{"pkgs": retriever},
|
|
145
|
+
evaluator=make_graded_rubric_evaluator(judge=judge, refine=refine),
|
|
146
|
+
budget=Budget(max_rounds=3),
|
|
147
|
+
)
|
|
148
|
+
assert [h.artifact_id for h in agent("graph library")] == ["graphy"]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_binary_judgements_are_unchanged():
|
|
152
|
+
assert Judgement(relevant=[], sufficient=True).graded == {}
|
|
153
|
+
label = GradedLabel("core", True, True, True)
|
|
154
|
+
assert label.evidence_quote == "" and label.facet is None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_unknown_level_names_are_refused():
|
|
158
|
+
import pytest
|
|
159
|
+
|
|
160
|
+
with pytest.raises(ValueError, match="relevant_level"):
|
|
161
|
+
make_graded_rubric_evaluator(judge=_judge({}), relevant_level="great")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_the_same_id_in_two_sources_is_judged_per_source():
|
|
165
|
+
a = SearchHit("x", "k", 1.0, "A graph library for nodes.", {}, source="A")
|
|
166
|
+
b = SearchHit("x", "k", 1.0, "An audio decoder.", {}, source="B")
|
|
167
|
+
|
|
168
|
+
def judge(**kw):
|
|
169
|
+
if "graph" in kw["content"]:
|
|
170
|
+
return _reply("core", quote="A graph library")
|
|
171
|
+
return _reply("none")
|
|
172
|
+
|
|
173
|
+
judged = make_graded_rubric_evaluator(judge=judge)(TASK, [a, b])
|
|
174
|
+
assert [h.source for h in judged.relevant] == ["A"]
|
|
175
|
+
assert judged.graded["x"].level == "core" # the best label across sources
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_custom_levels_match_whatever_their_case():
|
|
179
|
+
judge = _judge({"graphy": _reply("core", quote="A graph library")})
|
|
180
|
+
ev = make_graded_rubric_evaluator(
|
|
181
|
+
judge=judge, levels=("Core", "Tangential", "None"),
|
|
182
|
+
unsupported_cap="Tangential", relevant_level="Core",
|
|
183
|
+
)
|
|
184
|
+
assert ev(TASK, POOL[:1]).graded["graphy"].level == "Core"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_a_judge_that_fails_on_every_candidate_raises():
|
|
188
|
+
import pytest
|
|
189
|
+
|
|
190
|
+
judge = _judge({h.artifact_id: RuntimeError("no key") for h in POOL})
|
|
191
|
+
with pytest.raises(RuntimeError, match="no key"):
|
|
192
|
+
make_graded_rubric_evaluator(judge=judge)(TASK, POOL)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_a_candidate_is_judged_once_per_goal_and_its_label_kept():
|
|
196
|
+
judge = _judge({"graphy": _reply("core", quote="A graph library")})
|
|
197
|
+
ev = make_graded_rubric_evaluator(judge=judge)
|
|
198
|
+
ev(TASK, POOL[:1])
|
|
199
|
+
judge_down = ev(TASK, POOL[:1]) # no new call: the label is remembered
|
|
200
|
+
assert len(judge.calls) == 1
|
|
201
|
+
assert [h.artifact_id for h in judge_down.relevant] == ["graphy"]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_a_half_level_offset_shifts_every_level_alike():
|
|
205
|
+
from raglab import apply_gates
|
|
206
|
+
|
|
207
|
+
content = "A graph library."
|
|
208
|
+
shifted = [
|
|
209
|
+
apply_gates(GradedLabel(lv, True, True, True, "graph library"), content,
|
|
210
|
+
calibration_offset=0.5).level
|
|
211
|
+
for lv in DEFAULT_LEVELS[:-1]
|
|
212
|
+
]
|
|
213
|
+
assert shifted == list(DEFAULT_LEVELS[1:])
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_a_one_word_quote_is_not_evidence():
|
|
217
|
+
from raglab import apply_gates
|
|
218
|
+
|
|
219
|
+
label = GradedLabel("core", True, True, True, "a")
|
|
220
|
+
assert apply_gates(label, "A graph library.").level == "tangential"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|