raglab 0.2.8__tar.gz → 0.2.9__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: raglab
3
- Version: 0.2.8
3
+ Version: 0.2.9
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
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "raglab"
9
- version = "0.2.8"
9
+ version = "0.2.9"
10
10
  description = "A medley of tools to make RAG-based applications."
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -0,0 +1,5 @@
1
+ """Recipes: whole workflows composed from raglab's roles, ready to call."""
2
+
3
+ from .corpus_labeling import CorpusLabeling, FacetCoverage, label_corpus
4
+
5
+ __all__ = ["CorpusLabeling", "FacetCoverage", "label_corpus"]
@@ -0,0 +1,219 @@
1
+ """Label a corpus against a topic, facet by facet, with a graded judge (raglab#8, part B).
2
+
3
+ :func:`label_corpus` composes existing pieces and adds no orchestrator:
4
+
5
+ - a planner that emits one :class:`~raglab.agent.SubTask` per **facet** of the
6
+ topic, for broad recall;
7
+ - a formulator (identity by default; :func:`raglab.make_llm_formulator` for
8
+ query expansion);
9
+ - the graded rubric evaluator (:func:`raglab.make_graded_rubric_evaluator`),
10
+ which judges **every** retrieved candidate on its disclosed content;
11
+ - a **coverage** check per facet (enough candidates at ``coverage_level``),
12
+ which, when a facet looks thin and a ``reformulate`` policy is given, emits a
13
+ refinement sub-task: the back-edge of :class:`~raglab.agent.SingleContextAgent`.
14
+
15
+ The deliverable is a frozen :class:`CorpusLabeling`: each judged artifact's best
16
+ :class:`~raglab.agent.GradedLabel` (its ``facet`` set) and each facet's coverage.
17
+ Nothing here touches ``ir``; the corpus is read through a retriever
18
+ (:func:`ir.as_retriever` by default).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Callable, Iterable, Mapping
24
+ from dataclasses import dataclass, field, replace
25
+ from typing import Any
26
+
27
+ import ir
28
+
29
+ from ..agent import (
30
+ Budget,
31
+ Formulator,
32
+ GradedLabel,
33
+ Judgement,
34
+ Query,
35
+ Result,
36
+ Retriever,
37
+ SingleContextAgent,
38
+ SubTask,
39
+ identity_formulator,
40
+ )
41
+ from ..judging import DEFAULT_LEVELS, make_graded_rubric_evaluator
42
+
43
+ __all__ = ["CorpusLabeling", "FacetCoverage", "label_corpus"]
44
+
45
+ #: A gap-driven reformulation: ``(facet goal, labels so far) -> a new query, or None``.
46
+ Reformulate = Callable[[str, Mapping[str, GradedLabel]], "str | None"]
47
+
48
+ #: The registry key the corpus's retriever is registered under.
49
+ SOURCE = "corpus"
50
+
51
+ #: Candidates retrieved per query by the default retriever: labelling wants recall.
52
+ DFLT_K = 50
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class FacetCoverage:
57
+ """How well one facet was covered: candidates judged, those at the coverage level, rounds run."""
58
+
59
+ facet: str
60
+ judged: int
61
+ covered: int
62
+ rounds: int
63
+ sufficient: bool
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class CorpusLabeling:
68
+ """The graded labels of a corpus for one topic, and each facet's coverage."""
69
+
70
+ labels: Mapping[str, GradedLabel] = field(default_factory=dict)
71
+ coverage: Mapping[str, FacetCoverage] = field(default_factory=dict)
72
+ levels: tuple[str, ...] = DEFAULT_LEVELS
73
+
74
+ def at_least(self, level: str) -> list[str]:
75
+ """The artifact ids labelled ``level`` or better, on the levels they were labelled on."""
76
+ cut = self.levels.index(level)
77
+ return [
78
+ aid for aid, g in self.labels.items() if self.levels.index(g.level) <= cut
79
+ ]
80
+
81
+
82
+ def _facet_goals(facets: Mapping[str, str] | Iterable[str]) -> dict[str, str]:
83
+ goals = dict(facets) if isinstance(facets, Mapping) else {f: f for f in facets}
84
+ if not goals:
85
+ raise ValueError("label_corpus needs at least one facet")
86
+ if len(set(goals.values())) < len(goals):
87
+ raise ValueError(
88
+ f"two facets share a goal: {goals}; give each facet its own goal"
89
+ )
90
+ return goals
91
+
92
+
93
+ def label_corpus(
94
+ corpus: Any,
95
+ *,
96
+ facets: Mapping[str, str] | Iterable[str],
97
+ retriever: Retriever | None = None,
98
+ formulator: Formulator = identity_formulator,
99
+ judge: Callable[..., Any] | None = None,
100
+ reformulate: Reformulate | None = None,
101
+ levels: tuple[str, ...] = DEFAULT_LEVELS,
102
+ coverage_level: str = "strong",
103
+ min_covered: int = 1,
104
+ budget: Budget | None = None,
105
+ k: int = DFLT_K,
106
+ **evaluator_kwargs: Any,
107
+ ) -> CorpusLabeling:
108
+ """Label ``corpus`` for each of ``facets`` with a graded judge; see the module.
109
+
110
+ Args:
111
+ corpus: an ``ir`` corpus or corpus name, read through
112
+ ``ir.as_retriever(corpus, k=k)`` unless ``retriever`` is given.
113
+ facets: the topic's facets, as ``{name: goal}`` or names (a name is its
114
+ own goal). One sub-task each.
115
+ retriever: inject a :data:`~raglab.agent.Retriever` instead (a test
116
+ double, a federated source); ``corpus`` is then not read.
117
+ formulator: the Formulator role (identity, or
118
+ :func:`raglab.make_llm_formulator`).
119
+ judge: the graded judge (built lazily on :mod:`oa` when omitted).
120
+ reformulate: gap-driven reformulation; when a facet has fewer than
121
+ ``min_covered`` labels at ``coverage_level`` it gets a new query, which
122
+ becomes the facet's next sub-task. ``None`` (default): one round each.
123
+ budget: the loop bounds (``max_rounds`` caps the reformulations).
124
+ **evaluator_kwargs: forwarded to
125
+ :func:`~raglab.judging.make_graded_rubric_evaluator` (``rubric``,
126
+ ``calibration_offset``, ``disclose_kwargs``, ``rng``, ...).
127
+ """
128
+ goals = _facet_goals(facets)
129
+ levels = tuple(levels)
130
+ named = {
131
+ "coverage_level": coverage_level,
132
+ **{
133
+ key: evaluator_kwargs.get(key, default)
134
+ for key, default in (
135
+ ("unsupported_cap", "tangential"),
136
+ ("relevant_level", "uses-tools"),
137
+ )
138
+ },
139
+ }
140
+ for key, value in named.items():
141
+ if value not in levels:
142
+ raise ValueError(
143
+ f"{key}={value!r} is not one of {list(levels)}: with custom levels, "
144
+ "pass coverage_level, unsupported_cap and relevant_level from them"
145
+ )
146
+ cut = levels.index(coverage_level)
147
+ source = retriever if retriever is not None else ir.as_retriever(corpus, k=k)
148
+ facet_of: dict[str, str] = {goal: name for name, goal in goals.items()}
149
+ seen: dict[str, dict[str, GradedLabel]] = {name: {} for name in goals}
150
+ rounds: dict[str, int] = dict.fromkeys(goals, 0)
151
+
152
+ def covered(name: str) -> int:
153
+ return sum(levels.index(g.level) <= cut for g in seen[name].values())
154
+
155
+ max_rounds = (budget if budget is not None else Budget()).max_rounds
156
+
157
+ def refine(task: SubTask, name: str) -> SubTask | None:
158
+ if (
159
+ reformulate is None
160
+ or covered(name) >= min_covered
161
+ or rounds[name] >= max_rounds
162
+ ):
163
+ return None # covered, no policy, or no round left to run a new query in
164
+ query = reformulate(goals[name], dict(seen[name]))
165
+ if not query or query in facet_of:
166
+ return None # nothing new to ask: stop rather than loop
167
+ facet_of[query] = name
168
+ return SubTask(goal=query, sources=task.sources)
169
+
170
+ graded_evaluator = make_graded_rubric_evaluator(
171
+ judge=judge, levels=levels, **evaluator_kwargs
172
+ )
173
+
174
+ def evaluator(task: SubTask, results: list[Result]) -> Judgement:
175
+ name = facet_of[task.goal]
176
+ rounds[name] += 1
177
+ judged = graded_evaluator(_as_facet_goal(task, goals[name]), results)
178
+ for aid, label in judged.graded.items():
179
+ label = replace(label, facet=name)
180
+ best = seen[name].get(aid)
181
+ if best is None or levels.index(label.level) < levels.index(best.level):
182
+ seen[name][aid] = label
183
+ refinement = refine(task, name)
184
+ return replace(judged, sufficient=refinement is None, refinement=refinement)
185
+
186
+ def _as_facet_goal(task: SubTask, goal: str) -> SubTask:
187
+ # The judge grades against the facet's goal, whatever query retrieved the pool.
188
+ return task if task.goal == goal else replace(task, goal=goal)
189
+
190
+ def planner(query: Query, sources: Mapping[str, Retriever]) -> list[SubTask]:
191
+ return [SubTask(goal=goal, sources=(SOURCE,)) for goal in goals.values()]
192
+
193
+ agent = SingleContextAgent(
194
+ sources={SOURCE: source},
195
+ planner=planner,
196
+ formulator=formulator,
197
+ evaluator=evaluator,
198
+ budget=budget if budget is not None else Budget(),
199
+ )
200
+ agent(Query(text=" / ".join(goals.values())))
201
+
202
+ labels: dict[str, GradedLabel] = {}
203
+ for per_facet in seen.values():
204
+ for aid, label in per_facet.items():
205
+ if aid not in labels or levels.index(label.level) < levels.index(
206
+ labels[aid].level
207
+ ):
208
+ labels[aid] = label
209
+ coverage = {
210
+ name: FacetCoverage(
211
+ facet=name,
212
+ judged=len(seen[name]),
213
+ covered=covered(name),
214
+ rounds=rounds[name],
215
+ sufficient=covered(name) >= min_covered,
216
+ )
217
+ for name in goals
218
+ }
219
+ return CorpusLabeling(labels=labels, coverage=coverage, levels=levels)
@@ -0,0 +1,117 @@
1
+ """Tests for the corpus-labeling recipe (raglab#8, part B). Hermetic: a fake retriever and a scripted judge."""
2
+
3
+ import pytest
4
+ from ir import SearchHit
5
+
6
+ from raglab import Budget
7
+ from raglab.recipes import CorpusLabeling, label_corpus
8
+
9
+ DOCS = {
10
+ "graphy": "A graph library: nodes, edges, traversals.",
11
+ "au": "An async task framework; jobs form a DAG.",
12
+ "layout": "Graph layout algorithms for drawing networks.",
13
+ "decoder": "An audio decoder.",
14
+ }
15
+ INDEX = {
16
+ "graph data structures": ["graphy", "au"],
17
+ "graph drawing": ["decoder"],
18
+ "network layout algorithms": ["layout"],
19
+ }
20
+
21
+
22
+ def _retriever(query, **kw):
23
+ return [SearchHit(aid, "k", 1.0 - i / 10, DOCS[aid], {}) for i, aid in enumerate(INDEX.get(query, []))]
24
+
25
+
26
+ def _judge(*, goal, artifact_id, content, **kw):
27
+ level = {"graphy": "core", "au": "strong", "layout": "core", "decoder": "none"}[artifact_id]
28
+ supported = artifact_id != "au"
29
+ quote = content[:20]
30
+ return {"level": level, "is_about": True, "is_supported": supported, "is_useful": supported, "evidence_quote": quote}
31
+
32
+
33
+ def test_every_facet_is_labelled_and_the_gate_holds():
34
+ result = label_corpus(
35
+ None,
36
+ facets={"structures": "graph data structures", "drawing": "graph drawing"},
37
+ retriever=_retriever,
38
+ judge=_judge,
39
+ )
40
+ assert isinstance(result, CorpusLabeling)
41
+ assert {aid: g.level for aid, g in result.labels.items()} == {
42
+ "graphy": "core",
43
+ "au": "tangential",
44
+ "decoder": "none",
45
+ }
46
+ assert result.labels["graphy"].facet == "structures"
47
+ assert result.coverage["structures"].sufficient
48
+ assert not result.coverage["drawing"].sufficient
49
+ assert result.at_least("strong") == ["graphy"]
50
+
51
+
52
+ def test_a_thin_facet_is_reformulated_and_judged_against_its_own_goal():
53
+ goals_seen = []
54
+
55
+ def judge(**kw):
56
+ goals_seen.append(kw["goal"])
57
+ return _judge(**kw)
58
+
59
+ result = label_corpus(
60
+ None,
61
+ facets={"drawing": "graph drawing"},
62
+ retriever=_retriever,
63
+ judge=judge,
64
+ reformulate=lambda goal, labels: "network layout algorithms",
65
+ budget=Budget(max_rounds=3),
66
+ )
67
+ assert result.labels["layout"].level == "core"
68
+ assert result.coverage["drawing"].rounds == 2 and result.coverage["drawing"].sufficient
69
+ assert set(goals_seen) == {"graph drawing"}
70
+
71
+
72
+ def test_a_reformulation_that_repeats_itself_stops():
73
+ result = label_corpus(
74
+ None,
75
+ facets=["graph drawing"],
76
+ retriever=_retriever,
77
+ judge=_judge,
78
+ reformulate=lambda goal, labels: goal,
79
+ budget=Budget(max_rounds=5),
80
+ )
81
+ assert result.coverage["graph drawing"].rounds == 1
82
+
83
+
84
+ def test_facets_are_required():
85
+ with pytest.raises(ValueError, match="at least one facet"):
86
+ label_corpus(None, facets=[], retriever=_retriever, judge=_judge)
87
+
88
+
89
+ def test_facets_with_one_goal_are_refused():
90
+ with pytest.raises(ValueError, match="share a goal"):
91
+ label_corpus(None, facets={"x": "same", "y": "same"}, retriever=_retriever, judge=_judge)
92
+
93
+
94
+ def test_no_reformulation_is_asked_for_once_the_rounds_are_spent():
95
+ asked = []
96
+
97
+ def reformulate(goal, labels):
98
+ asked.append(goal)
99
+ return f"query {len(asked)}"
100
+
101
+ label_corpus(None, facets=["graph drawing"], retriever=_retriever, judge=_judge,
102
+ reformulate=reformulate, budget=Budget(max_rounds=2))
103
+ assert len(asked) == 1
104
+
105
+
106
+ def test_custom_levels_need_their_own_level_names():
107
+ def judge(**kw):
108
+ return {"level": "hi", "is_about": True, "is_supported": True, "is_useful": True,
109
+ "evidence_quote": kw["content"][:20]}
110
+
111
+ with pytest.raises(ValueError, match="unsupported_cap"):
112
+ label_corpus(None, facets=["graph data structures"], retriever=_retriever, judge=judge,
113
+ levels=("hi", "lo"), coverage_level="hi")
114
+ result = label_corpus(None, facets=["graph data structures"], retriever=_retriever, judge=judge,
115
+ levels=("hi", "lo"), coverage_level="hi",
116
+ unsupported_cap="lo", relevant_level="hi")
117
+ assert sorted(result.at_least("hi")) == ["au", "graphy"]
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes