Meta-evolution — evolving the slots of evolve()¶
Module: agentdescent.meta
· Example: examples/metasearch/
· Design record: design-meta-evolution.md
Every decision evolve() makes is a field of Policies, and
every algorithm port plugs its mechanism into one of those fields — a tree
search into selection (behind the aggregator_factory exit
when it needs a tree of its own). meta_evolve() evolves the field itself:
from agentdescent import meta_evolve, meta_validate, priority_selection, slot_reflector
spec = priority_selection() # the `selection` slot as a gated priority rule
result = meta_evolve(
{"algotune": algotune_problem}, # inner problems: (value, seed) -> MetaOutcome
slot="selection", spec=spec,
propose=slot_reflector(model, spec), # the reflector rewrites the value
seeds=range(8), rounds=6, n_workers=4)
policy = spec.compile(result.rendered) # -> Policies(selection=policy), or EraTree(policy=...)
report = meta_validate(spec, spec.render(spec.initial()), result.rendered,
{"swe_science": swe_problem, "tb_science": tb_problem}, seeds=range(4))
It is the ordinary engine one level up. The artifact is the slot's value,
held by a SlotSpec — a strategy that also knows how to
compile its rendering into the object Policies takes. A task is one
inner Problem: run a whole inner search with the candidate value installed,
at a fixed budget, from a seed, and return a MetaOutcome (the held-out curve,
the final reward, the rollouts spent, whatever the reflector should see). The
reward is a function of that outcome — auc by default, the mean
best-so-far, because a decision rule cannot make a better answer exist, only
find one sooner. Governance is L1: a slot value changes how everything
downstream is searched, so every merge also passes the oracle.
What may evolve¶
SLOTS is the decision plane: selection, task_sampler, acceptance,
conflict, fusion, promotion, staleness, proposal. The machinery
fields — verifier, ledger, executor, evaluator, eval_cache,
aggregator_factory, sandbox_* — are the training code and are refused,
which is where the central analogy draws the L0 line.
Two representations of a value¶
| spec | value | merge behaviour | gate |
|---|---|---|---|
ParamSlot(factory, params, bounds) |
the numeric constructor keywords of any policy class (FlatPuct(c_puct, prior_exponent), Beam(k), …) |
different parameters union-merge; the same one contradicts and is resolved on held-out | unknown names and out-of-bounds values are refused |
SourceSlot(initial_value, validate, build) |
source text compiled by build |
one slot: every round is a tournament | validate raises ValueError |
policy_source(slot, seed) is the general one: the value is the source of a
class satisfying the slot's own Protocol (SLOT_PROTOCOLS, all
runtime_checkable). The gate refuses imports outside a fixed allowlist, dunder
access and the calls that reach the interpreter, builds the class in a
namespace of safe builtins, checks it with isinstance against the Protocol,
and runs a smoke test — one shipped per slot, replaceable via smoke=.
seed_source(slot) provides a starting value for every slot: the engine's
default rule transcribed where a seed can carry it (selection,
task_sampler, staleness, fusion, promotion), the simplest contract-
satisfying rule where the default reads the verifier or a posterior
(acceptance, conflict), and a placeholder shape for proposal. It is the
gate SICA and Gödel Agent run their self-edits behind,
not a sandbox: enough to keep a model's rewrite to deciding, not enough for
code from a stranger.
priority_selection() is the narrower, safer SourceSlot for a tree search: the value
is one function, priority(rank, visits, total, prior, depth, n_nodes), seeded
with upstream ERA's flat PUCT, and PrioritySelection is the SelectionPolicy
that runs it — rank normalisation, prior normalisation, the visit reservation up
the parent chain and the tie-break stay in the wrapper, so a rule can only be
wrong about priority. The gate is an AST whitelist plus a run over a fixed
grid of inputs that includes the root before any expansion: a rule that divides
by visits is refused at proposal time.
What the outer loop actually does¶
meta_evolve is not a new optimiser. It is evolve() — the same
parallel, merge-based loop the rest of this project uses — with three things
substituted:
evolve() term |
what meta_evolve puts there |
|---|---|
| the artifact | the slot's value: for priority_selection(), the source of one function |
| a rollout | compile the candidate, then run one whole inner search with it and return the best-so-far curve |
| the reward | a MetaOutcome -> [0, 1] summary of that curve, auc by default |
| the task set | one task per (inner problem, inner seed) pair, interleaved seed-major so every problem lands on both sides of the train/held-out cut |
One outer rollout is therefore an entire inner search, which is what makes this expensive and what decides where to evolve and where to validate.
The loop per round:
- Split.
held_out_fraccuts the tasks into train and held-out, asevolve()always does. The held-out side is never rolled out on. - Roll out.
n_workersworkers each take a train task in parallel, compile the current rule through the slot's gate, and run the inner search. - Propose. For a rollout that scored below target,
slot_reflectormakes one model call. The prompt is only four things: the slot's owndescribe()(arguments and the syntax contract — no advice about what to change), the current value, the inner outcome as JSON (curve,final,outcomes), and the reward. The model replies with a whole new function. - Merge. The workers' proposals go through the ordinary conflict and fusion
policies — by default
reflective_merge, so contradicting proposals are fused rather than one being discarded. - Gate. The merged candidate is scored on the held-out tasks and committed only if it does not lose ground there.
- Oracle.
blast_radius=0.6isHARNESS_BLAST_RADIUS, so the slot is governed at L1: every merge is also forced through the oracle, which scores the same artifact on the same held-out set. A tie is a veto.
Two gates sit in front of the model's output, and they are what make an arbitrary LLM rewrite safe to execute:
- an AST whitelist — exactly one function of the six named arguments, built
from arithmetic, comparisons, conditionals, locals,
min/max/absandmath.sqrt/log/log1p/exp/tanh/pow. No imports, loops, or other calls. - a fixed input grid — the rule must return a finite number everywhere on
it, including the root before any expansion. A rule that divides by
visitsis refused at proposal time rather than crashing at the root.
The wrapper keeps rank normalisation, prior normalisation, the visit reservation up the parent chain and the tie-break, so an evolved rule can only be wrong about priority — never about the tree's bookkeeping.
The configuration the real-data result used¶
meta_evolve(problems, slot="selection", spec=priority_selection(),
propose=slot_reflector(complete, spec),
seeds=[0], rounds=8, n_workers=3, held_out_frac=0.4)
with an inner search of 8 expansions, deepseek-v4-pro at temperature 0.7,
thinking disabled, and completions cached so an inner run is a function of the
rule.
It asked for 8 rounds and got 2, stopping on a max_seconds=5400 budget:
6 rollouts, 2 commits, 0 rejections, 40 model calls, 197k tokens, 2.7 hours.
A rollout is one whole inner search, so a sweep of three workers costs about
half an hour — budget the outer loop in sweeps of wall clock, not in rounds.
seeds=[0], not [0, 1, 2]. The recorded run passed three seeds and they were three copies of one comparison: on a domain where the inner run is a function of the value, the seed randomises nothing. Spend that budget on more problems instead — the reasoning is in the result page.
From the CLI and the host plugins¶
kind: "policy_slot" puts all of this behind the ordinary spec surface, so
plan, evolve, status and watch work on it unchanged — it is still an
evolve() call. show prints the evolved rule, and apply needs a
destination: the artifact is source text, but target names a slot rather than
a file, so agentdescent apply <run> --to rule.py is how the rule comes out. agentdescent init selection --kind policy_slot
writes a starter:
{
"kind": "policy_slot",
"target": "selection",
"data": {"problems": "mypkg.problems:build", "seeds": [0]},
"score": "auc",
"agent": {"ref": "openai_compatible", "model": "deepseek-v4-flash"},
"evolve": {"rounds": 4, "n_workers": 2, "max_seconds": 5400}
}
Two fields do not mean what they mean for every other kind, and both are the nature of the thing rather than an inconsistency:
targetis a slot name, not a path.selection,task_sampler, … — the artifact is a decision rule of the optimiser, and there is no file to point at.dataholds refs, not rows. An inner problem is a callable(value, seed) -> MetaOutcome; no row format can express one.data.problemsis amodule:attributeref to a zero-argument builder returning a mapping of them, inside the spec's import allowlist. Pass--data mypkg.problems:buildtoinitto choose that ref; make the module importable and include its package in the generated spec'sallowlist before planning.
score takes auc (the default), final_reward, rollouts_to, or a ref to
any MetaOutcome -> float.
plan is worth reading here rather than skipping. Because a rollout is a
whole inner search, it reports how many outer tasks exist and how many of them
the gate actually gets — and it says so when that number is 0 or very small,
which is the defect this line of work hit at four different levels and which has
failed both ways: committing nothing, and silently committing a rule that lost
on its own family.
Where to evolve, where to validate¶
The outer loop runs a whole inner search per rollout and again per held-out problem at every gate, so evolve where the inner problem is cheap and validate where it is expensive — that is scored once per value. Three experiments ship, cheapest first:
| script | slot | inner problem | needs |
|---|---|---|---|
bench/metasearch_slots.py |
task_sampler |
a whole inner evolve() evolving an instruction on a slice of GSM-Hard / GSM8K |
a model, nothing else |
bench/metasearch_algotune.py |
selection |
an ERA tree search on one AlgoTune task, scored in speedup | a model, a sandbox |
examples/metasearch/_harbor.py |
selection |
an ERA tree search over patches to a Harbor science task | a model, a container, an agent |
The example itself does stage 0 offline on a synthetic landscape (a seeded
family to evolve on, a harder one never seen), and its boundary is stated in
examples/metasearch/README.md.
meta_validate scores the value before and after on problems disjoint from the
outer run, paired by seed, and transfer_ratio reads target gain over source
gain: near 1 is a better rule, near 0 with a positive source gain is a fit to the
training landscape, negative is a rule that traded generality for it.
A seed is only a replicate if it moves the run. On a domain where the
evaluator is deterministic and completions are cached, an inner run is a
function of the value — which is the property that makes the domain measurable
at all, and it makes seeds=[0, 1, 2] three copies of one comparison. Replicate
across problems or across the data split, and check the numbers differ before
counting them as independent.
What one evolved rule looks like¶
Seed (upstream ERA's flat PUCT) against the rule two outer sweeps on
LLM-SRBench lsr_synth committed:
# before
def priority(rank, visits, total, prior, depth, n_nodes):
c = 1.0
return rank + c * (1.0 / n_nodes) * math.sqrt(total) / (1 + visits)
# after
def priority(rank, visits, total, prior, depth, n_nodes):
c, d = 1.0, 0.1
exploration = c * math.sqrt(math.log1p(total) + 1.0) / (1.0 + visits)
prior_term = prior * math.sqrt(total + 1.0) * (1.0 - rank)
return rank + prior_term + exploration - d * depth
1 / n_nodes is deleted, so exploration stops decaying as the tree grows;
(1 - rank) explicitly favours low-ranked nodes; - 0.1 * depth prefers
breadth. Measured at nine nodes it moves a node 9.5x further off its raw
rank than the seed does, and in the opposite direction as the tree grows.
The same seed and the same reflector on a synthetic landscape go the other way — every one of three runs shrinks exploration. Nothing in the prompt says which way to go; the domains want opposite things and the search finds each.
What has been measured¶
| result | what it says |
|---|---|
| Evolving the tree search itself | positive, synthetic. +0.0227 source / +0.0120 target over 3 seeds; lands on the Pareto front of the hand-tuned PUCT family, from a seed that is off it |
| A search rule on real scientific data | positive, real data. LLM-SRBench lsr_synth: 5 wins / 0 losses / 4 ties over nine held-out paired comparisons, sign test p = 0.031 — and the rule moves in the opposite direction to the synthetic one, as predicted in advance |
| Choosing a domain to evolve on | the method, and every null. Four properties a domain must supply, three checks that cost one inner run apiece, and what each domain tried here actually supplied — AlgoTune (irreparable: the timing is the feedback) and SWE-bench-Science (three properties out of four; the fourth needs the agent phase) included |
| A benchmark x slot matrix | mixed. Seven cells on task_sampler and acceptance; four commit, unseen gains +0.02 to +0.04, none negative once the inner budget is adequate |
The product surface: meta_run¶
meta_evolve returns a raw EvolutionResult; wiring the value back and
judging the transfer is the caller's job. meta_run() is that job, done — one
call that evolves, validates on problems the outer loop never saw, and decides:
from agentdescent import meta_run, ParamSlot, DifficultyWeighted
report, result = meta_run(
{"train-a": problem_a, "train-b": problem_b}, # the outer loop's train set
{"val-z": problem_z}, # never seen by the outer loop
slot="task_sampler",
spec=ParamSlot(factory=lambda c: DifficultyWeighted(c=c),
params={"c": 0.2}, bounds={"c": (0.05, 1.0)}),
propose=offline_propose, # or slot_reflector(model, spec)
seeds=(0, 1), val_seeds=(20, 21), rounds=3, n_workers=1)
print(report.verdict, report.reason) # "adopt" / "reject", with why
value = spec.compile(report.value) # -> Policies(task_sampler=value)
MetaRunReport bundles the rendered value (report.value; compile with
spec.compile(...)), the per-problem validation (before / after / gain),
the transfer ratio (mean val gain / mean train gain) and an adopt / reject
verdict: adopt when the mean gain on unseen problems clears min_gain and
the transfer is not below transfer_floor, reject otherwise, with the
reason. The report rides result.meta, so a CLI or a plugin shows the decision
without recomputing it.
The verdict is honest. The caller must keep validation problems and seeds
disjoint from the training set (meta_run raises on overlap), so a verdict is
never measured on the problems that shaped the value. The measured matrix
(below) found transfer of +0.02 to +0.04 on unseen benchmarks — real, positive,
and modest. meta_run does not dress that up: a rule that does not transfer is
reported as reject, which is a result, not an error.
Continuous meta-learning: the archive drives the next session¶
meta_run decides and the run dies with the process; continuous_meta_run
makes the decision durable. It is meta_run wired to the
consolidation archive, so the next session starts where the
last one was validated rather than from the seed:
from agentdescent import ConsolidationStore, continuous_meta_run
store = ConsolidationStore("~/.agentdescent/archive")
# session 1: the empty archive is an ordinary meta_run
report, _ = continuous_meta_run(
store, slot="task_sampler", spec=slot(), propose=offline_propose,
tasks_for=lambda rec: tasks_for_config(rec.config), # caller's data, read through the archive
run=run, reward=reward, inner_propose=propose, inner_strategy=SingleSlot("cap:0"),
config=CONFIG, run_id="run-1",
problems={"a": problem_a, "b": problem_b}, # first session: caller's problems
val_problems={"z": problem_z},
inner={"rounds": 5, "n_workers": 1}, rounds=3, n_workers=1)
# session 2: the archive seeds the search and supplies the problems
report, _ = continuous_meta_run(
store, slot="task_sampler", spec=slot(), propose=offline_propose,
tasks_for=lambda rec: tasks_for_config(rec.config),
run=run, reward=reward, inner_propose=propose, inner_strategy=SingleSlot("cap:0"),
config=CONFIG, run_id="run-2", problems=None, val_problems={"z": problem_z})
The loop per session:
- Problems come from the archive. The top
train_runsarchived rules' problem descriptors become the train set, the nextval_runsthe validation set — rebuilt byproblem_from_archivefrom each record's descriptor plustasks_forand the inner domain functions, so the outer loop never validates on a problem it trained on. An archive too thin to fill a set falls back toproblems/val_problems— the first session is an ordinarymeta_run. - The search starts where the archive left off. The best archived rule for
the slot, spec and configuration becomes the session's
initial_state(same configuration first, then measured reward), so the evolution refines last session's rule rather than rediscovering it. Use a freshrepo_pathper session: the engine ignoresinitial_statewhen it is resuming a repo that already has state. - The gain is measured twice.
mean_gainis the evolved value against the prior this session had to beat;fresh_gainis it against the fresh seed — the honest cross-session number, the rule you have now against the rule you had before the first session ever ran. - Adoption is archived. On
adopt,archive_rulewrites the new value back with aproblemdescriptor (the slot and the JSON-safe inner settings; the tasks stay with the caller — the "link, don't copy" rule applied to a problem instead of a trajectory). The next session starts from a better rule, and the report'sseeded_from/archive_countsay so.
archive_rule(store, report, result, *, run_id, slot, spec, config, problem)
writes an adopted rule to the archive; problem_from_archive(store, run_id, *,
tasks_for, spec, run, reward, propose, strategy) rebuilds a runnable
Problem from a record's descriptor, refusing a record archived by a different
spec (its state would be misread, exactly the guard seed_from_archive runs).
ConsolidationRecord.problem is a new optional field — an archive written
before it existed reads back as None and stays seedable.
Offline demo (two sessions, the second seeded by the first, each archiving a strictly better rule):
Meta-audit: the trust layer for the rule-about-rules¶
The audit package verifies the inner reward — AuditedReward pairs a cheap
verifier against ground truth on a probability sample. Nothing verified the
meta reward: meta_run decides adopt/reject on an auc scored over inner
outcomes, and that number was the one unverified number in the loop. meta_audit
is AuditedReward applied one level up:
from agentdescent import MetaAuditStore, meta_run_audited, final_reward
store = MetaAuditStore("~/.agentdescent/audit.jsonl")
report, result = meta_run_audited(
{"train-a": problem_a, "train-b": problem_b},
{"val-z": problem_z}, slot="task_sampler", spec=slot(),
propose=offline_propose, oracle=final_reward, store=store,
audit_rate=0.1, rounds=3, n_workers=1)
# result.meta: {"verdict": ..., "audited_n": 12, "oracle_gain": 0.04, "gain_bias": 0.3, ...}
The contract is the audit layer's own:
- A probability sample. Every
(problem, seed)pair recorded carries itsinclusion_prob— without it nothing estimates anything, and it cannot be reconstructed later. - The decision path never sees the oracle.
meta_rewardscores every verdict; the oracle is called only on sampled pairs and only lands in the store. Auditing can be turned on mid-fleet without changing which rules are adopted. An oracle that cannot answer yet returnsNone: the pair is stored pending andMetaAuditStore.resolve()attaches truth later. - The verdict is durable. Sampled evidence and the decision it served are both in the JSONL store, append-only, reconciled by last occurrence, so "why was this rule adopted" is answerable after the run is gone.
- The bias is estimable. Over the resolved sample,
verifier_gain - oracle_gainis the residual, andMetaAuditStore.gain_bias()is its Hajek estimate. If the meta reward is systematically optimistic, that shows up as a number instead of adopting rules in silence.rectified_gain()is the honest gain estimate over the same sample.
The measured case in the tests: a meta reward that reads an auc of +0.65
where truth reads +0.0 — gain_bias reports the +0.65, the verdict is
still computed (and recorded) on the verifier, and the store holds the pairs
with both readings.
Meta-population: the archive seeds a population, the population promotes¶
continuous_meta_run seeds one session from the archive's best rule;
meta_population_run is the parallel version — the archive's top rules each
seed an island, every island searches from its own starting rule, and the best
adopt island's rule is promoted back:
from agentdescent import ConsolidationStore, meta_population_run
report, _ = meta_population_run(
store, slot="task_sampler", spec=slot(), propose=offline_propose,
tasks_for=lambda rec: tasks_for_config(rec.config),
run=run, reward=reward, inner_propose=propose, inner_strategy=SingleSlot("cap:0"),
config=CONFIG, islands=3, train_runs=3,
problems={"ta": helpful, "tb": helpful}, val_problems={"va": helpful_val},
rounds=3, n_workers=1)
for island in report.islands: # one row per starting rule
print(island.index, island.seeded_from, island.mean_gain, island.verdict)
print(report.promoted_from, report.reason)
The loop, in the same shape as the rest of the meta surface:
- Islands are sessions. The archive's rule records for the slot/spec/config
are ranked as
continuous_meta_runranks them; the topislandsdistinct rules each seed ameta_evolveviainitial_state. Every island trains on the same problems and validates the sameval_problemsatval_seedsdisjoint from training, so the islands differ only in where they started. - Each island runs in its own fresh repo. A shared
repo_pathwould make the engine resume the ledger and silently ignore the islands' distinctinitial_state— a population of clones.repo_dir=names the base directory for the per-island repos. - Promotion is evidence-based and fail-closed. Only an island whose
verdict is
adopt(positive mean gain on unseen problems, non-negative transfer) can be promoted; the best such island — highest mean gain, then best transfer — is written back viaarchive_rule. An island that errored is reported and never promoted, without stopping the others. - The report is per-seed.
MetaPopulationReport.islandsis onePopulationIslandper starting rule (seeded_from, value, mean_gain, transfer, verdict, reason);promoted_from/promoted_run_id/archive_countsay what the population changed.
An empty archive is refused with a pointer to run continuous_meta_run once
first — a population needs something to diversify from. Offline demo (two
sessions seed the archive, a population of two islands promotes the one that
gained):
Meta-curriculum: the meta loop generates the problems it is judged on¶
continuous_meta_run trains on the archive's problems and validates on the
caller's. The problem distribution is the least-examined part of the design —
the same gap curriculum_evolve closed one level down for tasks.
curriculum_meta_run closes it for problems: a challenger generates frontier
problems, the rule climbs toward them, and the validation set never moves:
from agentdescent import ConsolidationStore, MetaProblemChallenger, curriculum_meta_run
challenger = MetaProblemChallenger(
generate=lambda spec, seed: parse(spec), # a candidate problem config
validate=build_problem, # config -> Problem; raises on invalid
propose=advance_difficulty, # (descriptor, report) -> next descriptor
spec="d:0.5")
report, result = curriculum_meta_run(
store, slot="task_sampler", spec=slot(), challenger=challenger,
propose=offline_propose,
problems={"ta": problem_a, "tb": problem_b}, # the fixed curriculum
val_problems={"va": problem_z}, # never touched by the challenger
rounds=6, n_workers=1)
print(report["frontier"]) # generated_total, invalid_total, learned, stuck, r0_mean
The loop:
- The challenger is the distribution's own solver-challenger. Between outer
rounds it draws a candidate problem configuration, runs it through a
mandatory validity gate (
validatemust build a runnableProblem; a payload that cannot is counted, never crashed over), and installs the validated problems into aFrontierSamplerthat mixes them with the seed problems. - Learnability is two-phase and measured online.
recordsees the meta reward; a generated problem the rule already solves is dropped on its first attempt, one it never learns is dropped as stuck, one it learns is counted — the frontier keeps turning toward the rule's current edge, andpropose(descriptor, report)pushes the generation spec outward on the measuredlearnedcount. - The challenger never touches the final evaluation. The validation set is
fixed from
val_problemsand the held-out split is computed from the seed tasks alone — what the run reports is always measured on problems the challenger never generated. - Continuity is the archive's. The search starts from the best archived
rule for the slot/spec/config (in a fresh ledger repo, so the seed is
honoured), and on
adoptthe rule is written back with its problem descriptor — the next session starts where this one was validated, with a problem distribution the meta loop itself has been pushing outward.
Offline demo (a challenger pushes the difficulty outward while the rule climbs):
Self-exam: adoptions are provisional until the oracle says so¶
archive_rule writes an adopted rule into the archive, and _archived_rules
keeps seeding the next search from it. exam_archive is the rung that makes
that adoption provisional: it re-validates each rule against the problems
its own descriptor describes, using an independent oracle, and stamps the
outcome on the record:
from agentdescent import ConsolidationStore, exam_archive, final_reward
report = exam_archive(
store, slot="task_sampler", spec=slot(), oracle=final_reward,
tasks_for=lambda rec: tasks_for_config(rec.config),
run=run, reward=reward, propose=propose, strategy=SingleSlot("cap:0"),
config=CONFIG, min_gain=0.0, max_flags=2)
print(report.passed, report.flagged, report.demoted)
The ladder:
passed— the oracle gain on the rebuilt problems clearsmin_gain(flags reset). The rule keeps seeding the search.flagged— the oracle disagrees; the rule stays seedable and the flag count climbs.demoted— a rule that fails truthmax_flagstimes in a row is retired:_archived_rulesskips it, socontinuous_meta_runand population selection never start a search from a rule truth has rejected.
ConsolidationRecord.exam / exam_flags / exam_note carry the stamp
(optional, backwards compatible), and ConsolidationStore.mark_exam rewrites
atomically. The meta loop's verdicts are thereby provisional until examined,
and the archive self-corrects: what the loop believed is checked with evidence,
and what fails the check stops steering the search.
This is the audit rung made actionable: meta_run_audited records the
probability-sampled evidence, and exam_archive turns resolved oracle truth
into a seeding decision.
Verifiable-reward policy improvement: the framework trains on truth it can check¶
DeepSeek-R1 and RAGEN improve a policy with RL over trajectories where the
reward is verifiable — exact match, a checker, a held-out fact — not a
learned proxy. The meta stack defaults to auc (a proxy); verifiable_rl.py
makes the verifiable reward first-class:
from agentdescent import verifiable_problem, verifiable_reward, policy_improve
problems = {"a": verifiable_problem(problem_fn, gold, task_for=task_for)}
report, result = policy_improve(problems, val, slot="task_sampler",
spec=slot(), propose=offline_propose)
print(report.reward_curve) # per-round verifiable reward, RL's signal
print(report.seed_reward, report.improved_reward)
The loop:
verifiable_problem(problem_fn, gold, task_for)— wraps a(value, task) -> outputproblem so its outcome carries the output and the gold indetail.verifiable_reward()— exact match (1.0 / 0.0); a rule that cannot be checked is not rewarded — the DeepSeek-R1 verifiability contract.policy_improve(...)—meta_evolvewith that reward, reporting the per-round verifiable reward curve (the trajectory-level signal RL trains on) and the before/after on held-out verifiable facts.
The measured claim is the R1 one: only verifiable progress counts, and the report shows the reward curve the policy actually earned.
Self red-teaming: the framework attacks its own value to find the OOD gap¶
Red-teaming / adversarial search finds the inputs where a system fails; a self-improving framework should do this to itself before anyone else does:
from agentdescent import red_team_evolve
report, result = red_team_evolve(
problems, run=run, reward=reward, propose=offline_propose,
mutate=push_difficulty, strategy=skill, attack_budget=40)
print(report.failures_found) # the OOD gap the attack found
print(report.coverage_before, report.coverage_after)
The loop:
red_team(value, score, mutate, seed_tasks, budget)— mutates the training tasks outward and scores the value on each mutation, collecting the failures: the OOD region where the value does not generalise. Deterministic and reproducible.red_team_evolve(...)— evolves on the problems, attacks its own result, then retrains on the union of the problems and the failures it found, closing the OOD gap.RedTeamReport— the failures found and the coverage (fraction of the attack's mutations solved) before and after the retraining.
The measured claim: the framework finds its own blind spot and fixes it — and the report shows the gap closing.
Trajectory tree search: the evolution loop becomes a learned MCTS over states¶
LATS / AlphaCodium run MCTS over agent trajectories; the parallel engine here
explores rollouts in batches, with no learned preference for which branch of
the state tree deserves the next rollout. trajectory_search.py is the
tree-search dimension:
from agentdescent import search_evolve
best, report = search_evolve(
problem, spec, seed_value="c: 0.2", branch=branch_fn,
budget=40, ucb_c=1.4)
print(report.value_estimate, report.verified_value) # search's belief vs fresh truth
print(report.nodes, report.depth)
The loop:
SearchTree— a state tree over rendered values:expand(rendered, children)adds branches (a self-loop is refused, so select cannot spin),update(rendered, value)back-propagates, andselect()walks from the root choosing the child with the highest UCB score (mean value plus an exploration bonus) — the same bandit the tree policies inselectionrun, applied one level up.search_evolve— each iteration selects a leaf by UCB, branches it into child renderings via a callable, scores each child with a critic (auc, or an oracle / the self-rewarding judge) over seeds, and back-propagates.- The report is inspectable —
SearchReportcarries the budget, the node count, the search's ownvalue_estimate, and a freshly verified score of the best state side by side, so the search's reasoning is measured against final truth, not assumed.
On a monotone landscape the tree climbs to the optimum; on a landscape with a trap band, UCB spends its budget past it — the search's own preference is what makes the tree better than a flat batch.
Agent society: the framework evolves a cooperating topology, not just one artifact¶
Everything else in this project evolves one artifact against one task set.
agent_society.py adds the topology dimension (Anthropic's multi-agent
research system; AgentEvolve): a society is a graph of agent nodes — each a
name, an instruction, a budget — with delegation edges, and the whole graph is
one evolvable artifact:
from agentdescent import society_evolve, society_problem
report, result = society_evolve(
{"t": society_problem(train_tasks, gold=gold)},
{"v": society_problem(val_tasks, gold=gold)}, # held-out inputs
propose=offline_propose, rounds=6, n_workers=2)
print(report.gain) # positive: the delegation won on the held-out set
print(report.value) # the evolved graph source
The loop:
AgentNode+SocietyGraph— the container: nodes (instruction, budget) and delegation edges;solve(input)routes the input through the graph and returns the output and the cost (nodes used).society_spec— the evolvable artifact: a gatedsociety()source compiled into aSocietyGraph, withSOCIETY_SEEDthe single-agent baseline. The gate is the same AST walk every other self-edit runs.society_problem— a multi-step task routed through the graph, rewarded as correctness minus delegation cost: a topology that solves the task with the least hand-off wins.society_evolve— the ordinaryevolve()over those tasks, so the search finds the topology (which nodes exist, who hands to whom, each node's instruction) that wins.SocietyReportcompares the evolved graph with the seed on validation tasks.
A single-node society is the old one-agent loop; a society that delegates to a specialist the single node lacked is the dimension up — measured, not assumed.
World model: the framework learns an internal simulator and dreams before it acts¶
Dreamer / world-model RL train a simulator from experience and use it for cheap
rollouts, spending real cost only where it pays. Every evolution run here pays
the real cost of every rollout; world_model.py is the Dreamer loop applied to
the framework:
from agentdescent import LearnedSimulator, imagine_then_evolve
sim = LearnedSimulator(k=1)
sim.fit(observed_pairs) # (state features, real reward) from the ledger
report, result = imagine_then_evolve(
{"t": problem_a}, {"v": problem_z},
slot="task_sampler", spec=slot(), simulator=sim,
propose=offline_propose, rounds=6, n_workers=2)
print(report.simulator_error, report.simulator_agreement) # is the dream trustworthy?
print(report.dreamed_rollouts, report.verified_rollouts) # budget split
The loop:
LearnedSimulator— a pure-Python nearest-neighbour model fit on observed(state, reward)pairs: deterministic, dependency-free, and unable to extrapolate beyond what it has seen.simulator_problem(simulator)— turns the simulator into aProblem: a cheap, model-free rollout (no inner run, no model call), which is what makes dreaming affordable.simulator_score(simulator, real_problem, value, seeds)— the simulator against the real problem: mean abs error and rank agreement, so the framework knows when to trust the dream and when to wake up.imagine_then_evolve(...)— spends most of its budget in the dream (evolving against the simulator viameta_evolve), then verifies the value on the realval_problemsand reports the dreamed vs verified rollout counts and the real gain over the seed.
The measured claim is the Dreamer one made concrete: pay the real cost where it pays, dream where it is cheap — and the report says how much the dream was worth.
Self-rewarding meta-evolution: the judge improves as the run goes¶
Self-Rewarding Language Models (Meta, 2024) train a model to reward its own
outputs, and improve both the outputs and the judge in alternation. The meta
level here already pairs a fixed meta_reward with an oracle that samples the
truth (#426); the missing rung was that the reward itself never improves.
self_rewarding_meta_run closes it:
from agentdescent import self_rewarding_meta_run, final_reward
report, result = self_rewarding_meta_run(
{"ta": problem_a, "tb": problem_b}, {"va": problem_z},
slot="task_sampler", spec=slot(), oracle=final_reward,
propose=offline_propose, self_reward_rounds=2, rounds=4, n_workers=2)
print(report.judge_agreement) # judge vs oracle on outcomes it never trained on
print(report.judge_labels, report.oracle_gain)
The loop:
outcome_features(outcome)— the curve-shape features a judge learns quality from: mean best-so-far, final, sweeps, rollouts. Deliberately never the oracle's exact quantity — the judge must learn quality from the shape.LearnableMetaJudge— a small pure-Python linear regressor from those features to oracle quality, fit by least squares (deterministic, no numpy), trained only on labels the oracle actually produced, never its own guesses.self_rewarding_meta_run— evolve with the plainauc, scorejudge_sampleoutcomes with the oracle and fit the judge, then re-evolve with the judge as the meta reward and refit on the newly oracle-labelled outcomes — each round the search moves against a closer approximation of truth.- The report is measured, not claimed.
judge_agreementis the judge vs the oracle on validation outcomes it never trained on — the same final-evaluation isolation every other development set gets — plus the label count and the oracle gain of the evolved value over the seed.
Rationale distillation: the artifact learns to carry its own reasoning¶
STaR (Self-Taught Reasoner) boots a model by distilling its own successful
reasoning back into it. Every evolution run here produces rollouts — successes
and near-misses — and then the artifact dies with the run; nothing distilled
why it succeeded into the artifact itself. rationale_distill.py does:
from agentdescent import rationale_evolve
report, result = rationale_evolve(
problems, run=run, reward=reward, propose=offline_propose,
rounds=6, n_workers=2)
print(report.skill) # the skill now carrying its own notes
print(report.n_rationales, report.successes)
The loop:
extract_rationale(task, output, success)— one rollout becomes a rule: a success says how it answered ("answer '6' for difficulty 3"), a near-miss says what it needed ("need cap >= 4").distill(records)— dedupes the rollouts' rationales into the skill the artifact carries, near-miss rationales included — the artifact's own notes about what it still needs.rationale_evolve— runs the ordinaryevolve(), then distills the run's own successes back into the skill. The report shows the distilled skill, the rationale count and the success count.
The measured claim is the STaR one: the artifact boots itself by carrying what its own runs taught it — and the report shows the notes it learned to carry.
Evolved code that beats the shipped implementation on quality-per-cost¶
priority_selection evolves the selection priority rule, but its reward is
quality alone — an evolved rule that costs more than the shipped seed can win
on AUC and lose on latency, invisibly. evolve_fast_rule is the AlphaEvolve /
FunSearch idea applied to the decision plane: the reward includes cost, so
the search finds a rule that is as good and cheaper than the hand-written
default, and the report measures the evolved rule against the seed on both axes:
from agentdescent import evolve_fast_rule, priority_grid_problem
report, result = evolve_fast_rule(
{"g": priority_grid_problem()}, {"v": priority_grid_problem()},
propose=offline_propose, rounds=6, n_workers=2)
print(report.evolved_ops, report.seed_ops) # fewer ops ...
print(report.evolved_quality, report.seed_quality) # ... at equal quality
print(report.gain) # positive: it beat the seed
The pieces:
source_ops(source)— the arithmetic/call nodes of a candidate rule's source, counted from the AST: a deterministic, attributable cost proxy (evaluating a rule costs what its expression tree costs), where wallclock is not reproducible.priority_grid_problem()— aProblemover a fixed grid of priority inputs whose reference ordering is the shipped seed's own: the seed scores1.0at the baseline cost, and a rule must be cheaper at equal quality (or better at equal cost) to beat it.cost_adjusted_reward(lam, baseline)—quality - lam * (cost / baseline), clipped to[0, 1]; the cost is read from the outcome'sdetail.evolve_fast_rule(...)—meta_evolveonselectionwith that reward, thenFastRuleReport: seed vs evolvedops, seed vs evolved quality, and the cost-adjusted gain — the number that says "this evolved rule beat the hand-written default on quality-per-cost".
The grid regime spans the root before any expansion, a lone node, deep nodes
and unrated priors — the same regime compile_priority refuses a rule on, so a
candidate that is not a finite number everywhere never reaches the cost
comparison.
Self-modifying framework: the framework evolves patches to its own code¶
Everything else in this project evolves artifacts — skills, prompts, decision
rules. improve_source evolves the framework's own source, gated by the
framework's own tests — a gate strong enough to keep a model's rewrite to
computing, not enough to run untrusted code from a stranger:
from agentdescent import improve_source, patch_source
tests = [lambda fn: fn(3) == 9, lambda fn: fn(0) == 0]
bench = lambda fn: 1.0 / (1.0 + len(fn.__code__.co_code)) # bytecode size ~ speed
report, result = improve_source(seed_source, tests, bench,
propose=offline_propose, rounds=4, n_workers=2)
print(report.patch) # the unified diff a reviewer sees
print(report.evolved_tests, report.gain)
The loop:
patch_source/apply_patch— a candidate is the source of one module-level function;patch_sourcemakes the diff a reviewer sees andapply_patchreverses it (a patch that cannot apply is refused, never half-applied).gate_source— the same AST walk every other self-edit runs: no imports, no dunder, no interpreter calls (exec,eval,open,getattr...). The candidate compiles in a namespace of safe builtins only.evaluate_source— every test must pass before any benchmark score counts. A patch that breaks anything is a zero, never a regression with a number attached; an unparseable candidate is counted, not crashed.improve_source— the ordinaryevolve()over(test, seed)tasks, so the framework searches its own code under its own gate. The report carries the accepted patch, the tests it passed, and the measured gain over the seed.
The gate is the same one SICA and Gödel Agent run their self-edits behind: enough to keep a model's rewrite to computing, not enough to run untrusted code from a stranger.
Learn-to-think: the framework evolves when to spend compute¶
Search-R1 / ZigZag train a model to decide when to retrieve, search, or answer
directly, with verifiable rewards. The same decision exists in every evolution
run — spend a cheap rollout, a deep search, or a reflection pass — and nothing
ever learned which for a given difficulty and budget. learn_to_think makes
the routing rule an evolvable artifact:
from agentdescent import learn_to_think, think_policy_spec, think_problem
report, result = learn_to_think(
{"t": think_problem()}, {"v": think_problem()},
propose=offline_propose, rounds=6, n_workers=2)
print(report.routes_on) # {'easy': 'direct', 'hard': 'search'}
print(report.gain) # positive: it beats the default on quality-per-cost
The pieces:
think_policy_spec()— the value is a gated sourcePolicy.route( difficulty, budget_left, reward)returning exactlydirect/search/reflect. The gate refuses imports, dunder access, forbidden calls, a wrong signature, and a return outside the three routes; a smoke test runs the route on easy, hard-with-budget and hard-without-budget inputs.think_problem()— a workload instance whose outcome depends on the route: each route has a cost model and a quality model, and the reward is quality-per-cost, with the decision and the cost carried indetail.learn_to_think(...)— a bespokeevolve()over those problems: the artifact is the route source, the propose rewrites it, the reward is the outcome's quality-per-cost.LearnToThinkReportcompares the evolved rule with the default on validation problems and shows the route each chose on an easy and a hard input.
The measured claim is the Search-R1 one made concrete: the framework learns, from its own measured returns, to search the hard tasks and answer the easy ones directly — instead of paying a fixed price everywhere.
Failure-weighted replay: the framework replays the past it is still failing¶
Curious Replay (ICLR 2025) replays under-explored regions of experience,
weighted by learning progress. The trajectory ledger records every past
rollout; nothing here ever re-examined them. curious_replay.py makes the
past an explicit training set with failure weights (a need signal, not
learning-progress):
from agentdescent import curious_replay_evolve, ReplayExample
report, result = curious_replay_evolve(
past_examples, spec=slot(), run=score_past, propose=offline_propose,
rounds=6, n_workers=2)
print(report.weights) # which past the framework is still failing
print(report.curious_gain, report.mastered_gain)
The loop:
ReplayExample— one replayed past task with its rolling scores.curiosity_weight— high while the artifact is still failing it (falls as it improves, floor once mastered).- One task per replayed example, weighted by a snapshot of the weights (taken before any candidate rolls out), with the mastered and still-curious cohorts interleaved so both land on both sides of the positional held-out cut — the held-out set cannot be curious-only, so a candidate that forgets the mastered past is rejected by the gate.
curious_replay_evolve— the ordinaryevolve()over those tasks, recording each outcome into the examples' rolling scores for the next call; the report shows the weights and the gain on the still-curious vs mastered past.
The measured claim: the signal goes where the artifact is still failing — and the report says whether the replay helped there.
Async meta-population: the islands run concurrently¶
meta_population_run runs its islands one after another; async_meta_population_run
is the same call with a thread pool:
import os
from agentdescent import async_meta_population_run
report, _ = async_meta_population_run(
store, slot="task_sampler", spec=slot(), propose=offline_propose,
tasks_for=lambda rec: tasks_for_config(rec.config),
run=run, reward=reward, inner_propose=propose, inner_strategy=SingleSlot("cap:0"),
config=CONFIG, islands=3, workers=3,
problems={"ta": helpful, "tb": helpful}, val_problems={"va": helpful_val},
repo_dir=os.path.expanduser("~/.agentdescent/pop-repos"))
The islands are independent sessions — each starts from its own archived rule,
trains on the shared problems, validates the shared isolated set, and runs in
its own fresh ledger repo — so workers of them run at once (default
islands, i.e. all). The archive is only read while the islands run
(ConsolidationStore is thread-safe) and written once at promotion, so the
decision and the report are identical to the sequential call's, only faster to
reach. An errored island is reported and never promoted, exactly as sequential;
an empty archive is refused with the same pointer to run continuous_meta_run
first.
Trajectory-driven curriculum: the problems come from the run's own failures¶
curriculum_meta_run (#429) gives the meta loop a challenger that generates
frontier problems; where those problems come from is the challenger's
hardest question. trajectory_challenger answers it with the run's own
memory — every archived rule links its evidence through
ConsolidationRecord.trajectory_store (#334), and the trajectory ledger (#248)
records every rollout, including the ones that failed:
from agentdescent import ConsolidationStore, trajectory_challenger, problem_from_tasks, curriculum_meta_run
challenger = trajectory_challenger(
store,
tasks_for=lambda ids: tasks_by_id(ids), # task_id -> Task
build_problem=lambda tasks: problem_from_tasks( # tasks -> Problem
tasks, slot="task_sampler", run=run, reward=reward,
propose=propose, strategy=SingleSlot("cap:0"), rounds=5, n_workers=1),
slot="task_sampler", failure_threshold=0.5)
report, result = curriculum_meta_run(
store, slot="task_sampler", spec=slot(), challenger=challenger,
propose=offline_propose,
problems={"ta": problem_a, "tb": problem_b}, val_problems={"va": problem_z},
rounds=6, n_workers=1)
The challenger's generation spec is an archived run_id: generate reads
that rule's trajectory store, takes the rollouts whose reward fell below
failure_threshold (a corrupt record is skipped; a rule with no trajectories —
or no failures to mine — is counted invalid, never crashed over), and propose
advances across the archive's rules that recorded trajectories, so the frontier
turns across the framework's failures instead of one rule's. problem_from_tasks
is the natural problem builder: an evolve_problem over exactly the failed
tasks.
The #429 guarantee is inherited unchanged: the validation set stays fixed and the held-out split is computed from the seed tasks alone, so a problem mined from a failure can only ever appear in the training frontier. Memory becomes the exam — what the meta loop got wrong is what it is asked next.
Chain audit: one adoption, traced through every evidence ring¶
The framework's promise is that no number in the loop is unverified — the inner
reward is audited, the meta gain is audited, adoptions are provisional until
the self-exam says so, and routing evidence can exist for the harness that won.
chain_audit makes that promise inspectable: given one archived rule, it walks
the rings its adoption passed through and shows, per ring, whether the evidence
is present, within tolerance, or missing entirely:
from agentdescent import ConsolidationStore, chain_audit
report = chain_audit(
archive, "run-1",
meta_audit=meta_store, # the meta-audit store (#426)
inner_audit=inner_store, # the inner verifier's audit store (#273)
harness_evidence=lambda config: leaderboard_best(config))
for link in report.links:
print(link.layer, link.status, link.reason)
print(report.verified)
The rings:
- governance — the slot's layer (decision plane vs machinery): always reported, statically known.
- decision — the recorded verdict and transfer, when a decision trail
exists (e.g.
meta_run_audited's verdict lines). - meta-gain — the oracle-vs-verifier gain bias for this run from a
MetaAuditStore; a bias abovebias_thresholdflags the ring. - self-exam — the rule's
examstamp (passed / flagged / demoted). - inner-reward — the inner verifier's residual bias from an
AuditStore. - harness — routing evidence, when the caller has any.
A ring is verified when its evidence is present and within tolerance,
flagged when present and outside it, and unverified when the evidence
simply is not there — reported, never silently assumed.
ChainAuditReport.verified is true only when no ring is flagged or
unverified; the reason names the rings that keep it false, so "this adoption is
not fully verified" is a list, not a shrug.