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.
Files changed (71) hide show
  1. ragaudit/__init__.py +1 -0
  2. ragaudit/adapters/__init__.py +0 -0
  3. ragaudit/adapters/base.py +109 -0
  4. ragaudit/adapters/callable_adapter.py +15 -0
  5. ragaudit/cli.py +540 -0
  6. ragaudit/cli_easy.py +458 -0
  7. ragaudit/cli_v2.py +507 -0
  8. ragaudit/env.py +46 -0
  9. ragaudit/generate/__init__.py +0 -0
  10. ragaudit/generate/pipeline.py +486 -0
  11. ragaudit/generate/prompts.py +116 -0
  12. ragaudit/generate/testset_generator.py +146 -0
  13. ragaudit/generate/text.py +75 -0
  14. ragaudit/ingest/__init__.py +0 -0
  15. ragaudit/ingest/chunker.py +53 -0
  16. ragaudit/ingest/corpus.py +120 -0
  17. ragaudit/ingest/loader.py +49 -0
  18. ragaudit/ingest/versioning.py +92 -0
  19. ragaudit/llm/__init__.py +0 -0
  20. ragaudit/llm/client.py +86 -0
  21. ragaudit/llm/factory.py +70 -0
  22. ragaudit/llm/json_output.py +72 -0
  23. ragaudit/llm/mock.py +552 -0
  24. ragaudit/models/__init__.py +0 -0
  25. ragaudit/models/adapter.py +39 -0
  26. ragaudit/models/document.py +28 -0
  27. ragaudit/models/evaluation.py +70 -0
  28. ragaudit/models/question.py +152 -0
  29. ragaudit/models/run.py +44 -0
  30. ragaudit/models/scoring.py +82 -0
  31. ragaudit/models/testset.py +30 -0
  32. ragaudit/models/tune.py +56 -0
  33. ragaudit/report/__init__.py +0 -0
  34. ragaudit/report/generator.py +155 -0
  35. ragaudit/report/plain_summary.py +122 -0
  36. ragaudit/report/templates/_v2_tooltip.jinja +43 -0
  37. ragaudit/report/templates/generate_report.html.jinja +175 -0
  38. ragaudit/report/templates/report.html.jinja +252 -0
  39. ragaudit/report/templates/style.css +1095 -0
  40. ragaudit/report/templates/tune_report.html.jinja +158 -0
  41. ragaudit/report/templates/v2.css +286 -0
  42. ragaudit/report/v2.py +380 -0
  43. ragaudit/runner/__init__.py +0 -0
  44. ragaudit/runner/ablation.py +46 -0
  45. ragaudit/runner/evaluate.py +325 -0
  46. ragaudit/runner/executor.py +98 -0
  47. ragaudit/runner/locked.py +37 -0
  48. ragaudit/runner/summary.py +45 -0
  49. ragaudit/scaffold.py +252 -0
  50. ragaudit/scoring/__init__.py +0 -0
  51. ragaudit/scoring/correctness.py +94 -0
  52. ragaudit/scoring/groundedness.py +86 -0
  53. ragaudit/scoring/retrieval.py +42 -0
  54. ragaudit/scoring/text_match.py +51 -0
  55. ragaudit/scoring/verdict.py +14 -0
  56. ragaudit/storage/__init__.py +0 -0
  57. ragaudit/storage/db.py +81 -0
  58. ragaudit/storage/lock.py +36 -0
  59. ragaudit/storage/repository.py +216 -0
  60. ragaudit/storage/store.py +536 -0
  61. ragaudit/tune/__init__.py +7 -0
  62. ragaudit/tune/devset.py +16 -0
  63. ragaudit/tune/loop.py +208 -0
  64. ragaudit/tune/search_space.py +140 -0
  65. ragaudit/tune/strategies.py +197 -0
  66. ragaudit/yaml_io.py +199 -0
  67. ragaudit_cli-0.3.0.dist-info/METADATA +429 -0
  68. ragaudit_cli-0.3.0.dist-info/RECORD +71 -0
  69. ragaudit_cli-0.3.0.dist-info/WHEEL +4 -0
  70. ragaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
  71. 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"}