raglab 0.2.7__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.7
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.7"
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"
@@ -30,6 +30,7 @@ from .agent import (
30
30
  Citer,
31
31
  Evaluator,
32
32
  Formulator,
33
+ GradedLabel,
33
34
  Judgement,
34
35
  LowLevelQuery,
35
36
  Planner,
@@ -53,12 +54,20 @@ from .agent import (
53
54
  single_subtask_planner,
54
55
  )
55
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
+ )
56
64
 
57
65
  __all__ = [
58
66
  "Query",
59
67
  "SubTask",
60
68
  "LowLevelQuery",
61
69
  "Judgement",
70
+ "GradedLabel",
62
71
  "Result",
63
72
  "Retriever",
64
73
  "Planner",
@@ -83,4 +92,9 @@ __all__ = [
83
92
  "make_llm_formulator",
84
93
  "make_llm_evaluator",
85
94
  "EVALUATION_PROMPT",
95
+ "DEFAULT_LEVELS",
96
+ "DEFAULT_TOPICAL_RUBRIC",
97
+ "GRADING_PROMPT",
98
+ "apply_gates",
99
+ "make_graded_rubric_evaluator",
86
100
  ]
@@ -52,6 +52,7 @@ __all__ = [
52
52
  "SubTask",
53
53
  "LowLevelQuery",
54
54
  "Judgement",
55
+ "GradedLabel",
55
56
  "Result",
56
57
  "Retriever",
57
58
  "Planner",
@@ -111,6 +112,25 @@ class LowLevelQuery:
111
112
  params: Mapping[str, Any] = field(default_factory=dict)
112
113
 
113
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
+
114
134
  @dataclass(frozen=True)
115
135
  class Judgement:
116
136
  """An evaluator's verdict over a round's results.
@@ -118,12 +138,15 @@ class Judgement:
118
138
  ``relevant`` is the kept subset; ``sufficient`` says whether to stop; a
119
139
  non-``None`` ``refinement`` is the **back-edge** — the next sub-task to
120
140
  re-query with. The pass-through default returns ``sufficient=True`` and no
121
- 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.
122
144
  """
123
145
 
124
146
  relevant: Sequence[Result]
125
147
  sufficient: bool
126
148
  refinement: SubTask | None = None
149
+ graded: Mapping[str, GradedLabel] = field(default_factory=dict)
127
150
 
128
151
 
129
152
  # --------------------------------------------------------------------------- #
@@ -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)
@@ -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"]
@@ -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
File without changes
File without changes
File without changes