feat: add LLM-as-World candidate selection strategy#295
Conversation
Introduce LLMCandidateSelector that uses an LLM to evaluate and select candidates by comparing their texts, scores, and lineage side-by-side, rather than relying solely on absolute numeric scores. Supports "best" (single selection) and "pareto_front" (identify Pareto-optimal set, then sample) modes with graceful fallback to baseline selectors on LLM failure. - Add CandidateSelectionSignature for prompt rendering and output parsing - Add LLMCandidateSelector implementing CandidateSelector protocol - Register "llm" and "llm_pareto" string strategies in optimize_anything and api - Mode-aware default fallback (CurrentBest for best, Pareto for pareto_front)
Changed Files
|
LakshyAAAgrawal
left a comment
There was a problem hiding this comment.
Good implementation overall — clean design, robust fallback, solid test coverage. A few issues to address:
1. The LLM will just pick the highest score
The prompt includes Aggregate Score: 0.8000 for each candidate. Any LLM will overwhelmingly select the candidate with the highest numeric score, making this functionally equivalent to CurrentBestCandidateSelector. The value of LLM-as-judge is in evaluating qualitative differences that scores don't capture, but showing the scores gives the LLM the answer on a silver platter.
Consider hiding scores from the prompt (or at least de-emphasizing them: "scores are approximate and may not reflect true quality — focus on the text content and approach diversity").
2. pareto_front mode doesn't use actual Pareto dominance
The prompt asks the LLM to identify "Pareto-optimal" candidates, but the LLM only sees aggregate scores and objective scores — it has no access to per-example scores. Real Pareto dominance requires per-instance comparison (candidate A beats B on example 1, but B beats A on example 3). The LLM is doing "diverse subset selection," not Pareto identification. The naming "llm_pareto" could mislead users into thinking it replaces the real Pareto selector. Consider renaming to "llm_diverse" or documenting the distinction clearly.
3. output_extractor bare-integer regex fallback picks up score digits
numbers = re.findall(r"\b(\d+)\b", lm_out)If the LLM responds "Based on the scores of 0.8000, Candidate 2 is best", this finds ["0", "8000", "2"] and returns index 0 (from the "0" in "0.8000"), not index 2. The "Candidate N" pattern match above would catch this specific phrasing, but any response where score digits precede the candidate number and don't use the "Candidate N" pattern will silently return the wrong index.
Fix: either remove the bare-integer fallback entirely (the "Candidate N" pattern + comma-list + single-digit paths already cover well-formatted responses, and the fallback selector handles the rest), or filter out numbers that look like they're part of decimals (e.g., exclude matches adjacent to .).
4. _make_llm_selector uses reflection_lm before string→callable conversion
In optimize_anything.py, the selector is built at:
"llm": lambda: _make_llm_selector(config, rng, mode="best"),At this point config.reflection.reflection_lm may still be a string (e.g., "openai/gpt-5"). LLMCandidateSelector.__init__ handles this via LM(lm) if isinstance(lm, str), so it works — but later in optimize_anything, the same string is converted to an LM callable again:
config.reflection.reflection_lm = make_litellm_lm(config.reflection.reflection_lm)This creates two separate LM instances for the same model — one in the selector, one in the proposer. They don't share state (retry config, etc.). Consider either (a) building the selector after the string→callable conversion, or (b) passing the already-converted callable to the selector.
- De-emphasize scores in prompt so LLM focuses on text quality - Rename "pareto_front" mode to "diverse" (not actual Pareto dominance) - Remove bare-integer regex fallback that could extract score digits - Fix api.py to pass already-converted reflection_lm_callable
Summary
LLMCandidateSelectorthat uses an LLM to evaluate and select candidates by comparing their texts, scores, and lineage side-by-side — treating the LLM as the evolutionary "world" (selection pressure / world model)"best"(single selection) and"diverse"(identify diverse set, sample from it) modesCurrentBestCandidateSelectorfor best,ParetoCandidateSelectorfor diverse"llm"and"llm_diverse"as string strategies inoptimize_anythingandapiFiles changed
src/gepa/strategies/llm_candidate_selector.py(new) —CandidateSelectionSignature+LLMCandidateSelectortests/test_llm_candidate_selector.py(new) — 27 testssrc/gepa/optimize_anything.py— register new strategiessrc/gepa/api.py— register new strategiesEvaluation on HotpotQA
Single-component prompt optimization with GPT-4.1-mini (solver) and GPT-4.1 (reflection/selection). Aggregated over 3 random seeds, 2000 metric calls per run, 20 train / 20 val / 100 test examples.
paretocurrent_bestepsilon_greedytop_k_paretollmllm_diverseKey findings:
llm_diverseachieves the highest test accuracy (40.00%) with the lowest variance (0.0141), suggesting the LLM's qualitative diversity selection generalizes better than pure score-based strategiesllm(best mode) selector performs comparably toepsilon_greedy, whilellm_diverseoutperforms all baselines includingparetoTest plan
uv run pytest tests/test_llm_candidate_selector.py -v— 27 passeduv run pytest— 397 passed, 1 skippeduv run ruff check— clean on changed filesuv run pyright— 0 errors on changed files