Skip to content

fix(evolution): report skipped holdout as N/A + preserve GEPA candidate (t_ac619234) - #165

Open
RomanXSad wants to merge 9 commits into
NousResearch:mainfrom
RomanXSad:fix/holdout-skip-reporting-t_ac619234
Open

fix(evolution): report skipped holdout as N/A + preserve GEPA candidate (t_ac619234)#165
RomanXSad wants to merge 9 commits into
NousResearch:mainfrom
RomanXSad:fix/holdout-skip-reporting-t_ac619234

Conversation

@RomanXSad

Copy link
Copy Markdown

What

Fixes the fabricated zero-improvement signal when the holdout eval is skipped by the budget guard (remaining < HOLDOUT_SKIP_THRESHOLD_SECONDS). Verified 2026-08-02 on pipeline-verification (t_43f1f626 / t_a752e135): the skip path hardcoded 0.0 scores and overwrote GEPA's candidate with a byte-identical baseline copy (md5 0a959721 both), so the run surfaced as +0.000 (+0.0%) / "no improvement" — a placeholder, not a measurement (the metric is real: same skill text scored 0.423 on 2026-07-30).

Changes (t_ac619234)

evolution/skills/evolve_skill.py

  1. handle_holdout_skip() (new helper) — preserves GEPA's candidate in candidate_full before the baseline overwrite; returns placeholder scores + skipped: true result. The deployed artifact stays the baseline (byte-identical → has_diff=0), mirroring the 8b regression guard.
  2. build_metrics() (new helper) — when holdout_skipped, baseline_score / evolved_score / improvement are written as null (N/A), never 0.0; adds holdout_candidate_preserved.
  3. Results table renders Holdout Score: N/A | N/A | — and the final summary says "Holdout skipped — no score measured" instead of 0.000 / "did not improve".
  4. The save step writes evolved_candidate.md with GEPA's real candidate alongside the baseline-copy evolved_skill.md, so a later budget-raised re-run can diff/reuse it.

tests/skills/test_holdout_skip_reporting.py (new) — 3 tests pinning: candidate survives the skip, metrics are null when skipped (incl. JSON round-trip), non-skipped runs keep real float scores.

Verification

  • pytest tests/skills/test_holdout_skip_reporting.py -v3 passed
  • pytest tests/skills/ → 193 passed; the 10 failures are pre-existing (untracked skill-artifact tests + a timing-flaky 0.15s-budget race test that fails identically without this change — A/B verified)
  • bash -n clean on the companion cron-evolve.sh change (separate dotfiles repo, not in this PR)

Companion change (local dotfiles repo ~/.hermes/scripts/cron-evolve.sh)

Section 5a now reads holdout_skipped from metrics.json, forces scores to N/A, gates the PR branch (HOLDOUT_SKIPPED != 1), and emits an explicit "holdout skipped (budget guard)" verdict + ticket note — a skipped holdout can never be misread as "no improvement". Dry-run harness: cron-holdout-dryrun.sh (all checks pass, incl. adversarial HAS_DIFF=1 + skipped → no PR).

Valera and others added 9 commits June 9, 2026 15:43
…t_316c92c4)

Root cause (t_3832d98c): holdout eval (24x3x2=144 LLM calls, last phase)
with no per-call timeout and no checkpointing blew the gtimeout-480 budget
3x. This commit:
- make_lm(): dspy.LM now gets timeout=60 (litellm kwarg) + num_retries=2,
  so a stalled API call raises quickly instead of hanging 600s+ (litellm
  default) and getting SIGKILLed with no output.
- evaluate_holdout(): refactored holdout loop with in-process wall-clock
  budget (--max-budget, default 480, passed the remaining time) -> returns
  a PARTIAL result (budget_exceeded=True) instead of being killed.
- Persistent score cache output/<skill>/holdout_scores.json keyed by
  (eval model, skill-body hash, program, example, sample): warm runs skip
  the 144-call holdout phase entirely (checkpointing for the 480s budget).
- metrics.json gains holdout_complete/examples/cache_hits/calls_made.
- New regression tests (7) in tests/skills/test_timeout_regression.py.

Note: this file also carries in-flight harness edits from prior fix tasks
(dataset load_or_generate, seed, GEPA 5-arg metric) that were uncommitted.
…handling (t_47e91803)

Constraints FAILED (esc-20260731-201857-1474): growth_limit hard-rejected a
genuinely better candidate (+71.3% size, +0.035 valset) against the 50% soft
cap. _check_growth now accepts a measured improvement and waives the soft cap
up to a new hard cap (max_prompt_growth_hard=1.0) when the delta clears
growth_waiver_min_improvement=0.03. evolve_skill.py measures valset
improvement only when the growth gate trips (12-example cap, median
aggregation) and re-validates with it.

