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.
@@ -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.4
1
+ Metadata-Version: 2.5
2
2
  Name: raglab
3
- Version: 0.2.6
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") # -> list[ir.SearchHit], ranked
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": "embed text with a model and cache the vectors",
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", # numpy-only, no model download
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 # -> "deploy"
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(), # rewrite/expand the query (HyDE)
151
- evaluator=raglab.make_llm_evaluator(), # sufficiency + the back-edge
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 [SubTask(goal=g, sources=tuple(sources))
216
- for g in (query.text, query.text + " examples")]
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") # -> list[ir.SearchHit], ranked
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": "embed text with a model and cache the vectors",
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", # numpy-only, no model download
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 # -> "deploy"
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(), # rewrite/expand the query (HyDE)
117
- evaluator=raglab.make_llm_evaluator(), # sufficiency + the back-edge
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 [SubTask(goal=g, sources=tuple(sources))
182
- for g in (query.text, query.text + " examples")]
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
 
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "raglab"
9
- version = "0.2.6"
9
+ version = "0.2.7"
10
10
  description = "A medley of tools to make RAG-based applications."
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -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
- found: list[Result] = []
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
- judged = self.evaluator(current, found[: self.budget.max_results_per_task])
337
- found = list(judged.relevant)
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 found
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