ragaudit-cli 0.3.0__py3-none-any.whl
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.
- ragaudit/__init__.py +1 -0
- ragaudit/adapters/__init__.py +0 -0
- ragaudit/adapters/base.py +109 -0
- ragaudit/adapters/callable_adapter.py +15 -0
- ragaudit/cli.py +540 -0
- ragaudit/cli_easy.py +458 -0
- ragaudit/cli_v2.py +507 -0
- ragaudit/env.py +46 -0
- ragaudit/generate/__init__.py +0 -0
- ragaudit/generate/pipeline.py +486 -0
- ragaudit/generate/prompts.py +116 -0
- ragaudit/generate/testset_generator.py +146 -0
- ragaudit/generate/text.py +75 -0
- ragaudit/ingest/__init__.py +0 -0
- ragaudit/ingest/chunker.py +53 -0
- ragaudit/ingest/corpus.py +120 -0
- ragaudit/ingest/loader.py +49 -0
- ragaudit/ingest/versioning.py +92 -0
- ragaudit/llm/__init__.py +0 -0
- ragaudit/llm/client.py +86 -0
- ragaudit/llm/factory.py +70 -0
- ragaudit/llm/json_output.py +72 -0
- ragaudit/llm/mock.py +552 -0
- ragaudit/models/__init__.py +0 -0
- ragaudit/models/adapter.py +39 -0
- ragaudit/models/document.py +28 -0
- ragaudit/models/evaluation.py +70 -0
- ragaudit/models/question.py +152 -0
- ragaudit/models/run.py +44 -0
- ragaudit/models/scoring.py +82 -0
- ragaudit/models/testset.py +30 -0
- ragaudit/models/tune.py +56 -0
- ragaudit/report/__init__.py +0 -0
- ragaudit/report/generator.py +155 -0
- ragaudit/report/plain_summary.py +122 -0
- ragaudit/report/templates/_v2_tooltip.jinja +43 -0
- ragaudit/report/templates/generate_report.html.jinja +175 -0
- ragaudit/report/templates/report.html.jinja +252 -0
- ragaudit/report/templates/style.css +1095 -0
- ragaudit/report/templates/tune_report.html.jinja +158 -0
- ragaudit/report/templates/v2.css +286 -0
- ragaudit/report/v2.py +380 -0
- ragaudit/runner/__init__.py +0 -0
- ragaudit/runner/ablation.py +46 -0
- ragaudit/runner/evaluate.py +325 -0
- ragaudit/runner/executor.py +98 -0
- ragaudit/runner/locked.py +37 -0
- ragaudit/runner/summary.py +45 -0
- ragaudit/scaffold.py +252 -0
- ragaudit/scoring/__init__.py +0 -0
- ragaudit/scoring/correctness.py +94 -0
- ragaudit/scoring/groundedness.py +86 -0
- ragaudit/scoring/retrieval.py +42 -0
- ragaudit/scoring/text_match.py +51 -0
- ragaudit/scoring/verdict.py +14 -0
- ragaudit/storage/__init__.py +0 -0
- ragaudit/storage/db.py +81 -0
- ragaudit/storage/lock.py +36 -0
- ragaudit/storage/repository.py +216 -0
- ragaudit/storage/store.py +536 -0
- ragaudit/tune/__init__.py +7 -0
- ragaudit/tune/devset.py +16 -0
- ragaudit/tune/loop.py +208 -0
- ragaudit/tune/search_space.py +140 -0
- ragaudit/tune/strategies.py +197 -0
- ragaudit/yaml_io.py +199 -0
- ragaudit_cli-0.3.0.dist-info/METADATA +429 -0
- ragaudit_cli-0.3.0.dist-info/RECORD +71 -0
- ragaudit_cli-0.3.0.dist-info/WHEEL +4 -0
- ragaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
- ragaudit_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
ragaudit/tune/loop.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""The closed tuning loop: search configs on the dev split, then score baseline and best once on the test split.
|
|
2
|
+
|
|
3
|
+
1. Trial 0 is the baseline config. Each later trial asks the strategy for
|
|
4
|
+
a config, evaluates it on the **dev split only** (cached results are
|
|
5
|
+
reused) and keeps the best dev score (ties go to the earlier trial).
|
|
6
|
+
2. When the search stops (max trials, space exhausted, or the LLM budget),
|
|
7
|
+
the baseline and the best config are evaluated on the **locked test
|
|
8
|
+
split, once each**, through ragaudit.runner.locked. Only aggregate
|
|
9
|
+
scores come back.
|
|
10
|
+
3. If the best config's dev score beats its test score by more than the
|
|
11
|
+
threshold, the run is flagged as likely overfit to the dev set.
|
|
12
|
+
|
|
13
|
+
The loop sees questions only through DevSet; it never touches the test
|
|
14
|
+
split except through `evaluate_test_split`.
|
|
15
|
+
|
|
16
|
+
LLM budget: --max-llm-calls covers the judge and the proposer. Room is
|
|
17
|
+
reserved up front for the final test evaluation (two configs x the test
|
|
18
|
+
set), and the search stops before a trial that might not fit, so the
|
|
19
|
+
honest test score is always produced.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import uuid
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
|
|
25
|
+
from ragaudit.adapters.base import AdapterFactory
|
|
26
|
+
from ragaudit.llm.factory import BudgetedCompleter, CallBudget, Completer
|
|
27
|
+
from ragaudit.models.evaluation import FailureClass, QuestionResult
|
|
28
|
+
from ragaudit.models.question import Split, utc_now
|
|
29
|
+
from ragaudit.models.tune import Trial, TuneResult
|
|
30
|
+
from ragaudit.runner.evaluate import compute_config_hash, run_evaluation
|
|
31
|
+
from ragaudit.runner.locked import count_test_questions, evaluate_test_split
|
|
32
|
+
from ragaudit.scoring.correctness import judge_correctness
|
|
33
|
+
from ragaudit.storage.store import Store
|
|
34
|
+
from ragaudit.tune.devset import DevSet
|
|
35
|
+
from ragaudit.tune.search_space import SearchSpace, canonical
|
|
36
|
+
from ragaudit.tune.strategies import make_strategy
|
|
37
|
+
|
|
38
|
+
DEFAULT_MAX_TRIALS = 20
|
|
39
|
+
DEFAULT_OVERFIT_THRESHOLD = 10.0
|
|
40
|
+
FAILED_EXAMPLES_FOR_PROPOSER = 5
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TuneError(Exception):
|
|
44
|
+
"""Raised when tuning can't start (no dev questions, budget too small for the final evaluation, ...)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _judge_for(client: Completer):
|
|
48
|
+
def judge(question: str, expected_answer: str, candidate_answer: str):
|
|
49
|
+
return judge_correctness(client, question, expected_answer, candidate_answer)
|
|
50
|
+
|
|
51
|
+
return judge
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _feedback(best: Trial, best_results: list[QuestionResult], dev: DevSet) -> dict:
|
|
55
|
+
"""Dev-only feedback for the proposer: the best config's failure counts and a few failed dev examples."""
|
|
56
|
+
by_id = {q.question_id: q for q in dev.questions}
|
|
57
|
+
failed = [r for r in best_results if r.failure_class != FailureClass.PASS][:FAILED_EXAMPLES_FOR_PROPOSER]
|
|
58
|
+
return {
|
|
59
|
+
"best_config": best.config,
|
|
60
|
+
"best_dev_score": best.dev.score,
|
|
61
|
+
"failure_counts": best.dev.failure_counts,
|
|
62
|
+
"failed_examples": [
|
|
63
|
+
{
|
|
64
|
+
"question": by_id[r.question_id].question,
|
|
65
|
+
"gold_answer": by_id[r.question_id].gold_answer,
|
|
66
|
+
"pipeline_answer": r.answer,
|
|
67
|
+
"failure_class": r.failure_class.value,
|
|
68
|
+
"gold_chunk_retrieved": r.retrieval_hit,
|
|
69
|
+
}
|
|
70
|
+
for r in failed
|
|
71
|
+
if r.question_id in by_id
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_tune(
|
|
77
|
+
store: Store,
|
|
78
|
+
adapter_factory: AdapterFactory,
|
|
79
|
+
adapter_sha256: str,
|
|
80
|
+
adapter_path: str,
|
|
81
|
+
space: SearchSpace,
|
|
82
|
+
judge_client: Completer,
|
|
83
|
+
index_version: str,
|
|
84
|
+
*,
|
|
85
|
+
strategy: str = "grid",
|
|
86
|
+
proposer_client: Completer | None = None,
|
|
87
|
+
max_trials: int = DEFAULT_MAX_TRIALS,
|
|
88
|
+
max_llm_calls: int | None = None,
|
|
89
|
+
seed: int = 0,
|
|
90
|
+
k: int = 5,
|
|
91
|
+
overfit_threshold: float = DEFAULT_OVERFIT_THRESHOLD,
|
|
92
|
+
on_trial: Callable[[Trial, Trial], None] | None = None,
|
|
93
|
+
) -> TuneResult:
|
|
94
|
+
dev = DevSet(store)
|
|
95
|
+
if not dev.questions:
|
|
96
|
+
raise TuneError("no active dev questions; run `ragaudit generate --corpus <path>` first.")
|
|
97
|
+
n_test = count_test_questions(store)
|
|
98
|
+
if n_test == 0:
|
|
99
|
+
raise TuneError("no active test questions to score the final configs on; generate more questions first.")
|
|
100
|
+
if max_trials < 1:
|
|
101
|
+
raise TuneError("--max-trials must be at least 1")
|
|
102
|
+
|
|
103
|
+
final_reserve = 2 * n_test
|
|
104
|
+
if max_llm_calls is not None and max_llm_calls < final_reserve + len(dev):
|
|
105
|
+
raise TuneError(
|
|
106
|
+
f"--max-llm-calls {max_llm_calls} is too small: the baseline needs up to {len(dev)} judge calls on dev "
|
|
107
|
+
f"and the final test evaluation reserves {final_reserve}."
|
|
108
|
+
)
|
|
109
|
+
search_budget = CallBudget(None if max_llm_calls is None else max_llm_calls - final_reserve)
|
|
110
|
+
judge = _judge_for(BudgetedCompleter(judge_client, search_budget))
|
|
111
|
+
proposer = BudgetedCompleter(proposer_client, search_budget) if proposer_client is not None else None
|
|
112
|
+
chooser = make_strategy(strategy, space, seed, proposer)
|
|
113
|
+
|
|
114
|
+
result = TuneResult(
|
|
115
|
+
tune_id=str(uuid.uuid4()),
|
|
116
|
+
created_at=utc_now(),
|
|
117
|
+
strategy=strategy,
|
|
118
|
+
search_space=space.knobs,
|
|
119
|
+
adapter_path=adapter_path,
|
|
120
|
+
judge_model=judge_client.model,
|
|
121
|
+
index_version=index_version,
|
|
122
|
+
k=k,
|
|
123
|
+
seed=seed,
|
|
124
|
+
max_trials=max_trials,
|
|
125
|
+
overfit_threshold=overfit_threshold,
|
|
126
|
+
stopped_reason="max_trials",
|
|
127
|
+
)
|
|
128
|
+
store.save_tune_run(result.tune_id, result.created_at, strategy, space.knobs)
|
|
129
|
+
|
|
130
|
+
def config_hash(config: dict) -> str:
|
|
131
|
+
return compute_config_hash(config, adapter_sha256, judge_client.model, k)
|
|
132
|
+
|
|
133
|
+
def evaluate_dev(config: dict) -> tuple:
|
|
134
|
+
return run_evaluation(
|
|
135
|
+
store,
|
|
136
|
+
dev.questions,
|
|
137
|
+
Split.DEV,
|
|
138
|
+
adapter_factory(config),
|
|
139
|
+
judge,
|
|
140
|
+
config=config,
|
|
141
|
+
config_hash=config_hash(config),
|
|
142
|
+
index_version=index_version,
|
|
143
|
+
k=k,
|
|
144
|
+
full=True,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
trials: list[Trial] = []
|
|
148
|
+
best_results: list[QuestionResult] = []
|
|
149
|
+
next_proposal = (space.baseline, "baseline", None)
|
|
150
|
+
while next_proposal is not None:
|
|
151
|
+
config, source, rationale = next_proposal
|
|
152
|
+
summary, results = evaluate_dev(config)
|
|
153
|
+
trial = Trial(
|
|
154
|
+
index=len(trials),
|
|
155
|
+
config=config,
|
|
156
|
+
config_hash=config_hash(config),
|
|
157
|
+
source=source,
|
|
158
|
+
rationale=rationale,
|
|
159
|
+
dev=summary,
|
|
160
|
+
created_at=utc_now(),
|
|
161
|
+
)
|
|
162
|
+
trials.append(trial)
|
|
163
|
+
store.save_tune_trial(result.tune_id, trial)
|
|
164
|
+
if trial.dev.score > trials[result.best_index].dev.score or trial.index == 0:
|
|
165
|
+
result.best_index = trial.index
|
|
166
|
+
best_results = results
|
|
167
|
+
if on_trial is not None:
|
|
168
|
+
on_trial(trial, trials[result.best_index])
|
|
169
|
+
|
|
170
|
+
if len(trials) >= max_trials:
|
|
171
|
+
result.stopped_reason = "max_trials"
|
|
172
|
+
break
|
|
173
|
+
worst_case_trial = len(dev) + (2 if proposer is not None else 0)
|
|
174
|
+
if search_budget.remaining is not None and search_budget.remaining < worst_case_trial:
|
|
175
|
+
result.stopped_reason = "max_llm_calls"
|
|
176
|
+
break
|
|
177
|
+
proposal = chooser.propose(trials, _feedback(trials[result.best_index], best_results, dev))
|
|
178
|
+
if proposal is None:
|
|
179
|
+
result.stopped_reason = "search_space_exhausted"
|
|
180
|
+
break
|
|
181
|
+
next_proposal = (proposal.config, proposal.source, proposal.rationale)
|
|
182
|
+
|
|
183
|
+
result.trials = trials
|
|
184
|
+
final_budget = CallBudget(None)
|
|
185
|
+
final_judge = _judge_for(BudgetedCompleter(judge_client, final_budget))
|
|
186
|
+
|
|
187
|
+
def evaluate_test(config: dict):
|
|
188
|
+
return evaluate_test_split(
|
|
189
|
+
store,
|
|
190
|
+
adapter_factory(config),
|
|
191
|
+
final_judge,
|
|
192
|
+
config=config,
|
|
193
|
+
config_hash=config_hash(config),
|
|
194
|
+
index_version=index_version,
|
|
195
|
+
k=k,
|
|
196
|
+
full=True,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
result.baseline_test = evaluate_test(result.baseline.config)
|
|
200
|
+
same_config = canonical(result.best.config) == canonical(result.baseline.config)
|
|
201
|
+
result.best_test = result.baseline_test if same_config else evaluate_test(result.best.config)
|
|
202
|
+
|
|
203
|
+
result.overfit_gap = round(result.best.dev.score - result.best_test.score, 2)
|
|
204
|
+
result.overfit_warning = result.overfit_gap > overfit_threshold
|
|
205
|
+
result.llm_calls = search_budget.used + final_budget.used
|
|
206
|
+
result.finished_at = utc_now()
|
|
207
|
+
store.finish_tune_run(result.tune_id, result.finished_at, result.model_dump_json())
|
|
208
|
+
return result
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""The tuning search space: a YAML file of knobs the pipeline accepts, each with a finite list of values.
|
|
2
|
+
|
|
3
|
+
knobs:
|
|
4
|
+
chunk_size: [20, 40, 80]
|
|
5
|
+
top_k: [1, 3, 5]
|
|
6
|
+
hybrid_weight: {min: 0.0, max: 1.0, step: 0.25} # expands to 0.0, 0.25, ..., 1.0
|
|
7
|
+
reranker: [none, overlap]
|
|
8
|
+
baseline: # optional; defaults to the first value of each knob
|
|
9
|
+
chunk_size: 40
|
|
10
|
+
top_k: 1
|
|
11
|
+
|
|
12
|
+
Every config a strategy proposes must assign every knob a value from its
|
|
13
|
+
list; `contains` checks that.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import itertools
|
|
17
|
+
import json
|
|
18
|
+
import random
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
|
|
24
|
+
from ragaudit import yaml_io
|
|
25
|
+
|
|
26
|
+
MAX_RANGE_VALUES = 1000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SearchSpaceError(ValueError):
|
|
30
|
+
"""Raised for a malformed search-space file or an out-of-space config."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _expand_range(knob: str, spec: dict) -> list:
|
|
34
|
+
try:
|
|
35
|
+
low, high, step = spec["min"], spec["max"], spec["step"]
|
|
36
|
+
except KeyError as exc:
|
|
37
|
+
raise SearchSpaceError(f"knob {knob!r}: a range needs min, max and step") from exc
|
|
38
|
+
if not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in (low, high, step)):
|
|
39
|
+
raise SearchSpaceError(f"knob {knob!r}: min, max and step must be numbers")
|
|
40
|
+
if step <= 0 or high < low:
|
|
41
|
+
raise SearchSpaceError(f"knob {knob!r}: need step > 0 and max >= min")
|
|
42
|
+
count = int(round((high - low) / step)) + 1
|
|
43
|
+
if count > MAX_RANGE_VALUES:
|
|
44
|
+
raise SearchSpaceError(f"knob {knob!r}: range expands to {count} values (max {MAX_RANGE_VALUES})")
|
|
45
|
+
integral = all(isinstance(v, int) for v in (low, high, step))
|
|
46
|
+
return [low + i * step if integral else round(low + i * step, 10) for i in range(count)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _same_value(a, b) -> bool:
|
|
50
|
+
if isinstance(a, (int, float)) and isinstance(b, (int, float)) and not isinstance(a, bool) and not isinstance(b, bool):
|
|
51
|
+
return abs(a - b) < 1e-9
|
|
52
|
+
return a == b
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def canonical(config: dict) -> str:
|
|
56
|
+
"""A stable string identity for a config, for "already tried?" checks."""
|
|
57
|
+
return json.dumps(config, sort_keys=True, default=str)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SearchSpace(BaseModel):
|
|
61
|
+
knobs: dict[str, list]
|
|
62
|
+
baseline: dict
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def size(self) -> int:
|
|
66
|
+
size = 1
|
|
67
|
+
for values in self.knobs.values():
|
|
68
|
+
size *= len(values)
|
|
69
|
+
return size
|
|
70
|
+
|
|
71
|
+
def normalize(self, config: dict) -> dict:
|
|
72
|
+
"""Return config with each value replaced by the space's own value; raise if anything is outside the space."""
|
|
73
|
+
if set(config) != set(self.knobs):
|
|
74
|
+
missing, extra = set(self.knobs) - set(config), set(config) - set(self.knobs)
|
|
75
|
+
raise SearchSpaceError(f"config must set exactly the declared knobs (missing {sorted(missing)}, extra {sorted(extra)})")
|
|
76
|
+
normalized = {}
|
|
77
|
+
for knob, values in self.knobs.items():
|
|
78
|
+
match = next((v for v in values if _same_value(v, config[knob])), None)
|
|
79
|
+
if match is None and not any(_same_value(v, config[knob]) for v in values):
|
|
80
|
+
raise SearchSpaceError(f"{knob}={config[knob]!r} is outside the search space {values}")
|
|
81
|
+
normalized[knob] = match
|
|
82
|
+
return normalized
|
|
83
|
+
|
|
84
|
+
def contains(self, config: dict) -> bool:
|
|
85
|
+
try:
|
|
86
|
+
self.normalize(config)
|
|
87
|
+
except SearchSpaceError:
|
|
88
|
+
return False
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
def grid(self) -> Iterator[dict]:
|
|
92
|
+
"""Every config, in a fixed order (the first knob varies slowest)."""
|
|
93
|
+
names = list(self.knobs)
|
|
94
|
+
for combo in itertools.product(*(self.knobs[n] for n in names)):
|
|
95
|
+
yield dict(zip(names, combo))
|
|
96
|
+
|
|
97
|
+
def random_config(self, rng: random.Random) -> dict:
|
|
98
|
+
return {knob: rng.choice(values) for knob, values in self.knobs.items()}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_search_space(data) -> SearchSpace:
|
|
102
|
+
if not isinstance(data, dict):
|
|
103
|
+
raise SearchSpaceError("search space must be a mapping with a `knobs` section")
|
|
104
|
+
raw_knobs = data.get("knobs", data if "baseline" not in data else None)
|
|
105
|
+
if not isinstance(raw_knobs, dict) or not raw_knobs:
|
|
106
|
+
raise SearchSpaceError("search space needs a non-empty `knobs` mapping")
|
|
107
|
+
|
|
108
|
+
knobs: dict[str, list] = {}
|
|
109
|
+
for knob, spec in raw_knobs.items():
|
|
110
|
+
if isinstance(spec, dict):
|
|
111
|
+
values = _expand_range(knob, spec)
|
|
112
|
+
elif isinstance(spec, list):
|
|
113
|
+
values = list(spec)
|
|
114
|
+
else:
|
|
115
|
+
values = [spec]
|
|
116
|
+
if not values:
|
|
117
|
+
raise SearchSpaceError(f"knob {knob!r} has no values")
|
|
118
|
+
knobs[str(knob)] = values
|
|
119
|
+
|
|
120
|
+
baseline_spec = data.get("baseline") or {}
|
|
121
|
+
if not isinstance(baseline_spec, dict):
|
|
122
|
+
raise SearchSpaceError("`baseline` must be a mapping of knob: value")
|
|
123
|
+
unknown = set(baseline_spec) - set(knobs)
|
|
124
|
+
if unknown:
|
|
125
|
+
raise SearchSpaceError(f"baseline sets unknown knob(s): {sorted(unknown)}")
|
|
126
|
+
space = SearchSpace(knobs=knobs, baseline={})
|
|
127
|
+
baseline = {knob: baseline_spec.get(knob, values[0]) for knob, values in knobs.items()}
|
|
128
|
+
space.baseline = space.normalize(baseline)
|
|
129
|
+
return space
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def load_search_space(path: str | Path) -> SearchSpace:
|
|
133
|
+
path = Path(path)
|
|
134
|
+
if not path.is_file():
|
|
135
|
+
raise SearchSpaceError(f"search space file not found: {path}")
|
|
136
|
+
try:
|
|
137
|
+
data = yaml_io.load(path)
|
|
138
|
+
except yaml_io.YAMLError as exc:
|
|
139
|
+
raise SearchSpaceError(f"could not parse {path}: {exc}") from exc
|
|
140
|
+
return parse_search_space(data)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""How `tune` picks the next config: grid, seeded random, or an LLM reading dev-set failures.
|
|
2
|
+
|
|
3
|
+
Every strategy only ever sees dev-split information: trial configs, dev
|
|
4
|
+
scores, dev failure-class counts and a few failed dev examples. Proposals
|
|
5
|
+
are validated against the declared search space; an LLM proposal that is
|
|
6
|
+
outside it (or already tried) gets one corrective retry, then a seeded
|
|
7
|
+
random config is used instead and the trial is marked `llm_fallback`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import itertools
|
|
11
|
+
import json
|
|
12
|
+
import random
|
|
13
|
+
import re
|
|
14
|
+
from ragaudit.llm.factory import Completer
|
|
15
|
+
from ragaudit.models.tune import Trial
|
|
16
|
+
from ragaudit.tune.search_space import SearchSpace, SearchSpaceError, canonical
|
|
17
|
+
|
|
18
|
+
STATE_MARKER = "STATE_JSON:"
|
|
19
|
+
|
|
20
|
+
PROPOSER_SYSTEM_PROMPT = (
|
|
21
|
+
"You are tuning a RAG pipeline. You will get the search space (every knob and its allowed values), the "
|
|
22
|
+
"configs tried so far with their dev-set scores (pass rate in points: the gold passage was retrieved AND "
|
|
23
|
+
"the answer was correct), the failure-class counts of the best config so far, and a few failed dev "
|
|
24
|
+
"examples. Failure classes: retrieval_miss = the gold passage was not in the top k; generation_miss = it "
|
|
25
|
+
"was retrieved but the answer was still wrong.\n"
|
|
26
|
+
"\n"
|
|
27
|
+
"Propose ONE new config, not yet tried, that is likely to score higher. Every knob must be set, and every "
|
|
28
|
+
"value must be one of that knob's allowed values exactly. Reason from the failures: retrieval misses call "
|
|
29
|
+
"for retrieval knobs, generation misses for generation knobs.\n"
|
|
30
|
+
"\n"
|
|
31
|
+
"Respond with strict JSON only, no prose, no markdown fences, in the form: "
|
|
32
|
+
'{"config": {knob: value, ...}, "rationale": str}'
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
RETRIEVAL_KNOB_ORDER = ["top_k", "hybrid_weight", "reranker", "chunk_size", "chunk_overlap"]
|
|
36
|
+
GENERATION_KNOB_ORDER = ["prompt_variant", "chunk_size", "chunk_overlap", "top_k", "reranker"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Proposed:
|
|
40
|
+
"""A strategy's next config, where it came from, and (for llm) why."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, config: dict, source: str, rationale: str | None = None) -> None:
|
|
43
|
+
self.config = config
|
|
44
|
+
self.source = source
|
|
45
|
+
self.rationale = rationale
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _tried(trials: list[Trial]) -> set[str]:
|
|
49
|
+
return {canonical(t.config) for t in trials}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GridStrategy:
|
|
53
|
+
name = "grid"
|
|
54
|
+
|
|
55
|
+
def __init__(self, space: SearchSpace) -> None:
|
|
56
|
+
self._grid = space.grid()
|
|
57
|
+
|
|
58
|
+
def propose(self, trials: list[Trial], feedback: dict) -> Proposed | None:
|
|
59
|
+
tried = _tried(trials)
|
|
60
|
+
for config in self._grid:
|
|
61
|
+
if canonical(config) not in tried:
|
|
62
|
+
return Proposed(config, "grid")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RandomStrategy:
|
|
67
|
+
name = "random"
|
|
68
|
+
|
|
69
|
+
def __init__(self, space: SearchSpace, seed: int) -> None:
|
|
70
|
+
self.space = space
|
|
71
|
+
self.rng = random.Random(seed)
|
|
72
|
+
self._order: list[dict] | None = None
|
|
73
|
+
|
|
74
|
+
def propose(self, trials: list[Trial], feedback: dict) -> Proposed | None:
|
|
75
|
+
tried = _tried(trials)
|
|
76
|
+
if self.space.size <= 100_000:
|
|
77
|
+
if self._order is None:
|
|
78
|
+
self._order = list(self.space.grid())
|
|
79
|
+
self.rng.shuffle(self._order)
|
|
80
|
+
for config in self._order:
|
|
81
|
+
if canonical(config) not in tried:
|
|
82
|
+
return Proposed(config, "random")
|
|
83
|
+
return None
|
|
84
|
+
for _ in range(1000):
|
|
85
|
+
config = self.space.random_config(self.rng)
|
|
86
|
+
if canonical(config) not in tried:
|
|
87
|
+
return Proposed(config, "random")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_proposer_prompt(space: SearchSpace, trials: list[Trial], feedback: dict) -> str:
|
|
92
|
+
state = {
|
|
93
|
+
"search_space": space.knobs,
|
|
94
|
+
"trials": [
|
|
95
|
+
{"config": t.config, "dev_score": t.dev.score, "failure_counts": t.dev.failure_counts} for t in trials
|
|
96
|
+
],
|
|
97
|
+
"best_config": feedback.get("best_config"),
|
|
98
|
+
"best_dev_score": feedback.get("best_dev_score"),
|
|
99
|
+
"failure_counts": feedback.get("failure_counts", {}),
|
|
100
|
+
"failed_dev_examples": feedback.get("failed_examples", []),
|
|
101
|
+
}
|
|
102
|
+
return f"{STATE_MARKER}\n{json.dumps(state, indent=1, default=str)}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_proposal(raw: str) -> tuple[dict, str | None] | None:
|
|
106
|
+
text = raw.strip()
|
|
107
|
+
fenced = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL)
|
|
108
|
+
try:
|
|
109
|
+
data = json.loads(fenced.group(1) if fenced else text)
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
return None
|
|
112
|
+
if not isinstance(data, dict) or not isinstance(data.get("config"), dict):
|
|
113
|
+
return None
|
|
114
|
+
rationale = data.get("rationale")
|
|
115
|
+
return data["config"], rationale if isinstance(rationale, str) else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class LLMStrategy:
|
|
119
|
+
name = "llm"
|
|
120
|
+
|
|
121
|
+
def __init__(self, space: SearchSpace, client: Completer, seed: int) -> None:
|
|
122
|
+
self.space = space
|
|
123
|
+
self.client = client
|
|
124
|
+
self._fallback = RandomStrategy(space, seed)
|
|
125
|
+
|
|
126
|
+
def propose(self, trials: list[Trial], feedback: dict) -> Proposed | None:
|
|
127
|
+
tried = _tried(trials)
|
|
128
|
+
if len(tried) >= self.space.size:
|
|
129
|
+
return None
|
|
130
|
+
prompt = build_proposer_prompt(self.space, trials, feedback)
|
|
131
|
+
problem = None
|
|
132
|
+
for _ in range(2):
|
|
133
|
+
user = prompt if problem is None else f"{prompt}\n\nYour previous proposal was rejected: {problem}. Try again."
|
|
134
|
+
parsed = parse_proposal(self.client.complete(PROPOSER_SYSTEM_PROMPT, user))
|
|
135
|
+
if parsed is None:
|
|
136
|
+
problem = "it was not valid JSON of the form {\"config\": {...}, \"rationale\": str}"
|
|
137
|
+
continue
|
|
138
|
+
config, rationale = parsed
|
|
139
|
+
try:
|
|
140
|
+
config = self.space.normalize(config)
|
|
141
|
+
except SearchSpaceError as exc:
|
|
142
|
+
problem = str(exc)
|
|
143
|
+
continue
|
|
144
|
+
if canonical(config) in tried:
|
|
145
|
+
problem = f"config {canonical(config)} was already tried"
|
|
146
|
+
continue
|
|
147
|
+
return Proposed(config, "llm", rationale)
|
|
148
|
+
|
|
149
|
+
fallback = self._fallback.propose(trials, feedback)
|
|
150
|
+
if fallback is None:
|
|
151
|
+
return None
|
|
152
|
+
return Proposed(fallback.config, "llm_fallback", f"LLM proposal rejected ({problem}); used a random config")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def make_strategy(name: str, space: SearchSpace, seed: int, client: Completer | None = None):
|
|
156
|
+
if name == "grid":
|
|
157
|
+
return GridStrategy(space)
|
|
158
|
+
if name == "random":
|
|
159
|
+
return RandomStrategy(space, seed)
|
|
160
|
+
if name == "llm":
|
|
161
|
+
if client is None:
|
|
162
|
+
raise ValueError("the llm strategy needs an LLM client")
|
|
163
|
+
return LLMStrategy(space, client, seed)
|
|
164
|
+
raise ValueError(f"unknown strategy {name!r}; expected grid, random or llm")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def mock_proposal(user: str) -> dict:
|
|
168
|
+
"""What MockLLM answers to the proposer prompt: coordinate ascent steered by the dominant failure class.
|
|
169
|
+
|
|
170
|
+
Starting from the best config, change one knob, picking the knob list
|
|
171
|
+
for whichever failure class dominated the most recent trial (the
|
|
172
|
+
latest evidence), and prefer the next value up before going down.
|
|
173
|
+
Deterministic.
|
|
174
|
+
"""
|
|
175
|
+
state = json.loads(user.split(STATE_MARKER, 1)[1].split("\n\nYour previous proposal", 1)[0])
|
|
176
|
+
space: dict[str, list] = state["search_space"]
|
|
177
|
+
tried = {canonical(t["config"]) for t in state["trials"]}
|
|
178
|
+
best = state["best_config"] or {knob: values[0] for knob, values in space.items()}
|
|
179
|
+
failures = (state["trials"][-1].get("failure_counts") if state["trials"] else None) or state.get("failure_counts") or {}
|
|
180
|
+
retrieval_heavy = failures.get("retrieval_miss", 0) >= failures.get("generation_miss", 0)
|
|
181
|
+
preferred = RETRIEVAL_KNOB_ORDER if retrieval_heavy else GENERATION_KNOB_ORDER
|
|
182
|
+
order = [k for k in preferred if k in space] + [k for k in space if k not in preferred]
|
|
183
|
+
dominant = "retrieval misses" if retrieval_heavy else "generation misses"
|
|
184
|
+
|
|
185
|
+
for knob in order:
|
|
186
|
+
values = space[knob]
|
|
187
|
+
position = values.index(best[knob]) if best[knob] in values else 0
|
|
188
|
+
for candidate in values[position + 1 :] + list(reversed(values[:position])):
|
|
189
|
+
config = dict(best, **{knob: candidate})
|
|
190
|
+
if canonical(config) not in tried:
|
|
191
|
+
return {"config": config, "rationale": f"{dominant} dominate, so try {knob}={candidate}"}
|
|
192
|
+
|
|
193
|
+
for combo in itertools.product(*space.values()):
|
|
194
|
+
config = dict(zip(space, combo))
|
|
195
|
+
if canonical(config) not in tried:
|
|
196
|
+
return {"config": config, "rationale": "every single-knob change was tried; next untried config"}
|
|
197
|
+
return {"config": best, "rationale": "search space exhausted"}
|