raglab 0.2.6__tar.gz → 0.2.7__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.7}/.claude/CLAUDE.md +4 -1
- {raglab-0.2.6 → raglab-0.2.7}/PKG-INFO +15 -10
- {raglab-0.2.6 → raglab-0.2.7}/README.md +13 -8
- {raglab-0.2.6 → raglab-0.2.7}/pyproject.toml +1 -1
- {raglab-0.2.6 → raglab-0.2.7}/raglab/__init__.py +6 -0
- {raglab-0.2.6 → raglab-0.2.7}/raglab/agent.py +84 -4
- {raglab-0.2.6 → raglab-0.2.7}/tests/test_fanin.py +141 -0
- {raglab-0.2.6 → raglab-0.2.7}/.editorconfig +0 -0
- {raglab-0.2.6 → raglab-0.2.7}/.gitattributes +0 -0
- {raglab-0.2.6 → raglab-0.2.7}/.github/workflows/ci.yml +0 -0
- {raglab-0.2.6 → raglab-0.2.7}/.gitignore +0 -0
- {raglab-0.2.6 → raglab-0.2.7}/CHANGELOG.md +0 -0
- {raglab-0.2.6 → raglab-0.2.7}/LICENSE +0 -0
- {raglab-0.2.6 → raglab-0.2.7}/raglab/llm.py +0 -0
- {raglab-0.2.6 → raglab-0.2.7}/tests/test_agent.py +0 -0
- {raglab-0.2.6 → raglab-0.2.7}/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.7
|
|
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
|
|
|
@@ -33,18 +33,21 @@ from .agent import (
|
|
|
33
33
|
Judgement,
|
|
34
34
|
LowLevelQuery,
|
|
35
35
|
Planner,
|
|
36
|
+
PoolCap,
|
|
36
37
|
Query,
|
|
37
38
|
Reranker,
|
|
38
39
|
Result,
|
|
39
40
|
Retriever,
|
|
40
41
|
SingleContextAgent,
|
|
41
42
|
SubTask,
|
|
43
|
+
arrival_order_cap,
|
|
42
44
|
identity_citer,
|
|
43
45
|
identity_formulator,
|
|
44
46
|
ir_sources,
|
|
45
47
|
make_rrf_reranker,
|
|
46
48
|
make_search_agent,
|
|
47
49
|
passthrough_evaluator,
|
|
50
|
+
rank_fair_cap,
|
|
48
51
|
rrf_reranker,
|
|
49
52
|
score_reranker,
|
|
50
53
|
single_subtask_planner,
|
|
@@ -64,6 +67,9 @@ __all__ = [
|
|
|
64
67
|
"Reranker",
|
|
65
68
|
"Citer",
|
|
66
69
|
"Budget",
|
|
70
|
+
"PoolCap",
|
|
71
|
+
"rank_fair_cap",
|
|
72
|
+
"arrival_order_cap",
|
|
67
73
|
"SingleContextAgent",
|
|
68
74
|
"make_search_agent",
|
|
69
75
|
"ir_sources",
|
|
@@ -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
|
|
@@ -59,6 +60,9 @@ __all__ = [
|
|
|
59
60
|
"Reranker",
|
|
60
61
|
"Citer",
|
|
61
62
|
"Budget",
|
|
63
|
+
"PoolCap",
|
|
64
|
+
"rank_fair_cap",
|
|
65
|
+
"arrival_order_cap",
|
|
62
66
|
"SingleContextAgent",
|
|
63
67
|
"make_search_agent",
|
|
64
68
|
"ir_sources",
|
|
@@ -178,6 +182,70 @@ class Budget:
|
|
|
178
182
|
max_results_per_task: int = 50
|
|
179
183
|
|
|
180
184
|
|
|
185
|
+
@runtime_checkable
|
|
186
|
+
class PoolCap(Protocol):
|
|
187
|
+
"""Bound the pool the evaluator sees to at most ``limit`` results.
|
|
188
|
+
|
|
189
|
+
A governor decision (which hits survive the budget), injected like every other
|
|
190
|
+
role so a strategy can be swapped without touching the loop. It must return
|
|
191
|
+
hits **unchanged** — never re-scored — because the pool keeps raw per-source
|
|
192
|
+
magnitudes until the fan-in reranker.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __call__(self, results: Sequence[Result], limit: int) -> list[Result]: ...
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _rank_key(hit: Result) -> float:
|
|
199
|
+
"""A hit's raw score for ordering *within its source*; a missing or NaN score is last."""
|
|
200
|
+
score = hit.score
|
|
201
|
+
if isinstance(score, (int, float)) and not math.isnan(score):
|
|
202
|
+
return float(score)
|
|
203
|
+
return -math.inf
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def rank_fair_cap(results: Sequence[Result], limit: int) -> list[Result]:
|
|
207
|
+
"""Keep at most ``limit`` hits, taking the best of each source in turn (the default).
|
|
208
|
+
|
|
209
|
+
Groups the pool by ``hit.source`` (in first-arrival order; untagged hits are one
|
|
210
|
+
group), collapses each group to one hit per artifact (its best surface, as
|
|
211
|
+
:func:`ir.base.best_per_artifact` does), orders the group best-first by its own raw
|
|
212
|
+
score -- sound, since one source is one score scale -- and deals hits round-robin
|
|
213
|
+
across the groups. So a late source's strong hits are never cut before the evaluator
|
|
214
|
+
sees them, a duplicate never takes a slot, and no score ever crosses a source
|
|
215
|
+
boundary or is rewritten (unlike pre-ranking with the RRF reranker, whose fused
|
|
216
|
+
ordinal scores must never re-enter the pool). The output is in that dealt order
|
|
217
|
+
whatever the pool's size. A missing or NaN score sorts last in its source.
|
|
218
|
+
"""
|
|
219
|
+
if limit <= 0:
|
|
220
|
+
return []
|
|
221
|
+
groups: dict[str | None, dict[str, Result]] = {}
|
|
222
|
+
for hit in results:
|
|
223
|
+
best = groups.setdefault(hit.source, {})
|
|
224
|
+
cur = best.get(hit.artifact_id)
|
|
225
|
+
if cur is None or _rank_key(hit) > _rank_key(cur):
|
|
226
|
+
best[hit.artifact_id] = hit
|
|
227
|
+
queues = [
|
|
228
|
+
sorted(best.values(), key=_rank_key, reverse=True) for best in groups.values()
|
|
229
|
+
]
|
|
230
|
+
capped: list[Result] = []
|
|
231
|
+
for depth in range(max((len(q) for q in queues), default=0)):
|
|
232
|
+
for queue in queues:
|
|
233
|
+
if depth < len(queue):
|
|
234
|
+
capped.append(queue[depth])
|
|
235
|
+
if len(capped) == limit:
|
|
236
|
+
return capped
|
|
237
|
+
return capped
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def arrival_order_cap(results: Sequence[Result], limit: int) -> list[Result]:
|
|
241
|
+
"""Keep the first ``limit`` hits in arrival order (source order x query order).
|
|
242
|
+
|
|
243
|
+
The pre-governor behaviour: a late source's strong hits can be cut before the
|
|
244
|
+
evaluator sees them. Kept as an explicit opt-in (``pool_cap=arrival_order_cap``).
|
|
245
|
+
"""
|
|
246
|
+
return list(results[: max(0, limit)])
|
|
247
|
+
|
|
248
|
+
|
|
181
249
|
# --------------------------------------------------------------------------- #
|
|
182
250
|
# Default role implementations (the no-LLM thin slice) — ir_09 §8 step 2
|
|
183
251
|
# --------------------------------------------------------------------------- #
|
|
@@ -306,6 +374,7 @@ class SingleContextAgent:
|
|
|
306
374
|
reranker: Reranker = rrf_reranker
|
|
307
375
|
citer: Citer = identity_citer
|
|
308
376
|
budget: Budget = field(default_factory=Budget)
|
|
377
|
+
pool_cap: PoolCap = rank_fair_cap
|
|
309
378
|
|
|
310
379
|
def __call__(self, query: str | Query) -> list[Result]:
|
|
311
380
|
"""Run the agent for *query*; returns the final ranked, cited results."""
|
|
@@ -317,9 +386,15 @@ class SingleContextAgent:
|
|
|
317
386
|
return list(self.citer(ranked))
|
|
318
387
|
|
|
319
388
|
def _run_task(self, task: SubTask) -> list[Result]:
|
|
320
|
-
|
|
389
|
+
# `kept` is what the evaluator judged relevant last round: governor-approved,
|
|
390
|
+
# carried into the next round untouched. Only a round's *new* hits compete for
|
|
391
|
+
# the rest of the budget (`pool_cap`), so a re-query can never evict a hit the
|
|
392
|
+
# evaluator already kept, nor spend a slot re-offering it.
|
|
393
|
+
kept: list[Result] = []
|
|
321
394
|
current = task
|
|
395
|
+
limit = self.budget.max_results_per_task
|
|
322
396
|
for _ in range(max(1, self.budget.max_rounds)):
|
|
397
|
+
found: list[Result] = []
|
|
323
398
|
for source in current.sources[: self.budget.max_sources_per_task]:
|
|
324
399
|
retriever = self.sources.get(source)
|
|
325
400
|
if retriever is None:
|
|
@@ -333,12 +408,15 @@ class SingleContextAgent:
|
|
|
333
408
|
found.extend(
|
|
334
409
|
tag_source(retriever(llq.query, **dict(llq.params)), source)
|
|
335
410
|
)
|
|
336
|
-
|
|
337
|
-
|
|
411
|
+
seen = {(h.source, h.artifact_id) for h in kept}
|
|
412
|
+
fresh = [h for h in found if (h.source, h.artifact_id) not in seen]
|
|
413
|
+
pool = kept + self.pool_cap(fresh, max(0, limit - len(kept)))
|
|
414
|
+
judged = self.evaluator(current, pool)
|
|
415
|
+
kept = list(judged.relevant)
|
|
338
416
|
if judged.sufficient or judged.refinement is None:
|
|
339
417
|
break
|
|
340
418
|
current = judged.refinement # the back-edge: re-query
|
|
341
|
-
return
|
|
419
|
+
return kept
|
|
342
420
|
|
|
343
421
|
|
|
344
422
|
def make_search_agent(
|
|
@@ -350,6 +428,7 @@ def make_search_agent(
|
|
|
350
428
|
reranker: Reranker | None = None,
|
|
351
429
|
citer: Citer | None = None,
|
|
352
430
|
budget: Budget | None = None,
|
|
431
|
+
pool_cap: PoolCap | None = None,
|
|
353
432
|
) -> SingleContextAgent:
|
|
354
433
|
"""Build a :class:`SingleContextAgent` over *sources* with smart defaults.
|
|
355
434
|
|
|
@@ -372,6 +451,7 @@ def make_search_agent(
|
|
|
372
451
|
reranker=reranker or rrf_reranker,
|
|
373
452
|
citer=citer or identity_citer,
|
|
374
453
|
budget=budget or Budget(),
|
|
454
|
+
pool_cap=pool_cap or rank_fair_cap,
|
|
375
455
|
)
|
|
376
456
|
|
|
377
457
|
|
|
@@ -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"
|
|
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
|