Acceptance policies — whether a candidate commits¶
Module: agentdescent.defaults,
agentdescent.advantage
· Contract: AcceptancePolicy.accept(ctx: MergeContext) -> AcceptDecision
The gate at the end of the merge pipeline: given the merged candidate's
held-out measurement, its history, and its prior, decide commit or refuse —
and say which kind of refusal (AcceptDecision.category), because "the gate
says it doesn't help" and "it never reached the gate" need opposite fixes.
Implemented¶
| Policy | Rule | Reach for it when |
|---|---|---|
DefaultAcceptance |
Beta posterior on the improvement, annealed risk base_delta, plus a regression guard on the full held-out set |
the default; statistical acceptance for noisy rewards |
AdvantageAcceptance(inner, strength) |
shifts the prior by the candidate group's standardised advantage (GroupAdvantage), then defers to inner — wraps, never replaces, so the Beta test and regression guard survive |
GRPO-shaped methods (R-Zero here); group evidence should nudge, not override |
StableDistanceAcceptance |
penalises candidates that drift far from the stable branch — the KL-to-reference analogy with stable as the reference |
long runs where dev can wander; distance as regularisation |
AcceptAnyCompiling (examples/godel_agent) |
accepts everything the validator let through | faithfulness ablations of gateless methods; measuring what the gate is worth |
StrictImprovement (examples/skillopt) |
commit only a strict full-held-out improvement — no Beta draw, no annealing | SkillOpt's deliberately harsher gate; small clean val sets where strictness is the algorithm |
Related knobs that are not policies: TrustRegion / AdaptiveTrustRegion
cap diff size before cards ever reach the gate.
evolve(tasks, reward, agent=agent, policies=Policies(
acceptance=AdvantageAcceptance())) # wraps the shipped gate
AdvantageAcceptance() and StableDistanceAcceptance() wrap the shipped
DefaultAcceptance when given no inner. Its three thresholds
(base_delta, anneal_half_life, accept_samples) are left unset and the
aggregator fills them from the run's AggregatorConfig when the policy is
installed — so the wrapped gate and agg_config= cannot disagree, which they
silently could when the example above read
DefaultAcceptance(0.5, 64, 4000). Pass a value to pin one:
AdvantageAcceptance(DefaultAcceptance(base_delta=0.3)) keeps 0.3 and takes
the other two from the run. DefaultAcceptance.from_config(cfg) is the fully
pinned form.
Protecting capabilities outside the target¶
A recursive self-modification can raise the task it is optimising and quietly
lower another capability. CapabilityGuard wraps the acceptance policy with a
set of protected task suites: it scores the base artifact and the candidate on
every suite and rejects when either the suite's absolute floor or the base
minus its tolerance is missed. Only then does it consult the inner policy, so
the guard can only make acceptance stricter.
from agentdescent import CapabilityGuard, CapabilitySuite
policies = Policies(acceptance=CapabilityGuard(
[CapabilitySuite("tool-use", tool_tasks, floor=0.85),
CapabilitySuite("json", json_tasks, tolerance=0.01)],
inner=DefaultAcceptance()))
The suites use the run's own scorer and agent (a suite is a task set, not a
second verifier). Scores go through the bound verifier's eval_fn when the
aggregator installed one, and through artifact.score(tasks) otherwise; a
custom Evolvable that offers neither raises ContractError naming the
requirement (score is not part of the Evolvable protocol). CapabilityReport
carries the per-suite base/candidate/bar alongside the regressions for a report
or a log. inner=None means the shipped DefaultAcceptance, configured through
the forwarded configure hook, so the guard is never looser than the default.
Installing a policy: the two optional hooks¶
A policy may need two things only the engine has: the verifier, for anything
that ranks, and the aggregator's config, for anything that reads a threshold.
When a policy is installed the aggregator offers both through two optional
methods — bind(verifier) and configure(config) — and every shipped wrapper
forwards them to its inner rule. A policy with neither is left alone. A
shipped default used without having been installed (driven by hand, outside
evolve()) raises PolicyUnboundError naming the missing piece rather than
failing on a None in the middle of a merge; call the hook yourself, or use
install_policy(policy, verifier, config) from agentdescent.aggregator.
What the default knows that a replacement must be told¶
Acceptance reads the full held-out set, never the cheap layer.
MergeContext carries both (base_counts vs base_cheap) because the
regression guard once read the cheap one, which cheap_eval_tasks sub-samples
— so a four-task sample could veto a commit the full-set test had just
approved. Ranking may use the cheap numbers; deciding to commit may not.