hill-climb
Runs a global hill-climb optimization loop where the parent is always the current best candidate and the val significance gate decides acceptance. Use as the algorithm for most runs — the first run on a new project, binary pass/fail scorers, and small task sets. Pick how each ite
Install
npx skills add https://github.com/skillberry-ai/cap-evolve/tree/main/skills/algorithms/hill-climb
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install skillberry-ai-cap-evolve@llmmart
git clone https://github.com/skillberry-ai/cap-evolve.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole skillberry-ai/cap-evolve collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
hill-climb — one loop, three focus schedules
Greedy search over candidates: the parent is always the run's current best, and a
child replaces it only by clearing the val significance gate. The test split is
never touched here — that is finalize.
Requires baseline first. Without --resume the loop reads the seed's val
result from <run-dir>/baseline.json (scripts/run.py:121-122); with no run state
it raises FileNotFoundError: no run state at .../state.json. --resume instead
reads the current best's val from its stored rollouts, falling back to
baseline.json when the run has no best yet (run.py:118-122).
One iteration, end to end
This is the mechanism the other algorithm skills vary; they describe only their
differences and point back here. One iteration is harness.run_step
(core/cap_evolve/harness.py:1409):
- Pick the parent — always the current best (
harness.py:2224,run_dir.candidate_dir(run_dir.best_id)). Copy it towork/<cand_id>/; the optimizer edits that copy in place, so the parent is never mutated (harness.py:1454-1458). - Build the prompt. The parent's val per-task rows are split into
always-failing / flaky / infra-errored / solid (
harness.py:1775-1795), rendered as the failure index plus an explicit protect these passing ids block (harness.py:1803-1879), and substituted into the project's optimizer-instructions template.--focusnarrows which failures are emphasized; nothing else changes. - Inject context and memory. Full trajectories, capability guidance, and the four
cross-iteration files land in the workdir (
harness.py:1062-1094) — seereferences/run-step.md. - Optimize. The optimizer command mutates the workdir. A crash is caught, logged,
and left as an unchanged copy of the parent, so the gate simply rejects it — a
wasted iteration, not a dead run (
harness.py:1486-1500). - Evaluate on val only (
harness.py:1516), at--n-trialstrials per task. - Gate. With per-task data on both sides the paired test is chosen automatically:
accept iff mean per-task Δ >
k·SE of those paired deltas (harness.py:1524-1532).--no-regressionadds a second, harder condition on top. - Commit. Every candidate is snapshotted — accepted and rejected — so any
iteration can be diffed (
harness.py:1557); the version store commits it (harness.py:1609-1612). Only an accepted candidate callsset_bestand becomes the next parent (harness.py:1558-1559); a rejected one is filed in the rejected memory that feeds the next prompt (harness.py:1607-1608).
Why the parent is always the current best. The gate already guarantees every
accepted candidate is a real improvement on val, so the best candidate is the only
one with evidence behind it — forking anything else spends budget on a lineage
already known to be worse. The cost is that a candidate which trades one task class
for another can never be kept as a specialist; wanting that is the reason to use
gepa, whose per-instance Pareto frontier keeps specialists on purpose.
Why the bar is Δ > k·SE, not Δ > 0. Rewards are estimates from a finite sample
of tasks and trials, so about half of all no-op edits measure as a small positive
Δ by chance. Accepting on Δ > 0 therefore ratchets on noise: val creeps up, the
sealed test does not move, and the run reports a gain that was never there. The bar
is the noise scale itself, so a win has to be larger than the measurement error that
produced it. phases/gate owns the full statement of the decision and its modes.
Focus schedules
--focus |
what each iteration emphasizes | when to use |
|---|---|---|
all (default) |
every failing val task — find the one edit that lifts the most | broad capability gaps; the usual choice |
cyclic |
one val task at a time, round-robin | many distinct, unrelated failure modes |
hardest-first |
val tasks ordered by the parent's per-task reward ascending, then cycling | a few very hard tasks dominate the gap |
All three index the val per-task results, because those are the only per-task
data the loop holds. Non-regression protection covers the whole val split under
every schedule, not just the focused task. hardest-first costs no extra evaluation:
it orders off the per-task rewards already in hand.
Back-compat: --focus all-at-once is accepted and treated as all (run.py:35).
Key flags
Beyond --run-dir / --project / --optimizer / --focus / --max-iterations /
--n-trials / --resume (scripts/run.py):
--gate-mode(defaultauto) +--k-se(default1.0) —autolets the engine pick the paired gate;significant|paired|strict|thresholdpin it. Raisingk-semakes acceptance stricter.--protected-paths— globs sealing the eval surface (scorer, gold, tasks, splits;defaultexpands to the built-in set). A candidate that edits one is indecisive, never scored: the measurement would grade a compromised harness, so no reward is recorded, the stall counter is untouched, and best is unchanged (harness.py:1440-1446). Leave this on for any run whose number you intend to quote.--capabilities— comma-separated capability skills under optimization. When empty the optimizer receives no allowed-edit-space block at all (harness.py:1735-1737), so it guesses the edit surface from the files.--no-regression— reject a candidate that lowers any val task the parent scored higher on, even when the mean improves.--convergence— graded plateau signal (ok→warn→paradigm_shift→stop) appended to the prompt, so a plateau escalates the ask instead of burning the remaining iterations on more of what failed.--workers N— concurrent rollouts per evaluation. Only safe when the adapter'srun_targetis thread-safe; a shared client, temp path, or cwd will corrupt scores rather than fail loudly.--store git|copy|command(+--store-commit-cmd) — how each iteration is versioned; git is the default and every candidate becomes a commit.--instructions-file,--bench-repo,--capability-sources,--optimizer-name,--target-model,--target-profile-file— prompt and read-context wiring;cap-evolve runfills these from the spec.
Standalone use
python scripts/run.py --run-dir .capevolve/run_X --project .capevolve/project \
--optimizer 'python .../run-optimizer/scripts/run.py --name mock --workdir {workdir} --prompt {prompt}' \
--focus hardest-first --max-iterations 10 --n-trials 4 --protected-paths default
Known gate edge case (open, issue #351)
Under the default paired gate with k_se = 1.0, a candidate that improves exactly
one val task and changes nothing else has Δ̄ == SE(Δ) algebraically, so a strict
> resolves it on floating-point representation alone. Expect a genuine one-task
gain not to bank, unpredictably by split size. Do not lower k-se to work around it —
that disables the bar for every candidate; prefer edits that generalize across a
class of tasks, which is what the loop is asking for anyway.
Agent mode
When orchestration_mode: agent, drive the loop yourself with the same mechanism as
above: parent = current best, one edit per iteration, evaluate on val, gate,
accept → snapshot / reject → revert, seal once with the finalize phase script
(skills/phases/finalize/scripts/run.py), then report. orchestrate owns the
agent-mode rules; the hill-climb-specific obligation
is that you must reproduce the handover surface run_step normally builds —
LEDGER.md, JOURNAL.md, PROCESS.md, RUNMAP.md + prior_iterations/ — and carry
rejected edits into the next iteration's prompt. Skip it and the dashboard goes dark
and the optimizer re-proposes edits already refuted.
References
references/run-step.md— the shared step's exact contract: the handover files and their ownership, the rejected/accepted memory, the version store, the snapshot filter, and the tamper path. Load it when you need the contract verbatim, or when writing an algorithm that reusesrun_step. The sibling algorithm skills link this file rather than this body.references/focus-schedules.md— how each schedule builds its focus set. Load when choosing between the three or debugging a focus set.
Files (cap-evolve)
-
references
-
focus-schedules.md 2.4 KB
# Focus schedules All three schedules share the same loop body in `harness.hill_climb_loop`; they differ only in how the per-iteration *focus set* is chosen. The focus set drives `_focus_instructions`, which builds the optimizer prompt from the parent's failing **val** tasks (actionable failures separated from infrastructure errors via the structured `raw.errored` flag, not feedback substring matching). The focus set is always drawn from **val ids** (`harness.py:2183`). That is not a preference: `_focus_instructions` filters the parent's val per-task rows (`harness.py:2011-2013`), and the splits are disjoint slices of one shuffled id list (`splits.py:117-119`), so a focus set of train ids would intersect those rows in nothing and the prompt would render `0 failing of 0 tasks` with no failure index at all. Train tasks are never individually scored by this loop, so there is no per-task signal about them to focus on. ## all (default) - Focus set = every val task (no filtering). - The prompt asks the optimizer to find the single edit that lifts the most tasks. - Best when the capability has broad gaps rather than a few isolated ones. ## cyclic - Iteration `i` focuses on `val[i % len(val)]` — one task at a time, round-robin. - Useful when failures are heterogeneous: forcing attention onto each task in turn prevents the optimizer from over-fitting to whichever failure is loudest. ## hardest-first - Val ids are sorted by the parent's per-task reward ascending (hardest first) once, at loop entry, from the `current_val` the loop already holds — no extra evaluation. - Iteration `i` focuses on the `i`-th hardest task, then cycles. - Useful when a small number of very hard tasks dominate the val gap and you want budget spent there first. - The order is fixed at entry, so it reflects the parent at that moment, not the running best. Restart the loop (or `--resume`) to re-rank. ## Non-regression under a narrow focus The *protect these passing tasks* block is built from the whole val split, not the focus set (`harness.py:2016-2020`). An edit aimed at one task must still not break a passing task outside the focus, so narrowing attention must never narrow the constraint. ## Parent selection (all schedules) The parent is always the current best candidate — a strict global hill-climb. A per-task Pareto frontier that keeps specialists is a *different* algorithm (the `gepa` skill), not a focus mode here. -
run-step.md 4.7 KB
# The shared iteration step (`harness.run_step`) The contract every algorithm in this repo reuses for one propose → gate → commit iteration (`core/cap_evolve/harness.py:1409`). `hill-climb/SKILL.md` states the mechanism at the level needed to run it; this file is the verbatim contract, for writing or debugging an algorithm that calls `run_step` directly. `gepa`, `skillopt`, `agent-optimize` and `evograph` differ only in **which parent they pick** and **which tasks they focus** — not in anything below. ## Signature and what varies ``` run_step(adapter, *, run_dir, parent_dir, optimizer, instructions, current_val, n_trials=1, gate_kwargs=None, candidate_id=None, parent_id=None, no_regression=False, rejected=None, history=None, store=None, capabilities=None, eval_split="val", optimizer_name=None, capability_sources=None, project_dir=None, protected_patterns=None) -> dict ``` `parent_dir` is the algorithm's parent-selection decision — the only place a different search strategy enters. hill-climb passes `candidate_dir(best_id)`; gepa passes a candidate sampled from its per-instance Pareto frontier. `instructions` is the algorithm's focus decision. Everything else is fixed machinery. The returned dict carries `candidate_id`, `accepted`, `decision`, `candidate_val`, `parent_val`, `regressions`, the optimizer's seconds/usd/tokens, `optimizer_error`, and `workdir` (`harness.py:1614-1626`). ## Cross-iteration files, and who owns each Written into the workdir before the optimizer runs, with a prompt pointer to all of them (`harness.py:1062-1094`): | file | owner | lifetime | |---|---|---| | `LEDGER.md` | framework, read-only to the optimizer | regenerated each iteration; every prior outcome plus the exact tasks it broke/fixed | | `JOURNAL.md` | the optimizer, append-only | run-level handover; earlier entries must not be edited | | `PROCESS.md` | the optimizer, required | fresh each iteration; **snapshotted with the candidate** and surfaced per-iteration by the dashboard | | `RUNMAP.md` + `prior_iterations/<id>/` | framework | manifest plus every prior iteration's `PROCESS.md` and capability diff | | `INSIGHTS.md` / `META_INSIGHTS.md` / `FRAMEWORK_IMPROVEMENTS.md` | the optimizer, append-only, optional most iterations | run-level, distilled: verified findings / process meta-learning / cross-run framework feedback — a summary layer above `JOURNAL.md` | `_reconcile_journal` folds the optimizer's appended entry into the run-level journal for accepted *and* rejected iterations, and reuses it as the candidate's lineage note (`harness.py:1586-1588`). A genuinely empty handover is escalated (logged, and stamped into the journal itself), not silently dropped. `_fold_accumulator` does the same for the three summary files, minus the escalation — those are legitimately empty most iterations. ## Memory across iterations `_init_memory_store` (`harness.py:1631-1646`) creates `RejectedMemory` and `History` and initializes the version store (git by default, with a `seed` commit on a fresh run only). `_augment_instructions` injects both into every prompt, so the optimizer sees what was already refuted and what already worked. A **rejected** step records the candidate, the gate's reason, and the per-task broke/fixed impact (`harness.py:1607-1608`). An **indecisive** step — tamper, or coverage/infra void — is deliberately *not* recorded as a rejection (`harness.py:1598-1605`): the edit was never evaluated, so filing it would teach the optimizer to avoid a change nothing is known about. It also leaves the stall counter untouched (`harness.py:1560-1564`). ## Snapshot and store Every candidate is snapshotted, accepted or not, so any iteration can be diffed (`harness.py:1557`). `_SNAPSHOT_IGNORE` (`harness.py:1842-1845`) excludes injected read-context — `trajectories/`, `guidance/`, `prior_iterations/`, `LEDGER.md`, `JOURNAL.md`, `RUNMAP.md`, and the per-agent skill dirs / always-on instruction files — so stored candidates stay capability-only and diffs show the real edit. `PROCESS.md` is deliberately kept. The store then commits the iteration, tagging `best` on accept (`harness.py:1609-1612`). ## Protected paths → indecisive, never zero With `protected_patterns` set, the protected files are hashed *after* context injection (so the framework's own scratch never reads as tampering), marked read-only, and re-verified **before any rollout is paid for** (`harness.py:1467-1514`). On tamper the step is indecisive: `candidate_val` is None, no reward is recorded, the stall counter is untouched, and best is unchanged. Scoring such a candidate 0.0 would be wrong in the other direction — the number would describe a compromised harness, not the edit.
-
-
scripts
-
abstract.py 708 B
"""hill-climb has no per-skill abstract methods beyond the project adapter. It composes the contract methods (tasks/run_target/score/materialize) via the shared harness; the optimizer skill supplies the proposer and the capability skill owns the editable surface. Nothing here needs filling, so ``check.py`` verifies the loop wiring + the focus schedule rather than implementations. """ from __future__ import annotations from pathlib import Path # A focus schedule is the only "policy" this algorithm carries; the default is # "all" (propose against every failing val task each iteration). DEFAULT_POLICY = {"focus": "all"} def materialize(capability_dir: Path) -> dict: # noqa: ARG001 return {} -
check.py 2.2 KB
"""Contract: hill-climb wires to the shared loop and resolves every focus schedule (including the legacy skill names) to a valid focus. """ from __future__ import annotations import sys import _bootstrap # noqa: F401 from cap_evolve.skillcheck import Checker, import_run def main() -> int: c = Checker("hill-climb") run = import_run() c.require_main(run) from cap_evolve import harness c.check(hasattr(harness, "hill_climb_loop"), "core harness missing hill_climb_loop") c.check(set(run.FOCUS_CHOICES) == {"all", "cyclic", "hardest-first"}, f"unexpected focus choices: {run.FOCUS_CHOICES}", note=f"focus schedules: {run.FOCUS_CHOICES}") # Back-compat: the three old skill names must translate to a valid focus. for legacy in ("all-at-once", "cyclic", "hardest-first"): mapped = run._LEGACY_FOCUS.get(legacy, legacy) c.check(mapped in run.FOCUS_CHOICES, f"legacy name {legacy!r} does not map to a valid focus") c.note("legacy all-at-once/cyclic/hardest-first translate to --focus") # Behavioural: a narrow focus must render the focused task's FAILURES, not an # empty prompt. Comparing focus NAMES cannot see that — the schedule set stayed # correct the whole time the narrow schedules were shipping empty prompts. from cap_evolve.loop import SplitResult per = [{"task_id": "v1", "reward": 1.0, "feedback": "passed", "raw": {}}, {"task_id": "v2", "reward": 0.0, "feedback": "wrong result", "raw": {}}] current_val = SplitResult(split="val", reward=0.5, stderr=0.5, per_task=per, n_tasks=2, n_scored=2) rendered = harness._focus_instructions(current_val, ["v2"], "task v2") c.check("## (a)" in rendered, "a narrow focus renders no failure index — the focus set is not indexing " "the parent's val per-task rows", note="narrow focus renders the focused task's failures") c.check("## Currently PASSING" in rendered, "a narrow focus drops the protect-these-passing-tasks block", note="non-regression protection survives a narrow focus") return c.emit() if __name__ == "__main__": sys.exit(main()) -
run.py 5.4 KB
"""hill-climb — global hill-climb on the val gate, with a selectable focus schedule. ``--focus`` selects which of the parent's failing VAL tasks each iteration's reflection emphasizes (val is the only per-task data the loop holds): all every failing val task each iteration (default) cyclic one val task at a time, cycling through them hardest-first val tasks ordered by the parent's per-task reward ascending The parent is always the current best (global hill-climb); honesty (val-only gate, sealed test) lives in core. This is a thin wrapper over ``harness.hill_climb_loop(focus=...)``. """ from __future__ import annotations import argparse import json import shlex import sys from pathlib import Path import _bootstrap # noqa: F401 from cap_evolve import RunDir, harness from cap_evolve.store import make_store from cap_evolve.check import load_adapter from cap_evolve.loop import SplitResult FOCUS_CHOICES = ("all", "cyclic", "hardest-first") ALGO = "hill-climb" # Back-compat: accept the old skill names as --focus values and translate. _LEGACY_FOCUS = {"all-at-once": "all", "cyclic": "cyclic", "hardest-first": "hardest-first"} def main(argv=None) -> int: p = argparse.ArgumentParser(prog=ALGO) p.add_argument("--run-dir", required=True) p.add_argument("--project", required=True) p.add_argument("--optimizer", required=True, help="optimizer cmd with {workdir} {prompt}") p.add_argument("--focus", default="all", help="schedule: all | cyclic | hardest-first (old skill names accepted)") p.add_argument("--max-iterations", type=int, default=10) p.add_argument("--n-trials", type=int, default=1) p.add_argument("--workers", type=int, default=1, help="concurrent rollouts per evaluation (1 = serial, the default). " "Only safe when the adapter's run_target is thread-safe.") p.add_argument("--gate-mode", default="auto", help="auto = let the engine pick the paired gate (recommended; candidate & current share val tasks); or significant|paired|strict|threshold") p.add_argument("--k-se", type=float, default=1.0) p.add_argument("--store", default="git", help="git|copy|command") p.add_argument("--store-commit-cmd", default=None) p.add_argument("--no-regression", action="store_true", help="reject candidates that break a passing val task") p.add_argument("--resume", action="store_true", help="continue from the run's current best candidate (read its val " "from rollouts) instead of baseline") # The shared optimizer read-context flags (one declaration for every algorithm). harness.OptimizerContext.add_arguments(p) p.add_argument("--protected-paths", default="", help="comma-separated globs sealing the eval surface (scorer/gold/tasks/" "tests). 'default' expands to the built-in set. Empty = off. A " "candidate that edits one is INDECISIVE, not scored 0.0.") p.add_argument("--convergence", action="store_true", help="graded plateau signal (warn -> paradigm shift -> stop) injected " "into the optimizer prompt; off by default") args = p.parse_args(argv) focus = _LEGACY_FOCUS.get(args.focus, args.focus) if focus not in FOCUS_CHOICES: print(json.dumps({"error": f"unknown --focus {args.focus!r}; choose from {FOCUS_CHOICES}"})) return 2 run_dir = RunDir.open(Path(args.run_dir)) # Process-wide rollout concurrency for every evaluation this algorithm runs. harness.DEFAULT_WORKERS = max(1, args.workers) try: from capevolve_telemetry import load_observers_from_state for obs in load_observers_from_state(run_dir.load_observer_state()): run_dir.add_observer(obs) except Exception: # noqa: BLE001 pass # The optimizer read-context (capability skills, template, sources, bench repo, # optimizer features ref, consuming-LLM brief). Also logs the resolved profile. ctx = harness.OptimizerContext.from_args(args, run_dir=run_dir) if harness.DEFAULT_WORKERS > 1: run_dir.log_event("parallel", workers=harness.DEFAULT_WORKERS, algorithm=ALGO) store = make_store({"store": args.store, "store_commit_cmd": args.store_commit_cmd}, run_dir.root) adapter = load_adapter(Path(args.project)) optimizer = harness.optimizer_from_command(shlex.split(args.optimizer)) if args.resume and run_dir.best_id: current_val = harness.split_result_from_rollouts(run_dir, run_dir.best_id, "val") else: current_val = SplitResult.from_dict( json.loads((run_dir.root / "baseline.json").read_text())["val"]) result = harness.hill_climb_loop( adapter, run_dir=run_dir, optimizer=optimizer, current_val=current_val, focus=focus, max_iterations=args.max_iterations, n_trials=args.n_trials, gate_kwargs=({"k_se": args.k_se} if args.gate_mode == "auto" else {"mode": args.gate_mode, "k_se": args.k_se}), algorithm=f"{ALGO}:{focus}", no_regression=args.no_regression, store=store, ctx=ctx, protected_patterns=harness.parse_protected_paths(args.protected_paths), convergence=args.convergence, ) run_dir.close_observers() print(json.dumps(result, indent=2)) return 0 if __name__ == "__main__": sys.exit(main()) -
_bootstrap.py 3.6 KB
"""Thin shim: locate cap_evolve, then defer to cap_evolve._bootstrap. Skill scripts ``import _bootstrap`` first. The real path-resolution logic lives ONCE in ``cap_evolve._bootstrap`` (so it can't drift across skills); this shim only has to find that package, which means a minimal upward walk for ``core/`` — the single bit of bootstrapping that genuinely must run before cap_evolve is importable. Everything else delegates. """ from __future__ import annotations import os import sys from pathlib import Path def _seed_path() -> None: """Minimal: put a dir containing the cap_evolve package on sys.path. ``CAPEVOLVE_CORE`` is honoured BEFORE any ambient import. An editable install of a *different* cap-evolve checkout registers a ``sys.meta_path`` finder, which outranks both ``sys.path`` and ``PYTHONPATH`` — so "cap_evolve imports fine" is not evidence that it imports the checkout you are standing in. Deferring to the ambient package here made an explicit override unreachable, and the symptom was a stale core silently answering for this one (``ModuleNotFoundError: cap_evolve.constraints`` from a checkout that predates that module). An explicit env var wins. """ env = os.environ.get("CAPEVOLVE_CORE") want = Path(env).resolve() if env else None if want and (want / "cap_evolve" / "__init__.py").exists(): loaded = sys.modules.get("cap_evolve") already = getattr(loaded, "__file__", None) if already and Path(already).resolve().parent.parent == want: return # right checkout already imported: touch nothing p = str(want) if p in sys.path: sys.path.remove(p) sys.path.insert(0, p) if loaded is not None: # Evicting a module makes a re-import yield a DIFFERENT object, so anything # already holding a reference fails an `is` check. Only ever do it when the # loaded package really is the wrong checkout — otherwise this "fix" becomes # the bug (it broke two identity assertions in core/tests exactly once). for name in [m for m in sys.modules if m == "cap_evolve" or m.startswith("cap_evolve.")]: sys.modules.pop(name, None) for finder in list(sys.meta_path): if "cap_evolve" in getattr(finder, "MAPPING", {}): sys.meta_path.remove(finder) return # A checkout's own core outranks an ambient install. Without this, a skill script run # from checkout X silently executed against checkout Y's cap_evolve (an editable install # registers a sys.meta_path finder, which outranks sys.path), and the only symptom was # missing modules — or, worse, a green result measured against the wrong tree. here = Path(__file__).resolve() own = next((p / "core" for p in here.parents if (p / "core" / "cap_evolve" / "__init__.py").exists()), None) if own is not None: os.environ.setdefault("CAPEVOLVE_CORE", str(own)) return _seed_path() try: import cap_evolve # noqa: F401 return except Exception: pass cands = [] for parent in here.parents: cands.append(parent / "core") cands.append(parent) for c in cands: if (c / "cap_evolve" / "__init__.py").exists(): p = str(c) if p not in sys.path: sys.path.insert(0, p) return _seed_path() from cap_evolve._bootstrap import ensure_core # noqa: E402 # Anchor the upward walk at THIS skill script's location (not the core module's). ensure_core(Path(__file__).resolve())
-
-
meta.yaml 396 B
component: algorithm name: hill-climb summary: Global hill-climb on the val gate with a selectable focus schedule (--focus all|cyclic|hardest-first) over the val tasks; parent is always the current best. entry: scripts/run.py abstract: scripts/abstract.py check: scripts/check.py needs: [scores, traces, candidate] provides: [candidate] compatible_with: capabilities: ["*"] optimizers: ["*"] -
SKILL.md 9 KB
--- name: hill-climb description: Runs a global hill-climb optimization loop where the parent is always the current best candidate and the val significance gate decides acceptance. Use as the algorithm for most runs — the first run on a new project, binary pass/fail scorers, and small task sets. Pick how each iteration's reflection is focused with --focus all (every failing val task), cyclic (one task at a time), or hardest-first (lowest-scoring first). Switch to gepa when rollouts are expensive and per-task feedback is rich, or skillopt when you want an annealed edit budget. component: algorithm argument-hint: "--run-dir DIR --project DIR --optimizer CMD [--focus all|cyclic|hardest-first]" allowed-tools: Read, Write, Bash provides: [candidate] needs: [scores, traces, candidate] --- # hill-climb — one loop, three focus schedules Greedy search over candidates: the parent is always the run's current best, and a child replaces it only by clearing the val significance gate. The test split is never touched here — that is `finalize`. **Requires `baseline` first.** Without `--resume` the loop reads the seed's val result from `<run-dir>/baseline.json` (`scripts/run.py:121-122`); with no run state it raises `FileNotFoundError: no run state at .../state.json`. `--resume` instead reads the current best's val from its stored rollouts, falling back to `baseline.json` when the run has no best yet (`run.py:118-122`). ## One iteration, end to end This is the mechanism the other algorithm skills vary; they describe only their differences and point back here. One iteration is `harness.run_step` (`core/cap_evolve/harness.py:1409`): 1. **Pick the parent — always the current best** (`harness.py:2224`, `run_dir.candidate_dir(run_dir.best_id)`). Copy it to `work/<cand_id>/`; the optimizer edits that copy in place, so the parent is never mutated (`harness.py:1454-1458`). 2. **Build the prompt.** The parent's val per-task rows are split into always-failing / flaky / infra-errored / solid (`harness.py:1775-1795`), rendered as the failure index plus an explicit *protect these passing ids* block (`harness.py:1803-1879`), and substituted into the project's optimizer-instructions template. `--focus` narrows which failures are emphasized; nothing else changes. 3. **Inject context and memory.** Full trajectories, capability guidance, and the four cross-iteration files land in the workdir (`harness.py:1062-1094`) — see `references/run-step.md`. 4. **Optimize.** The optimizer command mutates the workdir. A crash is caught, logged, and left as an unchanged copy of the parent, so the gate simply rejects it — a wasted iteration, not a dead run (`harness.py:1486-1500`). 5. **Evaluate on val only** (`harness.py:1516`), at `--n-trials` trials per task. 6. **Gate.** With per-task data on both sides the paired test is chosen automatically: accept iff mean per-task Δ > `k`·SE of those paired deltas (`harness.py:1524-1532`). `--no-regression` adds a second, harder condition on top. 7. **Commit.** *Every* candidate is snapshotted — accepted and rejected — so any iteration can be diffed (`harness.py:1557`); the version store commits it (`harness.py:1609-1612`). Only an accepted candidate calls `set_best` and becomes the next parent (`harness.py:1558-1559`); a rejected one is filed in the rejected memory that feeds the next prompt (`harness.py:1607-1608`). **Why the parent is always the current best.** The gate already guarantees every accepted candidate is a real improvement on val, so the best candidate is the only one with evidence behind it — forking anything else spends budget on a lineage already known to be worse. The cost is that a candidate which trades one task class for another can never be kept as a specialist; wanting that is the reason to use `gepa`, whose per-instance Pareto frontier keeps specialists on purpose. **Why the bar is Δ > k·SE, not Δ > 0.** Rewards are estimates from a finite sample of tasks and trials, so about half of all *no-op* edits measure as a small positive Δ by chance. Accepting on Δ > 0 therefore ratchets on noise: val creeps up, the sealed test does not move, and the run reports a gain that was never there. The bar is the noise scale itself, so a win has to be larger than the measurement error that produced it. `phases/gate` owns the full statement of the decision and its modes. ## Focus schedules | `--focus` | what each iteration emphasizes | when to use | |---|---|---| | `all` (default) | every failing val task — find the one edit that lifts the most | broad capability gaps; the usual choice | | `cyclic` | one val task at a time, round-robin | many distinct, unrelated failure modes | | `hardest-first` | val tasks ordered by the parent's per-task reward ascending, then cycling | a few very hard tasks dominate the gap | All three index the **val** per-task results, because those are the only per-task data the loop holds. Non-regression protection covers the whole val split under every schedule, not just the focused task. `hardest-first` costs no extra evaluation: it orders off the per-task rewards already in hand. Back-compat: `--focus all-at-once` is accepted and treated as `all` (`run.py:35`). ## Key flags Beyond `--run-dir` / `--project` / `--optimizer` / `--focus` / `--max-iterations` / `--n-trials` / `--resume` (`scripts/run.py`): - `--gate-mode` (default `auto`) + `--k-se` (default `1.0`) — `auto` lets the engine pick the paired gate; `significant|paired|strict|threshold` pin it. Raising `k-se` makes acceptance stricter. - `--protected-paths` — globs sealing the eval surface (scorer, gold, tasks, splits; `default` expands to the built-in set). A candidate that edits one is *indecisive*, never scored: the measurement would grade a compromised harness, so no reward is recorded, the stall counter is untouched, and best is unchanged (`harness.py:1440-1446`). Leave this on for any run whose number you intend to quote. - `--capabilities` — comma-separated capability skills under optimization. When empty the optimizer receives **no** allowed-edit-space block at all (`harness.py:1735-1737`), so it guesses the edit surface from the files. - `--no-regression` — reject a candidate that lowers any val task the parent scored higher on, even when the mean improves. - `--convergence` — graded plateau signal (`ok` → `warn` → `paradigm_shift` → `stop`) appended to the prompt, so a plateau escalates the ask instead of burning the remaining iterations on more of what failed. - `--workers N` — concurrent rollouts per evaluation. Only safe when the adapter's `run_target` is thread-safe; a shared client, temp path, or cwd will corrupt scores rather than fail loudly. - `--store git|copy|command` (+ `--store-commit-cmd`) — how each iteration is versioned; git is the default and every candidate becomes a commit. - `--instructions-file`, `--bench-repo`, `--capability-sources`, `--optimizer-name`, `--target-model`, `--target-profile-file` — prompt and read-context wiring; `cap-evolve run` fills these from the spec. ## Standalone use ```bash python scripts/run.py --run-dir .capevolve/run_X --project .capevolve/project \ --optimizer 'python .../run-optimizer/scripts/run.py --name mock --workdir {workdir} --prompt {prompt}' \ --focus hardest-first --max-iterations 10 --n-trials 4 --protected-paths default ``` ## Known gate edge case (open, issue #351) Under the default paired gate with `k_se = 1.0`, a candidate that improves **exactly one** val task and changes nothing else has `Δ̄ == SE(Δ)` algebraically, so a strict `>` resolves it on floating-point representation alone. Expect a genuine one-task gain not to bank, unpredictably by split size. Do not lower `k-se` to work around it — that disables the bar for every candidate; prefer edits that generalize across a class of tasks, which is what the loop is asking for anyway. ## Agent mode When `orchestration_mode: agent`, drive the loop yourself with the same mechanism as above: parent = current best, one edit per iteration, evaluate on **val**, gate, accept → snapshot / reject → revert, seal once with the finalize phase script (`skills/phases/finalize/scripts/run.py`), then report. `orchestrate` owns the agent-mode rules; the hill-climb-specific obligation is that you must reproduce the handover surface `run_step` normally builds — `LEDGER.md`, `JOURNAL.md`, `PROCESS.md`, `RUNMAP.md` + `prior_iterations/` — and carry rejected edits into the next iteration's prompt. Skip it and the dashboard goes dark and the optimizer re-proposes edits already refuted. ## References - `references/run-step.md` — the shared step's exact contract: the handover files and their ownership, the rejected/accepted memory, the version store, the snapshot filter, and the tamper path. Load it when you need the contract verbatim, or when writing an algorithm that reuses `run_step`. The sibling algorithm skills link this file rather than this body. - `references/focus-schedules.md` — how each schedule builds its focus set. Load when choosing between the three or debugging a focus set.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.