Skip to content

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.

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.