agentdescent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,169 @@
1
+ """AgentDescent: a parallel, self-evolving framework for accelerating RSI.
2
+
3
+ A systematic port of the parallel-training playbook (data/tensor/pipeline
4
+ parallelism, parameter servers, decoupled/async RL, partial rollout) onto
5
+ recursive self-improvement, where the "parameters" are a library of evolvable
6
+ artifacts (skills, prompts, harness modules, verifiers) and the "gradients" are
7
+ diffs carrying evidence cards.
8
+
9
+ See ``agentdescent_design.md`` for the full design; each module cites the section
10
+ it implements.
11
+ """
12
+
13
+ from .evolvable import (
14
+ Contract,
15
+ Diff,
16
+ EvidenceCard,
17
+ Evolvable,
18
+ VersionVector,
19
+ vv_dominates,
20
+ vv_staleness,
21
+ )
22
+ from .ledger import Ledger, Snapshot, CASConflict, ContractRejected
23
+ from .verifier import ThreeLayerVerifier, VerifierBudget
24
+ from .scheduler import (
25
+ AuditScheduler,
26
+ DurationEstimator,
27
+ ResumeQueue,
28
+ TaskCluster,
29
+ TaskScheduler,
30
+ fifo_makespan,
31
+ lpt_schedule,
32
+ )
33
+ from .governance import Layer, classify, assert_mutable, GovernanceError, L1SerialGate
34
+ from .aggregator import (
35
+ Aggregator,
36
+ AggregatorConfig,
37
+ AggregatorProtocol,
38
+ AggregatorFactory,
39
+ MergeReport,
40
+ EvidenceBuffer,
41
+ )
42
+ from .staleness import (
43
+ StaleAction,
44
+ StalenessPolicy,
45
+ FullStaleness,
46
+ GuardedStaleness,
47
+ ReflectiveStaleness,
48
+ get_policy,
49
+ )
50
+ from .worker import Worker
51
+ from .orchestrator import AgentDescent, RoundStat, run_fork_baseline
52
+ from .agents import (
53
+ Completion,
54
+ claude,
55
+ echo,
56
+ from_callable,
57
+ openai_compatible,
58
+ with_retries,
59
+ )
60
+ from .evolution import (
61
+ Agent,
62
+ LLMAgent,
63
+ Task,
64
+ EvolvingArtifact,
65
+ Strategy,
66
+ AppendRules,
67
+ KeyedRules,
68
+ EvolutionResult,
69
+ RoundInfo,
70
+ evolve,
71
+ claude_agent,
72
+ rule_id,
73
+ )
74
+ from .async_evolve import async_evolve
75
+ from .async_runtime import AsyncAgentDescent, AsyncConfig, AsyncStats
76
+ from .parallel import (
77
+ ParallelMode,
78
+ ParallelStrategy,
79
+ WorkUnit,
80
+ DataParallel,
81
+ TensorParallel,
82
+ PipelineParallel,
83
+ TensorParallelMerge,
84
+ PipelineChain,
85
+ SectionViolation,
86
+ assign_sections,
87
+ section_of,
88
+ shard_round_robin,
89
+ )
90
+
91
+ __version__ = "0.7.0"
92
+
93
+ __all__ = [
94
+ "Contract",
95
+ "Diff",
96
+ "EvidenceCard",
97
+ "Evolvable",
98
+ "VersionVector",
99
+ "vv_dominates",
100
+ "vv_staleness",
101
+ "Ledger",
102
+ "Snapshot",
103
+ "CASConflict",
104
+ "ContractRejected",
105
+ "ThreeLayerVerifier",
106
+ "VerifierBudget",
107
+ "AuditScheduler",
108
+ "ResumeQueue",
109
+ "DurationEstimator",
110
+ "lpt_schedule",
111
+ "fifo_makespan",
112
+ "TaskCluster",
113
+ "TaskScheduler",
114
+ "Layer",
115
+ "classify",
116
+ "assert_mutable",
117
+ "GovernanceError",
118
+ "L1SerialGate",
119
+ "Aggregator",
120
+ "AggregatorConfig",
121
+ "AggregatorProtocol",
122
+ "AggregatorFactory",
123
+ "MergeReport",
124
+ "EvidenceBuffer",
125
+ "StaleAction",
126
+ "StalenessPolicy",
127
+ "FullStaleness",
128
+ "GuardedStaleness",
129
+ "ReflectiveStaleness",
130
+ "get_policy",
131
+ "Worker",
132
+ "AgentDescent",
133
+ "RoundStat",
134
+ "run_fork_baseline",
135
+ "AsyncAgentDescent",
136
+ "AsyncConfig",
137
+ "AsyncStats",
138
+ "ParallelMode",
139
+ "ParallelStrategy",
140
+ "WorkUnit",
141
+ "DataParallel",
142
+ "TensorParallel",
143
+ "PipelineParallel",
144
+ "TensorParallelMerge",
145
+ "PipelineChain",
146
+ "SectionViolation",
147
+ "assign_sections",
148
+ "section_of",
149
+ "shard_round_robin",
150
+ "Completion",
151
+ "claude",
152
+ "echo",
153
+ "from_callable",
154
+ "openai_compatible",
155
+ "with_retries",
156
+ "Agent",
157
+ "LLMAgent",
158
+ "Task",
159
+ "EvolvingArtifact",
160
+ "Strategy",
161
+ "AppendRules",
162
+ "KeyedRules",
163
+ "EvolutionResult",
164
+ "RoundInfo",
165
+ "evolve",
166
+ "async_evolve",
167
+ "claude_agent",
168
+ "rule_id",
169
+ ]
agentdescent/agents.py ADDED
@@ -0,0 +1,112 @@
1
+ """Provider-agnostic inference -- connect any agent or LLM to the framework.
2
+
3
+ This is the general "talk to a model/agent" layer. It is deliberately kept
4
+ **out of** :mod:`agentdescent.evolution`: skill evolution is just *one* application
5
+ built on the framework, and how you reach a model has nothing to do with it.
6
+
7
+ The whole contract is one type:
8
+
9
+ Completion = Callable[[str], str] # prompt -> text
10
+
11
+ Anything that maps a prompt to text is a completion -- an LLM call, a
12
+ tool-using agent loop, a canned stub for tests. The adapters below build
13
+ completions for Claude, an arbitrary callable, or a deterministic echo, and
14
+ :func:`with_retries` wraps any of them with backoff. Higher layers
15
+ (the evolution engine's LLMAgent, or your own) turn a completion
16
+ into whatever task interface they need.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import time
24
+ import urllib.request
25
+ from typing import Callable, Optional
26
+
27
+ Completion = Callable[[str], str]
28
+
29
+
30
+ def from_callable(fn: Completion) -> Completion:
31
+ """Identity adapter -- documents that any ``prompt -> text`` callable works."""
32
+ return fn
33
+
34
+
35
+ def echo(transform: Optional[Callable[[str], str]] = None) -> Completion:
36
+ """A deterministic, no-network completion for tests and dry runs.
37
+
38
+ Returns the prompt unchanged, or ``transform(prompt)`` if given."""
39
+ def complete(prompt: str) -> str:
40
+ return transform(prompt) if transform else prompt
41
+ return complete
42
+
43
+
44
+ def with_retries(completion: Completion, attempts: int = 3,
45
+ backoff: float = 0.5, sleep: Callable[[float], None] = time.sleep) -> Completion:
46
+ """Wrap a completion with exponential-backoff retries on any exception."""
47
+ def complete(prompt: str) -> str:
48
+ last: Optional[Exception] = None
49
+ for i in range(attempts):
50
+ try:
51
+ return completion(prompt)
52
+ except Exception as e: # noqa: BLE001 - provider-agnostic retry
53
+ last = e
54
+ if i < attempts - 1:
55
+ sleep(backoff * (2 ** i))
56
+ raise last # type: ignore[misc]
57
+ return complete
58
+
59
+
60
+ def claude(model: str = "claude-opus-4-8", max_tokens: int = 1024,
61
+ client: Optional[object] = None, **create_kwargs) -> Completion:
62
+ """A Claude-backed completion (requires ``pip install anthropic`` + creds).
63
+
64
+ Pass ``client`` to reuse an existing ``anthropic.Anthropic`` instance;
65
+ otherwise a default one is constructed lazily (resolving credentials from
66
+ the environment / an ``ant auth login`` profile). Use a cheaper ``model``
67
+ (e.g. ``"claude-haiku-4-5"``) for call-heavy loops."""
68
+ _client = client
69
+
70
+ def complete(prompt: str) -> str:
71
+ nonlocal _client
72
+ if _client is None:
73
+ from anthropic import Anthropic # lazy, optional dependency
74
+ _client = Anthropic()
75
+ msg = _client.messages.create(
76
+ model=model, max_tokens=max_tokens,
77
+ messages=[{"role": "user", "content": prompt}], **create_kwargs,
78
+ )
79
+ return "".join(b.text for b in msg.content if b.type == "text")
80
+
81
+ return complete
82
+
83
+
84
+ def openai_compatible(model: str, *, base_url_env: str = "OPENAI_BASE_URL",
85
+ api_key_env: str = "OPENAI_API_KEY",
86
+ default_base_url: str = "https://api.openai.com/v1",
87
+ max_tokens: int = 1024, timeout: float = 120.0) -> Completion:
88
+ """A completion for any OpenAI-compatible chat endpoint (GLM/Zhipu, proxies,
89
+ local servers, OpenAI itself).
90
+
91
+ The base URL and API key are read from the environment at call time -- they
92
+ never pass through code or arguments. Point it at GLM, for example, by
93
+ setting ``OPENAI_BASE_URL=https://open.bigmodel.cn/api/paas/v4`` and
94
+ ``OPENAI_API_KEY=<your key>`` in your shell, then use ``model="glm-4.6"``."""
95
+ def complete(prompt: str) -> str:
96
+ base = os.environ.get(base_url_env, default_base_url).rstrip("/")
97
+ key = os.environ.get(api_key_env)
98
+ if not key:
99
+ raise RuntimeError(f"set {api_key_env} (and {base_url_env}) in your environment")
100
+ body = json.dumps({
101
+ "model": model,
102
+ "messages": [{"role": "user", "content": prompt}],
103
+ "max_tokens": max_tokens,
104
+ }).encode()
105
+ req = urllib.request.Request(
106
+ f"{base}/chat/completions", data=body,
107
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
108
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
109
+ data = json.load(resp)
110
+ return data["choices"][0]["message"]["content"]
111
+
112
+ return complete
@@ -0,0 +1,370 @@
1
+ """The Aggregator: an optimizer over a discrete artifact space.
2
+
3
+ This is where the central analogy deliberately breaks (design doc, section 1):
4
+ gradients add, diffs do not. Aggregation is therefore not averaging but
5
+ **conflict resolution + statistical acceptance + transactional commit**.
6
+
7
+ The per-bucket pipeline (design doc, section 4):
8
+
9
+ 1. trigger + bucket (section 4.1)
10
+ 2. staleness filter (section 4.2) per-diff eta, three-way split
11
+ + rebase & cheap re-verify
12
+ 3. conflict resolution (section 4.3) syntactic / semantic / PCGrad-drop
13
+ + candidate fusion tournament (model-soup style)
14
+ 4. Beta-posterior accept (section 4.4) P(delta > 0) > 1 - delta
15
+ 5. CAS / 2PC commit (section 4.1)
16
+ 6. dual-branch promotion (section 4.5) EMA-style dev -> stable
17
+ 7. audit submission (section 5.3) the optimizer audits itself
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import threading
23
+ from collections import defaultdict
24
+ from dataclasses import dataclass, field
25
+ from typing import Callable, Dict, List, Optional, Protocol, Tuple, runtime_checkable
26
+
27
+ from .evolvable import Diff, EvidenceCard, Evolvable, VersionVector, vv_staleness
28
+ from .governance import Layer, classify, assert_mutable
29
+ from .ledger import CASConflict, Ledger
30
+ from .scheduler import AuditScheduler
31
+ from .staleness import StaleAction, StalenessPolicy, get_policy
32
+ from .stats import BetaPosterior, annealed_delta, prob_improvement
33
+ from .verifier import ThreeLayerVerifier
34
+
35
+
36
+ @runtime_checkable
37
+ class AggregatorProtocol(Protocol):
38
+ """The contract a custom aggregator must satisfy to plug into ``evolve``.
39
+
40
+ An aggregator is the framework's *optimizer*: it receives evidence cards
41
+ (diffs + evidence) and, on ``step()``, decides what to merge into the shared
42
+ ledger. Implement these two methods (see :class:`Aggregator` for the
43
+ reference 7-stage pipeline) to swap in your own merge/acceptance logic."""
44
+
45
+ def ingest(self, card: EvidenceCard) -> None: ...
46
+
47
+ def step(self) -> List["MergeReport"]: ...
48
+
49
+
50
+ # Builds an aggregator from the runtime deps ``evolve`` owns. The default is the
51
+ # reference :class:`Aggregator`; pass your own to ``evolve(aggregator_factory=)``.
52
+ AggregatorFactory = Callable[
53
+ [Ledger, ThreeLayerVerifier, AuditScheduler, "AggregatorConfig", StalenessPolicy],
54
+ AggregatorProtocol,
55
+ ]
56
+
57
+
58
+ @dataclass
59
+ class AggregatorConfig:
60
+ batch_trigger: int = 4 # B: hot artifacts trigger by batch size
61
+ max_wait_rounds: int = 3 # T_max: cold artifacts trigger by time
62
+ base_delta: float = 0.5 # acceptance risk (annealed by version)
63
+ alpha_head: int = 5 # staleness tolerance for hot artifacts
64
+ alpha_tail: int = 1 # staleness tolerance for cold artifacts
65
+ trust_region_ops: int = 6 # max ops per diff (trust region)
66
+ promote_after_k: int = 3 # dev->stable survival rounds (EMA)
67
+
68
+
69
+ @dataclass
70
+ class MergeReport:
71
+ artifact_id: str
72
+ accepted: Optional[Diff]
73
+ fused: bool
74
+ considered: int
75
+ survived_staleness: int
76
+ discarded_stale: int
77
+ conflicts_dropped: int
78
+ prob_improve: float
79
+ committed_version: Optional[int]
80
+ reason: str = ""
81
+
82
+
83
+ class EvidenceBuffer:
84
+ """Cards bucketed by target artifact (design doc, section 4.1).
85
+
86
+ Thread-safe: in the asynchronous runtime many worker threads call
87
+ :meth:`add` concurrently while the aggregator thread calls :meth:`ready` /
88
+ :meth:`drain`. All bucket mutations are guarded by an internal lock."""
89
+
90
+ def __init__(self) -> None:
91
+ self._buckets: Dict[str, List[EvidenceCard]] = defaultdict(list)
92
+ self._waited: Dict[str, int] = defaultdict(int)
93
+ self._settled: List[EvidenceCard] = []
94
+ self._lock = threading.Lock()
95
+
96
+ def add(self, card: EvidenceCard) -> None:
97
+ with self._lock:
98
+ self._buckets[card.diff.target].append(card)
99
+
100
+ def tick(self) -> None:
101
+ with self._lock:
102
+ for aid in list(self._buckets):
103
+ self._waited[aid] += 1
104
+
105
+ def ready(self, config: AggregatorConfig) -> List[str]:
106
+ with self._lock:
107
+ out = []
108
+ for aid, cards in self._buckets.items():
109
+ if not cards:
110
+ continue
111
+ if len(cards) >= config.batch_trigger or self._waited[aid] >= config.max_wait_rounds:
112
+ out.append(aid)
113
+ return out
114
+
115
+ def drain(self, artifact_id: str) -> List[EvidenceCard]:
116
+ with self._lock:
117
+ cards = self._buckets.pop(artifact_id, [])
118
+ self._waited.pop(artifact_id, None)
119
+ return cards
120
+
121
+ def pending(self) -> int:
122
+ with self._lock:
123
+ return sum(len(c) for c in self._buckets.values())
124
+
125
+ def settle(self, cards: List[EvidenceCard]) -> None:
126
+ """Return discarded-diff evidence to the pool for later reuse.
127
+
128
+ This is the structural advantage of artifacts over gradients (design
129
+ doc, section 3.3): a stale gradient is simply lost, but a stale diff's
130
+ evidence survives. Here we keep the cards addressable; a fuller system
131
+ would re-file them into the trajectory pool."""
132
+ with self._lock:
133
+ self._settled.extend(cards)
134
+
135
+ @property
136
+ def settled(self) -> List[EvidenceCard]:
137
+ with self._lock:
138
+ return list(self._settled)
139
+
140
+
141
+ def diffs_conflict(a: Diff, b: Diff) -> bool:
142
+ """Syntactic overlap: do two diffs edit an overlapping set of keys?"""
143
+ return bool(set(a.ops) & set(b.ops))
144
+
145
+
146
+ def diffs_contradict(a: Diff, b: Diff) -> bool:
147
+ """Semantic contradiction: same key, different proposed value."""
148
+ for k in set(a.ops) & set(b.ops):
149
+ if a.ops[k] != b.ops[k]:
150
+ return True
151
+ return False
152
+
153
+
154
+ def fuse_diffs(diffs: List[Diff]) -> Diff:
155
+ """Merge complementary (non-contradicting) diffs into one candidate.
156
+
157
+ The model-soup analogy (design doc, section 4.3): the fusion of several
158
+ local improvements often beats any single one -- but only when they share a
159
+ base and do not contradict, which the caller guarantees."""
160
+ ops: Dict = {}
161
+ for d in diffs:
162
+ ops.update(d.ops)
163
+ ids = "+".join(sorted(d.diff_id for d in diffs))
164
+ breaking = any(d.contract_breaking for d in diffs)
165
+ return Diff(diff_id=f"fused({ids})", target=diffs[0].target, ops=ops,
166
+ contract_breaking=breaking, author="aggregator")
167
+
168
+
169
+ class Aggregator:
170
+ """Per-artifact optimizer step over the ledger."""
171
+
172
+ def __init__(
173
+ self,
174
+ ledger: Ledger,
175
+ verifier: ThreeLayerVerifier,
176
+ audit: AuditScheduler,
177
+ config: Optional[AggregatorConfig] = None,
178
+ staleness_policy: Optional[StalenessPolicy] = None,
179
+ ) -> None:
180
+ self.ledger = ledger
181
+ self.verifier = verifier
182
+ self.audit = audit
183
+ # swap Full / Guarded / Reflective without touching the merge pipeline.
184
+ self.staleness_policy = staleness_policy or get_policy("guarded")
185
+ self.config = config or AggregatorConfig()
186
+ self.buffer = EvidenceBuffer()
187
+ self._posteriors: Dict[str, BetaPosterior] = defaultdict(BetaPosterior)
188
+ # dev-branch survival counter for EMA-style promotion.
189
+ self._survival: Dict[str, int] = defaultdict(int)
190
+
191
+ # -- staleness (section 4.2) ---------------------------------------------
192
+
193
+ def _alpha_for(self, artifact: Evolvable, card: EvidenceCard) -> int:
194
+ if card.diff.contract_breaking:
195
+ return 0 # contract-breaking diffs must be re-proposed, not rebased.
196
+ # hotter artifacts (bigger blast radius) tolerate more staleness.
197
+ return self.config.alpha_head if artifact.blast_radius > 0.5 else self.config.alpha_tail
198
+
199
+ def _staleness_filter(
200
+ self, artifact: Evolvable, head: VersionVector, cards: List[EvidenceCard]
201
+ ) -> Tuple[List[EvidenceCard], List[EvidenceCard]]:
202
+ """Split cards into (survivors, discarded), delegating to the policy.
203
+
204
+ The active :class:`~agentdescent.staleness.StalenessPolicy` (Full / Guarded
205
+ / Reflective) decides ACCEPT / REBASE / DISCARD from ``eta`` and
206
+ ``alpha``; the aggregator only executes the mechanical rebase-and-
207
+ re-verify for the REBASE case."""
208
+ survivors: List[EvidenceCard] = []
209
+ discarded: List[EvidenceCard] = []
210
+ for card in cards:
211
+ eta = vv_staleness(head, card.base_version)
212
+ alpha = self._alpha_for(artifact, card)
213
+ action = self.staleness_policy.decide(eta, alpha, card.diff.contract_breaking)
214
+ if action is StaleAction.ACCEPT:
215
+ survivors.append(card if eta == 0 else card.rebased_onto(head))
216
+ elif action is StaleAction.REBASE:
217
+ # cheaply re-verify the delta still holds on the current head.
218
+ candidate = artifact.apply(card.diff)
219
+ if artifact.cheap_eval(card) <= candidate.cheap_eval(card):
220
+ survivors.append(card.rebased_onto(head))
221
+ else:
222
+ discarded.append(card)
223
+ else: # DISCARD
224
+ discarded.append(card)
225
+ return survivors, discarded
226
+
227
+ # -- conflict resolution (section 4.3) -----------------------------------
228
+
229
+ def _resolve_conflicts(
230
+ self, artifact: Evolvable, cards: List[EvidenceCard]
231
+ ) -> Tuple[List[EvidenceCard], int]:
232
+ """Drop contradicting diffs (PCGrad-style) and return surviving cards."""
233
+ dropped = 0
234
+ kept: List[EvidenceCard] = []
235
+ for card in cards:
236
+ conflict = False
237
+ for i, k in enumerate(kept):
238
+ if diffs_contradict(card.diff, k.diff):
239
+ # project out the worse of the two on the held-out subset.
240
+ d_score = self.verifier.cheap_eval(artifact.apply(card.diff))
241
+ k_score = self.verifier.cheap_eval(artifact.apply(k.diff))
242
+ if d_score > k_score:
243
+ kept[i] = card
244
+ dropped += 1
245
+ conflict = True
246
+ break
247
+ if not conflict:
248
+ kept.append(card)
249
+ return kept, dropped
250
+
251
+ # -- fusion tournament (section 4.3) -------------------------------------
252
+
253
+ def _tournament(self, artifact: Evolvable, diffs: List[Diff]) -> Tuple[Diff, Evolvable, bool]:
254
+ """Run candidates (plus their fusion) in a held-out tournament."""
255
+ candidates: List[Tuple[Diff, Evolvable, bool]] = []
256
+ for d in diffs:
257
+ candidates.append((d, artifact.apply(d), False))
258
+ # add a fused candidate if the survivors are mutually complementary.
259
+ if len(diffs) > 1 and not any(
260
+ diffs_contradict(a, b) for i, a in enumerate(diffs) for b in diffs[i + 1:]
261
+ ):
262
+ fused = fuse_diffs(diffs)
263
+ candidates.append((fused, artifact.apply(fused), True))
264
+
265
+ best = max(candidates, key=lambda c: self.verifier.cheap_eval(c[1]))
266
+ return best
267
+
268
+ # -- public entry points -------------------------------------------------
269
+
270
+ def ingest(self, card: EvidenceCard) -> None:
271
+ self.buffer.add(card)
272
+
273
+ def step(self) -> List[MergeReport]:
274
+ """Fire every artifact bucket that is ready and return per-artifact reports."""
275
+ self.buffer.tick()
276
+ reports: List[MergeReport] = []
277
+ for aid in self.buffer.ready(self.config):
278
+ reports.append(self._process(aid))
279
+ return reports
280
+
281
+ def _process(self, artifact_id: str) -> MergeReport:
282
+ cards = self.buffer.drain(artifact_id)
283
+ snap = self.ledger.snapshot(Ledger.DEV)
284
+ artifact = snap.get(artifact_id)
285
+ if artifact is None:
286
+ return MergeReport(artifact_id, None, False, len(cards), 0, 0, 0, 0.0, None,
287
+ "unknown artifact")
288
+ assert_mutable(artifact) # L0 guard
289
+
290
+ # trust-region: reject over-large diffs up front.
291
+ cards = [c for c in cards if c.diff.size() <= self.config.trust_region_ops]
292
+
293
+ head = snap.version
294
+ survivors, discarded = self._staleness_filter(artifact, head, cards)
295
+ self.buffer.settle(discarded)
296
+ if not survivors:
297
+ return MergeReport(artifact_id, None, False, len(cards), 0, len(discarded),
298
+ 0, 0.0, None, "all stale / rejected")
299
+
300
+ kept_cards, conflicts = self._resolve_conflicts(artifact, survivors)
301
+ kept_diffs = [c.diff for c in kept_cards]
302
+ best_diff, best_state, fused = self._tournament(artifact, kept_diffs)
303
+
304
+ # -- Beta-posterior acceptance (section 4.4) -------------------------
305
+ # Compare candidate vs base as two Beta posteriors over their held-out
306
+ # success rate, with the running per-artifact posterior as a shared
307
+ # prior (so evidence-starved tail artifacts get a wider, more
308
+ # conservative test). Accept iff P(candidate_rate > base_rate) clears
309
+ # the annealed threshold -- not a point estimate crossing a line.
310
+ prior = self._posteriors[artifact_id]
311
+ base_s, base_f = self.verifier.eval_counts(artifact)
312
+ cand_s, cand_f = self.verifier.eval_counts(best_state)
313
+ base_score = self.verifier.cheap_eval(artifact)
314
+ cand_score = self.verifier.cheap_eval(best_state)
315
+
316
+ baseline_post = BetaPosterior(prior.successes + base_s, prior.failures + base_f)
317
+ candidate_post = BetaPosterior(prior.successes + cand_s, prior.failures + cand_f)
318
+ # fold each contributing worker's local before/after delta as extra
319
+ # evidence for the candidate (the "gradient magnitude").
320
+ for card in kept_cards:
321
+ if set(card.diff.ops) & set(best_diff.ops):
322
+ candidate_post.observe_delta(card.before_after_delta)
323
+
324
+ delta = annealed_delta(self.config.base_delta, artifact.version)
325
+ p_improve = prob_improvement(candidate_post, baseline_post, seed=artifact.version)
326
+
327
+ # -- audit (section 5.3): the optimizer audits its own decision ------
328
+ _, uncertainty = self.verifier.learned_eval(best_state)
329
+ self.audit.submit(best_diff.diff_id, artifact_id, artifact.blast_radius,
330
+ uncertainty, payload=best_diff)
331
+ if self.audit.force_oracle(artifact.blast_radius, artifact_id):
332
+ oracle_base = self.verifier.oracle_eval(artifact)
333
+ oracle_cand = self.verifier.oracle_eval(best_state)
334
+ agreed = (oracle_cand > oracle_base) == (cand_score > base_score)
335
+ self.audit.update_trust(artifact_id, agreed)
336
+ if oracle_cand <= oracle_base:
337
+ prior.observe_delta(oracle_cand - oracle_base)
338
+ return MergeReport(artifact_id, None, fused, len(cards), len(survivors),
339
+ len(discarded), conflicts, p_improve, None,
340
+ "oracle rejected")
341
+
342
+ if p_improve <= 1.0 - delta or cand_score < base_score:
343
+ prior.observe_delta(cand_score - base_score)
344
+ return MergeReport(artifact_id, None, fused, len(cards), len(survivors),
345
+ len(discarded), conflicts, p_improve, None,
346
+ f"P(delta>0)={p_improve:.2f} <= {1-delta:.2f}")
347
+
348
+ # -- commit (section 4.1): CAS on dev --------------------------------
349
+ base_vv = {artifact_id: head.get(artifact_id, 0)}
350
+ try:
351
+ _, new_version = self.ledger.commit(
352
+ best_state, base_vv, branch=Ledger.DEV,
353
+ message=f"merge {best_diff.diff_id} -> {artifact_id}",
354
+ )
355
+ except CASConflict:
356
+ self.buffer.settle(survivors)
357
+ return MergeReport(artifact_id, None, fused, len(cards), len(survivors),
358
+ len(discarded), conflicts, p_improve, None, "CAS conflict")
359
+
360
+ prior.update(True, weight=2.0) # a committed improvement is strong evidence.
361
+
362
+ # -- dual-branch EMA promotion (section 4.5) -------------------------
363
+ self._survival[artifact_id] += 1
364
+ if self._survival[artifact_id] >= self.config.promote_after_k:
365
+ self.ledger.promote_to_stable(artifact_id)
366
+ self._survival[artifact_id] = 0
367
+
368
+ return MergeReport(artifact_id, best_diff, fused, len(cards), len(survivors),
369
+ len(discarded), conflicts, p_improve, new_version,
370
+ "committed")