error 1: EMFILE (OSError 24) crashes at artifact writes and the holdout eval
surfaced as bare exit 1, mislabeled 'LLM provider issue' by cron-evolve.sh.
New evolution/core/errors.py defines the exit-code contract (1 generic,
2 EMFILE, 3 eval error); writes go through write_text_guarded() (exit 2 with
ulimit hint on EMFILE) and the holdout phase classifies exceptions. Baseline
validation now checks the full artifact (skill['raw']), fixing the
body-vs-full asymmetry at evolve_skill.py:133.

Tests: growth boundary (exact 50%), 0-char baseline, negative growth, waiver
grant/deny/hard-cap, validate_all end-to-end; classifier tests for the
EMFILE path; guard tests for write_text_guarded and holdout helpers.
…erruns (t_316c92c4)

Verified failure: first verification run under the exact cron invocation
exited 124 — cold-cache GEPA consumed 439s of the 480s budget (141 rollouts
x 4.14s after the skill-text edit invalidated ~/.dspy_cache), leaving the
holdout phase 71s and the process SIGKILLed mid-call. GEPA's iteration loop
is not hookable, so:

- start_budget_watchdog(): daemon exits max_budget - 10s with exit 124
  (gtimeout-compatible -> cron-evolve.sh auto-retries) after writing a
  budget_checkpoint.json (phase, elapsed, timed_out). ~/.dspy_cache makes
  the retry warm/fast instead of a zero-output SIGKILL.
- Holdout skip: if < 60s of budget remains, skip the holdout eval, keep the
  baseline skill text, and finish cleanly with holdout_skipped=true in
  metrics.json (no improvement claim) instead of dying at 480s.
- Holdout headroom: holdout budget = remaining - 30s reserve so metrics and
  the report always have time to write after the phase.
- Checkpoint updated at phase boundaries (gepa / validate / holdout).
- Regression tests: exit-code 124 compatibility, watchdog/holdout layering,
  checkpoint persistence (10 tests total in test_timeout_regression.py).
…or, t_63240783)

Growth gate compared the evolved FULL artifact (frontmatter+body) against the
baseline BODY only, inflating the ratio (gmail-monitor +100.5% vs +69.8%
body-vs-body, hard cap +100%) and rejecting a real +0.045 valset improvement
by 0.5%. Add growth_text param to validate_all/_check_growth so the gate
compares evolved body vs baseline body in both the initial check and the
waiver re-validation.

Failure path now also writes a timestamped dir (baseline_skill.md +
evolved_FAILED.md + metrics.json) so cron-evolve.sh can diff the CURRENT
run's artifacts instead of falling back to a stale identical pair (has_diff=0).
…s (t_63240783)

- Waiver re-validation now also compares body-vs-body (growth_text=evolved_body);
  previously only the initial check did, so the waiver path still used the
  frontmatter-inflated ratio (+82.6% vs +51.8%).
- Failure path writes a timestamped dir (baseline + evolved_FAILED + metrics)
  so cron diffs the current run's artifacts, not a stale identical pair.
- Fresh dspy.context per holdout iteration (Python >=3.14 one-shot
  _GeneratorContextManager) — pre-existing working-tree fix, required for
  the holdout eval to run at all.
…xceeded (t_f8e3ec7f)

The waiver-failure message always said 'improvement < waiver threshold'
even when the waiver WAS applied (improvement >= threshold) and the real
cause was the +100% hard-cap breach (gmail-monitor: +0.065 improvement,
+115.6% growth was reported as '0.065 < 0.030'). Message-only fix — no
threshold semantics changed. Regression test added.
…te (t_ac619234)

The budget-guard holdout skip (remaining < 60s) used to hardcode 0.0 scores
and overwrite GEPA's candidate with a byte-identical baseline copy, so a
skipped holdout surfaced as a fabricated '+0.000 (+0.0%)' / 'no improvement'
verdict (pipeline-verification 2026-08-02).

- handle_holdout_skip(): preserves candidate_full before the baseline
  overwrite; returns placeholder scores + skipped holdout_result
- build_metrics(): baseline_score/evolved_score/improvement are NULL when
  holdout_skipped (N/A downstream), never 0.0; adds holdout_candidate_preserved
- results table + final summary render 'N/A' / 'holdout skipped' instead of
  0.000 when the holdout was skipped
- evolved_candidate.md is written alongside the baseline-copy evolved_skill.md
  so a later budget-raised re-run can diff/reuse GEPA's candidate
- new tests: tests/skills/test_holdout_skip_reporting.py (3 tests, all pass)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants