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.
- agentdescent/__init__.py +169 -0
- agentdescent/agents.py +112 -0
- agentdescent/aggregator.py +370 -0
- agentdescent/async_evolve.py +215 -0
- agentdescent/async_runtime.py +300 -0
- agentdescent/backends.py +195 -0
- agentdescent/dataloader.py +266 -0
- agentdescent/domains/__init__.py +19 -0
- agentdescent/domains/router.py +157 -0
- agentdescent/evolution.py +511 -0
- agentdescent/evolvable.py +146 -0
- agentdescent/governance.py +95 -0
- agentdescent/ledger.py +273 -0
- agentdescent/orchestrator.py +179 -0
- agentdescent/parallel.py +217 -0
- agentdescent/scheduler.py +278 -0
- agentdescent/staleness.py +109 -0
- agentdescent/stats.py +100 -0
- agentdescent/verifier.py +104 -0
- agentdescent/worker.py +91 -0
- agentdescent-0.1.0.dist-info/METADATA +375 -0
- agentdescent-0.1.0.dist-info/RECORD +25 -0
- agentdescent-0.1.0.dist-info/WHEEL +5 -0
- agentdescent-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentdescent-0.1.0.dist-info/top_level.txt +1 -0
agentdescent/__init__.py
ADDED
|
@@ -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")
|