feat(fitness): objective ground-truth verifier for skill evolution (reference: arxiv) - #150
MaxFreedomPollard wants to merge 9 commits into
Conversation
…erence implementation
Phase 1 currently optimizes keyword overlap against the rubric text
(evolution/core/fitness.py), which rewards echoing the rubric's
vocabulary rather than being right. LLMJudge is imported but never
called, and even judge scoring never checks whether an answer is true.
That makes every reported improvement unfalsifiable.
This adds the missing signal: a pluggable Verifier interface that
grades outputs against checkable ground truth, plus a reference
verifier for the arxiv skill.
- evolution/core/verifier.py: Verifier ABC, registry, and a DSPy
metric adapter that returns floats for MIPROv2/holdout calls and
score-plus-feedback for GEPA's reflective form
- evolution/verifiers/arxiv_verifier.py: grades arXiv IDs, exact
titles, first authors, and submission years for 17 landmark papers.
Grading is pure Python (regex + normalization): zero API cost per
grade, and the same output always scores the same.
- evolve_skill.py: --fitness auto|verifier|keyword. Auto picks the
objective verifier when one is registered for the skill; the
verifier also supplies the eval dataset, since tasks and ground
truth must travel together.
Score shaping is monotone and anti-gaming: correct 1.0, hedging
across several candidates 0.75, confidently wrong 0.5, evasive or
empty 0.0. Conciseness only counts when an answer is present, so a
brief evasion can never outscore a verbose correct answer. Year
tasks are solvable purely from the arXiv ID's YYMM prefix, so they
measure whether the skill teaches the ID format, a property a skill
edit can actually flip.
Every embedded fact can be re-checked against the live arXiv API:
python -m evolution.verifiers.arxiv_verifier --validate
All 17 papers (51 facts) verified at commit time. No new
dependencies. 40 new tests, full suite passes (183).
…overrides a custom dataset Two review findings: - Title grading was too harsh on spacing variants: a response writing "Pretraining" for "Pre-training" or "LowRank" for "Low-Rank" lost full credit despite being right. A spaceless containment check now accepts fused variants while still requiring the exact letter sequence of the full title. - When a verifier is active, --eval-source and --dataset-path are ignored by design (verifier tasks carry their own ground truth). That was silent; now it prints a note pointing at --fitness keyword for evolving against a custom dataset.
|
Independent audit at
v = ArxivVerifier()
task = task_input_for(PAPER_BY_ID["1706.03762"], KIND_ID)
f = v.score(task, "The paper is arXiv 1706.03799.")
# f.correctness == 0.0, but f.composite == 0.5The 0.3 procedure + 0.2 conciseness weights exactly compensate for zero correctness. For an objective verifier, enforce
Shuffle and partition papers (group IDs), then sample task kinds within each assigned paper group. Add pairwise-disjoint group assertions.
Compatibility warning: a merge-tree simulation of #150 with #168 reports conflicts in Current branch tests do pass (184 passed), so dedicated tests for the three counterexamples above are necessary. |
|
I turned the reproduced correctness-floor and paper-split leakage fixes into a draft extension against your exact branch: MaxFreedomPollard#1. Full repository: 187/187 PASS; Ruff and diff-check PASS. Functional commit is |
…econd one The admission gate in the next commit needs an exact paired test, an interval that behaves at the boundaries, and an honest power number. All three already exist in NousResearch#162's evolution/core/stats.py, so this takes that file and its tests verbatim instead of growing a parallel implementation that would drift. Copied byte for byte on purpose. Git resolves an identical add on both sides without a conflict, so whichever of the two PRs lands second merges cleanly.
Grouping papers before sampling makes leakage structurally impossible, which is exactly why the invariant is cheap to check and worth checking: a later change to the sampling would otherwise reintroduce it in silence, and a holdout that shares papers with train reports a score the run has not earned.
Holdout admission was `if improvement > 0`. On six examples that is not a measurement. The best one-sided paired result short of a clean sweep, four losses from four, is p = 0.0625, and five aligned losses are needed to reach 0.05 at p = 0.03125, so nothing weaker than a near-unanimous flip can carry evidence and a small mean gain carries none. Admission is now declared and paired: the paired test must find a significant improvement at alpha, and must not find a significant regression. Where the verifier supplies ground truth the outcomes are binary and McNemar's exact test decides; otherwise the signed-rank test on the scores does. Both come from evolution/core/stats.py rather than a second implementation. Power is reported, not assumed. It does not gate, because a result that reached significance did so on evidence. What it does is qualify the non-regression claim: on six examples "no significant regression" means "no regression above 83.3%", and the run now says so instead of leaving the operator to work it out. Whether the deployable artifact changed is a separate gate this command does not check, so the verdict records it as unknown rather than as passed. metrics.json keeps the descriptive mean delta and adds the decision.
Reproductions rather than paraphrases: the same paper, the same wrong answer, the same num_cases and seed, the same win/loss pattern. Each fails on the code as it stood and passes now. The leakage test reads each task's paper out of the task text rather than asking the new grouping to confirm its own bookkeeping, so it still measures the thing it is meant to measure.
Keeps evolution/core/stats.py and its tests byte-identical to the copy in NousResearch#162, which is what lets the two branches merge without a conflict. The fix itself belongs to that module: at a boundary count the Wilson bound undershot by a floating-point step, so `contains(0.0)` was False for a sample of all misses at 43 of the first 200 sample sizes.
|
Thanks for the audit. All three admission defects are fixed, plus the disjointness assertion you prescribed. The compatibility point is not done, and I want to be explicit about why rather than let it look handled. Your draft went in as-is. Commits 2041d57 and 0e66121 are on the branch with your authorship intact, which closed MaxFreedomPollard#1 as merged. I reproduced both defects before taking it: the confidently wrong ID scored composite 0.5000 with correctness 0.0, and at num_cases=24, seed=13 the leakage was exactly your three sets, down to 1810.04805 appearing in all three splits. I also stress-tested the grouping past your tests, across seeds 1/7/13/99/2026 and num_cases 8/12/24/40/68, and found no leakage and exact split sizes. One thing I added on top. You prescribed pairwise-disjoint group assertions, and the draft had that as a test rather than an invariant, so build_dataset now raises on leakage directly. The grouping makes it structurally impossible, which is exactly why the check is cheap and worth keeping: a later change to the sampling would otherwise reintroduce it silently. Defect 3 reuses #162 rather than adding a second statistics implementation. evolution/core/stats.py and its tests are copied byte for byte from that branch, so whichever of the two lands second merges without a conflict. Admission is now declared and paired: the test must find a significant improvement at alpha and must not find a significant regression. Where the verifier supplies ground truth the outcomes are binary and mcnemar_exact decides, otherwise the signed-rank test on the scores does. Power is reported rather than gating, since a result that reached significance did so on evidence; what it does is qualify the non-regression claim, because min_detectable_paired_shift(6) is 5/6, so "no significant regression" on six examples means "no regression above 83.3%" and the run now says so. Your six-example case is a committed test: one win and five ties gives p=0.5 and is refused, while five aligned discordant pairs give p=0.03125 and are admitted. The mean-only improvement claim is gone. On the rebase onto #168, I did not do it, because with #168 unmerged and #150 based on main it does not stay a topology change. It takes this PR from 8 files and +981 to 19 files and +3815, and pulls six of your commits and +552/-49 of your unmerged work inside my PR. _compile_optimizer also arrives in 6073ab2, the last commit of #168, which depends on the earlier ones, so there is no narrow cherry-pick either. Your underlying point stands and I verified it rather than assuming it. A merge simulation still conflicts in evolve_skill.py, and the #150 side of that hunk does carry max_steps and the catch-all except Exception, so a mechanical resolution restores both. The resolution is to take the #168 side whole and change one argument to metric=metric_fn: I ran that combination and the full suite passes at 327. Nine hunks conflict, eight of them additive, and only the optimizer block is dangerous. Once #168 is in main this is a rebase and that one edit. Head 0156342, 312 tests passing, Ruff clean on every file added here. Each of the three counterexamples was checked to fail on the prior code by reverting the source and keeping the tests. |
The problem
Phase 1's gate is "at least one skill measurably improved." Right now that measurement can't be trusted, because the metric the optimizer maximizes (and the holdout is scored with) is keyword overlap against the rubric text:
This rewards echoing the rubric's vocabulary, not being right.
LLMJudgeexists but is dead code (imported inevolve_skill.py, never called), and even judge scoring is subjective: it never checks whether an answer is actually true. The Phase 1 validation report is honest about this and lists replacing the heuristic as its own next step.So every reported improvement so far is unfalsifiable. Nothing downstream (benchmark gates, significance checks like #135/#136, Phase 2's tool-selection accuracy) means anything until the number being optimized measures reality.
What this adds
A third, strongest tier of fitness signal: objective verifiers that grade outputs against checkable ground truth, plus a working reference implementation for the
arxivskill (the same skill the Phase 1 validation report used).evolution/core/verifier.pyVerifierABC: a verifier owns both halves of the evaluation contract,build_dataset()(tasks whose answers are verifiable facts) andscore()(grade an output against those facts). They must travel together, since the grader can only score tasks it knows the truth for.@register_verifier,get_verifier(skill_name)).verifier_metric()adapts a verifier to every metric calling convention in the pipeline: plain float for MIPROv2 and holdout comparison, and score-plus-feedback (dspy.Prediction(score=..., feedback=...)) when GEPA asks about a specific predictor. The verifier's feedback text ("expected ID 1706.03762, response said 1706.03799") is exactly the reflection signal GEPA mutates on.evolution/verifiers/arxiv_verifier.pyGrades four kinds of verifiable facts across 17 landmark ML papers (word2vec through the GEPA paper itself): arXiv IDs, exact titles, first authors, and submission years. Grading is regex plus normalization, no LLM calls.
Score shaping is monotone and anti-gaming:
Details that matter:
2005.14165doesn't get misread as the year 2005.The embedded facts are historical constants, and every one is re-checkable against the live arXiv API:
All 51 facts (17 papers x title/author/year) passed at commit time.
Wiring (
evolve_skill.py)New
--fitness auto|verifier|keywordflag, defaultauto: use the objective verifier when one is registered for the skill, otherwise fall back to current behavior. When a verifier is active it also supplies the eval dataset (deterministic sample, 50/25/25 split, saved todatasets/skills/<skill>-verifier/).Deliberate scope boundaries
--fitnessselector leaves room for an LLM-as-judge mode (as in fix(phase1): GEPA-compatible SkillModule + LLM-as-judge metric + constraint fixes #137) for skills where no objective verifier is possible.Test plan
tests/verifiers/test_arxiv_verifier.py,tests/core/test_verifier_registry.py): grading correctness for all four fact kinds, the anti-gaming behaviors, dataset determinism and gradability, metric adapter under all three calling conventions, edge cases (empty output, unknown task, versioned IDs).--validateflag.--validate(17/17 papers verified),--demo, andevolve_skill --dry-runin both auto-verifier and forced-keyword modes.No new dependencies (stdlib
urllib+xml.etreefor the validation fetch;click/richalready in use).