archforge-optimizer 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.
- archforge/__init__.py +76 -0
- archforge/__main__.py +10 -0
- archforge/architect.py +442 -0
- archforge/cli.py +881 -0
- archforge/config.py +140 -0
- archforge/config_init.py +150 -0
- archforge/diff.py +206 -0
- archforge/engine.py +444 -0
- archforge/gatekeeper.py +290 -0
- archforge/host/__init__.py +20 -0
- archforge/host/adapters/__init__.py +41 -0
- archforge/host/adapters/base.py +311 -0
- archforge/host/adapters/helpers.py +163 -0
- archforge/host/adapters/langgraph.py +726 -0
- archforge/host/base.py +105 -0
- archforge/host/fake.py +380 -0
- archforge/judge/__init__.py +20 -0
- archforge/judge/base.py +257 -0
- archforge/judge/scripted.py +145 -0
- archforge/lint.py +180 -0
- archforge/llm/__init__.py +65 -0
- archforge/llm/_common.py +94 -0
- archforge/llm/anthropic.py +90 -0
- archforge/llm/base.py +90 -0
- archforge/llm/gemini.py +112 -0
- archforge/llm/groq.py +63 -0
- archforge/llm/openai.py +63 -0
- archforge/llm/scripted.py +134 -0
- archforge/middleware.py +181 -0
- archforge/models.py +435 -0
- archforge/mutate.py +214 -0
- archforge/otel.py +613 -0
- archforge/runlog.py +103 -0
- archforge/runner.py +153 -0
- archforge/spec_builder.py +126 -0
- archforge/stores/__init__.py +22 -0
- archforge/stores/_jsonl.py +81 -0
- archforge/stores/attempt_store.py +161 -0
- archforge/stores/spec_store.py +188 -0
- archforge/stores/trace_store.py +42 -0
- archforge/suite.py +248 -0
- archforge/userconfig.py +144 -0
- archforge_optimizer-0.1.0.dist-info/METADATA +420 -0
- archforge_optimizer-0.1.0.dist-info/RECORD +47 -0
- archforge_optimizer-0.1.0.dist-info/WHEEL +4 -0
- archforge_optimizer-0.1.0.dist-info/entry_points.txt +2 -0
- archforge_optimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
archforge/judge/base.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""The Judge — LLM-as-judge scoring of a run (spec §3, §4, §5).
|
|
2
|
+
|
|
3
|
+
`Judge.score(trace, task, rubric_id) -> RunScore` is ONE LLM call that turns a
|
|
4
|
+
trace into a scored verdict: an aggregate, named rubric dimensions, a
|
|
5
|
+
confidence, and a *per-step breakdown* (StepScore[]) naming which agent lost
|
|
6
|
+
which points. The per-step breakdown is the credit-assignment raw material the
|
|
7
|
+
Architect consumes — but the Judge contains NO diagnosis or proposal
|
|
8
|
+
text; per the locked "keep split" decision it scores only.
|
|
9
|
+
|
|
10
|
+
`aggregate_scores(...)` turns a list of RunScores (R repeats × N tasks) into a
|
|
11
|
+
single `SuiteAggregate` (confidence-weighted mean + per-task means). It is pure
|
|
12
|
+
math — no LLM call — so the call budget stays at "1 judge call per scored run".
|
|
13
|
+
rubric_id is stamped on every RunScore and the aggregate (invariant I5, E2).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Protocol, Sequence, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
21
|
+
|
|
22
|
+
from archforge import userconfig as ucfg
|
|
23
|
+
import archforge.models as m
|
|
24
|
+
from archforge.host.base import Task
|
|
25
|
+
from archforge.llm.base import LLMClient, LLMError, Message, Role
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --------------------------------------------------------------------------- #
|
|
29
|
+
# Rubric — the scoring schema, versioned by rubric_id (E2 / I5)
|
|
30
|
+
# --------------------------------------------------------------------------- #
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Rubric(BaseModel):
|
|
34
|
+
"""A named, versioned scoring schema: sub-rubric -> human description.
|
|
35
|
+
|
|
36
|
+
`rubric_id` is the version key recorded on every RunScore, so the Gatekeeper
|
|
37
|
+
never compares scores graded under different rubrics (invariant I5).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
model_config = ConfigDict(extra="forbid")
|
|
41
|
+
|
|
42
|
+
rubric_id: str
|
|
43
|
+
sub_rubrics: dict[str, str] # dimension name -> "what a high score looks like"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_rubric() -> Rubric:
|
|
47
|
+
"""The Judge's default rubric (E2 / I5), built lazily from the active config.
|
|
48
|
+
|
|
49
|
+
A function (not a module-level instance) so the rubric id + sub-rubrics resolve
|
|
50
|
+
from ``archforge.userconfig`` at first USE — not at import — which keeps this
|
|
51
|
+
module importable before ``init`` has run and makes an edit to
|
|
52
|
+
``.archforge/archforge.py`` take effect on a fresh construction. Each call reads
|
|
53
|
+
the cached resolved namespace (one resolve per process) and builds a fresh
|
|
54
|
+
``Rubric``; cheap and free of cross-test stale-state.
|
|
55
|
+
"""
|
|
56
|
+
return Rubric(
|
|
57
|
+
rubric_id=ucfg.get("DEFAULT_RUBRIC_ID"),
|
|
58
|
+
sub_rubrics=dict(ucfg.get("DEFAULT_SUB_RUBRICS")),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --------------------------------------------------------------------------- #
|
|
63
|
+
# SuiteAggregate — a suite's scores rolled into one comparable number
|
|
64
|
+
# --------------------------------------------------------------------------- #
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SuiteAggregate(BaseModel):
|
|
68
|
+
"""Confidence-weighted aggregation of a suite's RunScores (spec §5).
|
|
69
|
+
|
|
70
|
+
`mean` is what the Gatekeeper compares across incumbent vs candidate (same
|
|
71
|
+
rubric_id + same suite, per I5). `per_task` supports per-task diagnosis and
|
|
72
|
+
suite-aware plateau reasoning.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
model_config = ConfigDict(extra="allow")
|
|
76
|
+
|
|
77
|
+
suite_id: str
|
|
78
|
+
rubric_id: str
|
|
79
|
+
mean: float
|
|
80
|
+
n_runs: int
|
|
81
|
+
per_task: dict[str, float] = Field(default_factory=dict)
|
|
82
|
+
confidence: float = 1.0
|
|
83
|
+
# Set by the SuiteRunner when too many tasks crashed (spec E4).
|
|
84
|
+
unrunnable: bool = False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --------------------------------------------------------------------------- #
|
|
88
|
+
# Judge protocol — real Judge and ScriptedJudge both satisfy this
|
|
89
|
+
# --------------------------------------------------------------------------- #
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@runtime_checkable
|
|
93
|
+
class JudgeProtocol(Protocol):
|
|
94
|
+
"""What the SuiteRunner depends on: score one run, aggregate a suite."""
|
|
95
|
+
|
|
96
|
+
def score(self, trace: m.Trace, task: Task, rubric_id: str) -> m.RunScore: ...
|
|
97
|
+
|
|
98
|
+
def score_suite(
|
|
99
|
+
self, scores: Sequence[m.RunScore], *, suite_id: str, rubric_id: str
|
|
100
|
+
) -> SuiteAggregate: ...
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --------------------------------------------------------------------------- #
|
|
104
|
+
# The real Judge — one LLM call per trace, JSON-structured scoring
|
|
105
|
+
# --------------------------------------------------------------------------- #
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Judge:
|
|
109
|
+
"""Scores runs via an LLM client. Provider-agnostic — depends on `LLMClient` only.
|
|
110
|
+
|
|
111
|
+
On any failure (provider error or malformed JSON judgment) `score` raises
|
|
112
|
+
`LLMError`; the caller (SuiteRunner) does bounded retry with backoff (E9)
|
|
113
|
+
rather than fabricating a score.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
llm: LLMClient,
|
|
119
|
+
*,
|
|
120
|
+
model: str,
|
|
121
|
+
rubric: Rubric | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._llm = llm
|
|
124
|
+
self._model = model
|
|
125
|
+
self._rubric = rubric if rubric is not None else default_rubric()
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def rubric(self) -> Rubric:
|
|
129
|
+
return self._rubric
|
|
130
|
+
|
|
131
|
+
def score(self, trace: m.Trace, task: Task, rubric_id: str) -> m.RunScore:
|
|
132
|
+
messages = self._build_messages(trace, task, rubric_id)
|
|
133
|
+
completion = self._llm.complete(
|
|
134
|
+
messages, model=self._model, temperature=0.0, response_format="json",
|
|
135
|
+
)
|
|
136
|
+
parsed = completion.parsed
|
|
137
|
+
if not isinstance(parsed, dict):
|
|
138
|
+
raise LLMError("judge returned no parsed JSON judgment")
|
|
139
|
+
return self._to_run_score(parsed, trace, rubric_id)
|
|
140
|
+
|
|
141
|
+
def score_suite(
|
|
142
|
+
self, scores: Sequence[m.RunScore], *, suite_id: str, rubric_id: str
|
|
143
|
+
) -> SuiteAggregate:
|
|
144
|
+
return aggregate_scores(scores, suite_id=suite_id, rubric_id=rubric_id)
|
|
145
|
+
|
|
146
|
+
# --------------------------------------------------------------- internals
|
|
147
|
+
def _build_messages(
|
|
148
|
+
self, trace: m.Trace, task: Task, rubric_id: str
|
|
149
|
+
) -> list[Message]:
|
|
150
|
+
return [
|
|
151
|
+
Message(
|
|
152
|
+
role=Role.SYSTEM,
|
|
153
|
+
content=(
|
|
154
|
+
"You are a strict LLM-as-judge. Score a multi-agent run against "
|
|
155
|
+
f"rubric `{rubric_id}`. Each score is a float in [0, 1]. Return "
|
|
156
|
+
"ONLY JSON with: `aggregate` (overall 0-1), `confidence` (0-1), "
|
|
157
|
+
"`rubric_scores` (per dimension 0-1), and `step_scores` (one per "
|
|
158
|
+
"step: {node_id, sub_rubrics: {dim: 0-1}, note})."
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
Message(
|
|
162
|
+
role=Role.USER,
|
|
163
|
+
content=(
|
|
164
|
+
f"TASK INPUT:\n{task.input}\n\n"
|
|
165
|
+
f"RUBRIC ({rubric_id}):\n"
|
|
166
|
+
+ "\n".join(f"- {k}: {v}" for k, v in self._rubric.sub_rubrics.items())
|
|
167
|
+
+ "\n\nRUN TRACE (steps in order):\n"
|
|
168
|
+
+ _trace_summary(trace)
|
|
169
|
+
+ "\n\nReturn the JSON judgment."
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
def _to_run_score(
|
|
175
|
+
self, parsed: dict, trace: m.Trace, rubric_id: str
|
|
176
|
+
) -> m.RunScore:
|
|
177
|
+
try:
|
|
178
|
+
aggregate = float(parsed["aggregate"])
|
|
179
|
+
confidence = float(parsed.get("confidence", 1.0))
|
|
180
|
+
rubric_scores = {k: float(v) for k, v in parsed.get("rubric_scores", {}).items()}
|
|
181
|
+
step_scores = [
|
|
182
|
+
m.StepScore(
|
|
183
|
+
node_id=str(s["node_id"]),
|
|
184
|
+
sub_rubrics={k: float(v) for k, v in s.get("sub_rubrics", {}).items()},
|
|
185
|
+
note=s.get("note"),
|
|
186
|
+
)
|
|
187
|
+
for s in parsed.get("step_scores", [])
|
|
188
|
+
]
|
|
189
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
190
|
+
raise LLMError(f"malformed judge JSON: {exc}") from exc
|
|
191
|
+
|
|
192
|
+
return m.RunScore(
|
|
193
|
+
run_id=trace.run_id,
|
|
194
|
+
spec_id=trace.spec_id,
|
|
195
|
+
task_id=trace.task_id,
|
|
196
|
+
rubric_scores=rubric_scores,
|
|
197
|
+
aggregate=aggregate,
|
|
198
|
+
confidence=confidence,
|
|
199
|
+
judge_meta=m.JudgeMeta(model=self._model, rubric_id=rubric_id),
|
|
200
|
+
step_scores=step_scores,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --------------------------------------------------------------------------- #
|
|
205
|
+
# Pure aggregation — shared by the real Judge and ScriptedJudge (1 impl)
|
|
206
|
+
# --------------------------------------------------------------------------- #
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def aggregate_scores(
|
|
210
|
+
scores: Sequence[m.RunScore], *, suite_id: str, rubric_id: str
|
|
211
|
+
) -> SuiteAggregate:
|
|
212
|
+
"""Confidence-weighted mean of a suite's RunScores (pure, no LLM).
|
|
213
|
+
|
|
214
|
+
Equal weights when total confidence is zero (degenerate but well-defined).
|
|
215
|
+
Per-task means are simple averages — the per-step breakdown is retained on
|
|
216
|
+
each RunScore for the Architect; the aggregate only needs task-level means.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
scores = list(scores)
|
|
220
|
+
n = len(scores)
|
|
221
|
+
if n == 0:
|
|
222
|
+
return SuiteAggregate(suite_id=suite_id, rubric_id=rubric_id, mean=0.0, n_runs=0,
|
|
223
|
+
per_task={}, confidence=1.0)
|
|
224
|
+
|
|
225
|
+
total_w = sum(s.confidence for s in scores)
|
|
226
|
+
if total_w <= 0:
|
|
227
|
+
mean = sum(s.aggregate for s in scores) / n
|
|
228
|
+
else:
|
|
229
|
+
mean = sum(s.aggregate * s.confidence for s in scores) / total_w
|
|
230
|
+
|
|
231
|
+
per_task: dict[str, list[float]] = {}
|
|
232
|
+
for s in scores:
|
|
233
|
+
per_task.setdefault(s.task_id, []).append(s.aggregate)
|
|
234
|
+
per_task_mean = {tid: sum(vs) / len(vs) for tid, vs in per_task.items()}
|
|
235
|
+
|
|
236
|
+
return SuiteAggregate(
|
|
237
|
+
suite_id=suite_id, rubric_id=rubric_id, mean=mean, n_runs=n,
|
|
238
|
+
per_task=per_task_mean, confidence=total_w / n,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _trace_summary(trace: m.Trace) -> str:
|
|
243
|
+
if not trace.steps:
|
|
244
|
+
return "(no steps recorded — empty run)"
|
|
245
|
+
lines: list[str] = []
|
|
246
|
+
for i, step in enumerate(trace.steps):
|
|
247
|
+
lines.append(
|
|
248
|
+
f"[{i}] node={step.node_id}\n prompt_in: {step.prompt_in}\n"
|
|
249
|
+
f" response_out: {step.response_out}"
|
|
250
|
+
)
|
|
251
|
+
return "\n".join(lines)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
__all__ = [
|
|
255
|
+
"Rubric", "default_rubric", "SuiteAggregate", "JudgeProtocol",
|
|
256
|
+
"Judge", "aggregate_scores",
|
|
257
|
+
]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""ScriptedJudge — a deterministic fake `JudgeProtocol`.
|
|
2
|
+
|
|
3
|
+
Used by every scenario test that needs a Judge without a real model. It honors
|
|
4
|
+
the same contract as the real Judge and reuses the pure `aggregate_scores`, so
|
|
5
|
+
aggregation behaviour is identical real-vs-fake.
|
|
6
|
+
|
|
7
|
+
Scripting hooks (the things scenario tests need):
|
|
8
|
+
* `set_aggregate(...)` / fixed per-(spec_id, task_id) tables -> deterministic scores
|
|
9
|
+
* `with_noise_band(width)` -> jitter the aggregate within ±width of a base (E1)
|
|
10
|
+
* `regress_after(spec_id, drop)` -> score the next-scoring spec lower by `drop`
|
|
11
|
+
(used to prove promoted-then-regresses rollback, E6)
|
|
12
|
+
* `raise_on_next(...)` -> force the next score() call to raise LLMError (E9)
|
|
13
|
+
|
|
14
|
+
It records every run it scores, including the per-step breakdown, so the
|
|
15
|
+
Architect and tests can assert on credit-assignment input. If a run
|
|
16
|
+
is unknown and no base/scripted behaviour covers it, it returns a neutral 0.5
|
|
17
|
+
(never silently wrong — tests configure exactly what they need).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Sequence
|
|
23
|
+
|
|
24
|
+
from archforge.config import SCRIPTED_NOISE_PATTERN
|
|
25
|
+
import archforge.models as m
|
|
26
|
+
from archforge.host.base import Task
|
|
27
|
+
from archforge.judge.base import JudgeProtocol, Rubric, SuiteAggregate, aggregate_scores, default_rubric
|
|
28
|
+
from archforge.llm.base import LLMError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ScriptedJudge:
|
|
32
|
+
"""A programmable, deterministic Judge (no LLM)."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, *, rubric: Rubric | None = None) -> None:
|
|
35
|
+
self._rubric = rubric if rubric is not None else default_rubric()
|
|
36
|
+
# (spec_id, task_id) -> aggregate
|
|
37
|
+
self._fixed: dict[tuple[str, str], float] = {}
|
|
38
|
+
# base aggregate when not explicitly fixed
|
|
39
|
+
self._base: float = 0.5
|
|
40
|
+
# noise band applied to the aggregate (spec E1), deterministic per run order
|
|
41
|
+
self._noise_width: float = 0.0
|
|
42
|
+
# forced regression: after seeing `trigger_spec_id` once, subtract `drop`
|
|
43
|
+
self._regress: tuple[str, float] | None = None
|
|
44
|
+
self._regress_armed: bool = False
|
|
45
|
+
# raise on the next score() call (spec E9 grader outage)
|
|
46
|
+
self._raise_next: Exception | None = None
|
|
47
|
+
# recorded verdicts for assertions
|
|
48
|
+
self.scored: list[m.RunScore] = []
|
|
49
|
+
|
|
50
|
+
# --------------------------------------------------------------- config
|
|
51
|
+
def using(self, rubric: Rubric) -> "ScriptedJudge":
|
|
52
|
+
self._rubric = rubric
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def set_aggregate(self, spec_id: str, task_id: str, aggregate: float) -> "ScriptedJudge":
|
|
56
|
+
self._fixed[(spec_id, task_id)] = aggregate
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def set_base(self, aggregate: float) -> "ScriptedJudge":
|
|
60
|
+
self._base = aggregate
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def with_noise_band(self, width: float) -> "ScriptedJudge":
|
|
64
|
+
self._noise_width = width
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def regress_after(self, trigger_spec_id: str, drop: float) -> "ScriptedJudge":
|
|
68
|
+
"""Arm regression: after `trigger_spec_id` is scored once, drop subsequent by `drop`."""
|
|
69
|
+
|
|
70
|
+
self._regress = (trigger_spec_id, drop)
|
|
71
|
+
self._regress_armed = False
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def raise_on_next(self, exc: Exception | type[Exception] | None = None) -> "ScriptedJudge":
|
|
75
|
+
"""Force the next score() call to raise (default LLMError). Consumed once."""
|
|
76
|
+
|
|
77
|
+
self._raise_next = (exc if isinstance(exc, Exception) else (exc or LLMError)("scripted outage"))
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def rubric(self) -> Rubric:
|
|
82
|
+
return self._rubric
|
|
83
|
+
|
|
84
|
+
# --------------------------------------------------------------- contract
|
|
85
|
+
def score(self, trace: m.Trace, task: Task, rubric_id: str) -> m.RunScore:
|
|
86
|
+
if self._raise_next is not None:
|
|
87
|
+
err = self._raise_next
|
|
88
|
+
self._raise_next = None
|
|
89
|
+
raise err
|
|
90
|
+
|
|
91
|
+
aggregate = self._resolve_aggregate(trace, task)
|
|
92
|
+
confidence = 1.0
|
|
93
|
+
|
|
94
|
+
rs = m.RunScore(
|
|
95
|
+
run_id=trace.run_id,
|
|
96
|
+
spec_id=trace.spec_id,
|
|
97
|
+
task_id=trace.task_id,
|
|
98
|
+
rubric_scores={dim: aggregate for dim in self._rubric.sub_rubrics},
|
|
99
|
+
aggregate=aggregate,
|
|
100
|
+
confidence=confidence,
|
|
101
|
+
judge_meta=m.JudgeMeta(model="scripted-judge", rubric_id=rubric_id),
|
|
102
|
+
step_scores=[
|
|
103
|
+
m.StepScore(
|
|
104
|
+
node_id=step.node_id,
|
|
105
|
+
sub_rubrics={dim: aggregate for dim in self._rubric.sub_rubrics},
|
|
106
|
+
note=f"scripted score for {step.node_id}",
|
|
107
|
+
)
|
|
108
|
+
for step in trace.steps
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
self.scored.append(rs)
|
|
112
|
+
return rs
|
|
113
|
+
|
|
114
|
+
def score_suite(
|
|
115
|
+
self, scores: Sequence[m.RunScore], *, suite_id: str, rubric_id: str
|
|
116
|
+
) -> SuiteAggregate:
|
|
117
|
+
return aggregate_scores(scores, suite_id=suite_id, rubric_id=rubric_id)
|
|
118
|
+
|
|
119
|
+
# --------------------------------------------------------------- internals
|
|
120
|
+
def _resolve_aggregate(self, trace: m.Trace, task: Task) -> float:
|
|
121
|
+
# regression hook takes priority over fixed values (it models a real
|
|
122
|
+
# post-promotion regression regardless of scripted per-task scores)
|
|
123
|
+
if self._regress is not None:
|
|
124
|
+
trigger, drop = self._regress
|
|
125
|
+
if trace.spec_id == trigger and not self._regress_armed:
|
|
126
|
+
self._regress_armed = True
|
|
127
|
+
elif self._regress_armed and trace.spec_id != trigger:
|
|
128
|
+
# subsequent non-trigger specs regress
|
|
129
|
+
base = self._fixed.get((trace.spec_id, trace.task_id), self._base)
|
|
130
|
+
return max(0.0, base - drop)
|
|
131
|
+
|
|
132
|
+
base = self._fixed.get((trace.spec_id, trace.task_id), self._base)
|
|
133
|
+
|
|
134
|
+
# deterministic noise: index of this run within the recorded history,
|
|
135
|
+
# so identical runs -> identical jitter (reproducible E1 boundary tests)
|
|
136
|
+
if self._noise_width > 0:
|
|
137
|
+
idx = len(self.scored)
|
|
138
|
+
# map idx onto a small repeating pattern within [-width, +width]
|
|
139
|
+
pattern = list(SCRIPTED_NOISE_PATTERN)
|
|
140
|
+
jitter = (pattern[idx % len(pattern)]) * self._noise_width
|
|
141
|
+
base = max(0.0, min(1.0, base + jitter))
|
|
142
|
+
return base
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
__all__ = ["ScriptedJudge"]
|
archforge/lint.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Spec Linter — pure structural validation of a Pipeline Spec (spec E5, I2).
|
|
2
|
+
|
|
3
|
+
`lint(spec) -> list[LintError]` is a pure function (no I/O). It is reused in two
|
|
4
|
+
places: inside the Architect (reject a candidate before it reaches the
|
|
5
|
+
SuiteRunner — E5) and as the standalone `archforge lint` subcommand.
|
|
6
|
+
|
|
7
|
+
The linter checks *structure*, not host behaviour:
|
|
8
|
+
|
|
9
|
+
* DAG-ness (no cycles, no self-loops)
|
|
10
|
+
* every edge endpoint resolves to an existing node
|
|
11
|
+
* edge-type rules (e.g. CONDITIONAL requires a `gate`)
|
|
12
|
+
* no orphan nodes in a multi-node spec; no duplicate node_ids
|
|
13
|
+
* tool ids are non-empty and unique within a node
|
|
14
|
+
|
|
15
|
+
Tool ids are checked for well-formedness only — resolution against the host's
|
|
16
|
+
tool registry is the host's job, not the linter's.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections import defaultdict, deque
|
|
22
|
+
from typing import Literal
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel
|
|
25
|
+
|
|
26
|
+
from archforge.models import EdgeType, NodeKind, Spec
|
|
27
|
+
|
|
28
|
+
# Lint error codes — stable identifiers so tests and the Architect can branch on
|
|
29
|
+
# them. Keep the set small; add codes only when a new structural fault appears.
|
|
30
|
+
LintCode = Literal[
|
|
31
|
+
"duplicate_node",
|
|
32
|
+
"orphan_node",
|
|
33
|
+
"edge_unknown_from",
|
|
34
|
+
"edge_unknown_to",
|
|
35
|
+
"self_loop",
|
|
36
|
+
"duplicate_edge",
|
|
37
|
+
"cycle",
|
|
38
|
+
"conditional_no_gate",
|
|
39
|
+
"tool_id_empty",
|
|
40
|
+
"tool_id_duplicate",
|
|
41
|
+
"node_unknown_kind",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LintError(BaseModel):
|
|
46
|
+
"""A single structural defect in a Spec. Structured → returnable to humans."""
|
|
47
|
+
|
|
48
|
+
code: LintCode
|
|
49
|
+
message: str
|
|
50
|
+
location: str | None = None # node_id or "from->to" depending on the fault
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def lint(spec: Spec) -> list[LintError]:
|
|
54
|
+
"""Return all structural defects found in `spec` (empty list == valid)."""
|
|
55
|
+
|
|
56
|
+
errors: list[LintError] = []
|
|
57
|
+
node_ids = {n.node_id for n in spec.nodes}
|
|
58
|
+
|
|
59
|
+
# --- duplicate node_ids --------------------------------------------------
|
|
60
|
+
seen: dict[str, int] = defaultdict(int)
|
|
61
|
+
for n in spec.nodes:
|
|
62
|
+
seen[n.node_id] += 1
|
|
63
|
+
if seen[n.node_id] == 2:
|
|
64
|
+
errors.append(
|
|
65
|
+
LintError(code="duplicate_node", message=f"node_id '{n.node_id}' appears more than once",
|
|
66
|
+
location=n.node_id)
|
|
67
|
+
)
|
|
68
|
+
# tool ids per node
|
|
69
|
+
tool_seen: set[str] = set()
|
|
70
|
+
for tid in n.tools:
|
|
71
|
+
if not tid:
|
|
72
|
+
errors.append(LintError(code="tool_id_empty",
|
|
73
|
+
message=f"node '{n.node_id}' has an empty tool id",
|
|
74
|
+
location=n.node_id))
|
|
75
|
+
elif tid in tool_seen:
|
|
76
|
+
errors.append(LintError(code="tool_id_duplicate",
|
|
77
|
+
message=f"node '{n.node_id}' lists tool '{tid}' more than once",
|
|
78
|
+
location=n.node_id))
|
|
79
|
+
else:
|
|
80
|
+
tool_seen.add(tid)
|
|
81
|
+
# node-kind rule (non-LLM optimizer extension). A bad `kind` value is
|
|
82
|
+
# normally rejected by pydantic's enum coercion at construction — this is
|
|
83
|
+
# a defense-in-depth guard for a model_construct / hand-edit path that
|
|
84
|
+
# bypassed validation. We deliberately do NOT require non-empty
|
|
85
|
+
# system_prompt/model on LLM nodes: this project's config-decay contract
|
|
86
|
+
# (`host/adapters/helpers.cfg_decay` -> `KnobVote`) treats an empty
|
|
87
|
+
# system_prompt as "the agent owns its prompt" and a None model as "the
|
|
88
|
+
# agent's hardcoded default". Requiring them would regress the established
|
|
89
|
+
# "text-in/text-out node need not declare one" SpecBuilder contract and
|
|
90
|
+
# break every legacy text-in/text-out Spec on lint — violating the design's
|
|
91
|
+
# own "legacy Specs load unchanged" back-compat guarantee.
|
|
92
|
+
if not isinstance(n.kind, NodeKind):
|
|
93
|
+
errors.append(LintError(code="node_unknown_kind",
|
|
94
|
+
message=f"node '{n.node_id}' has an unknown kind {n.kind!r}",
|
|
95
|
+
location=n.node_id))
|
|
96
|
+
|
|
97
|
+
# --- edge endpoints + per-edge rules ------------------------------------
|
|
98
|
+
edge_keys: set[tuple[str, str, str]] = set()
|
|
99
|
+
adjacency: dict[str, list[str]] = defaultdict(list)
|
|
100
|
+
indegree: dict[str, int] = {nid: 0 for nid in node_ids}
|
|
101
|
+
|
|
102
|
+
for edge in spec.edges:
|
|
103
|
+
f, t = edge.from_, edge.to
|
|
104
|
+
key = (f, t, edge.type.value)
|
|
105
|
+
if key in edge_keys:
|
|
106
|
+
errors.append(LintError(code="duplicate_edge",
|
|
107
|
+
message=f"duplicate edge {f}->{t} ({edge.type.value})",
|
|
108
|
+
location=f"{f}->{t}"))
|
|
109
|
+
edge_keys.add(key)
|
|
110
|
+
|
|
111
|
+
if f not in node_ids:
|
|
112
|
+
errors.append(LintError(code="edge_unknown_from",
|
|
113
|
+
message=f"edge from unknown node '{f}' (to '{t}')",
|
|
114
|
+
location=f"{f}->{t}"))
|
|
115
|
+
if t not in node_ids:
|
|
116
|
+
errors.append(LintError(code="edge_unknown_to",
|
|
117
|
+
message=f"edge to unknown node '{t}' (from '{f}')",
|
|
118
|
+
location=f"{f}->{t}"))
|
|
119
|
+
if f == t:
|
|
120
|
+
errors.append(LintError(code="self_loop",
|
|
121
|
+
message=f"edge {f}->{t} is a self-loop",
|
|
122
|
+
location=f"{f}->{t}"))
|
|
123
|
+
if edge.type is EdgeType.CONDITIONAL and not edge.gate:
|
|
124
|
+
errors.append(LintError(code="conditional_no_gate",
|
|
125
|
+
message=f"conditional edge {f}->{t} has no `gate`",
|
|
126
|
+
location=f"{f}->{t}"))
|
|
127
|
+
|
|
128
|
+
# Only count well-formed edges toward the graph (avoids KeyError spam).
|
|
129
|
+
if f in node_ids and t in node_ids and f != t:
|
|
130
|
+
adjacency[f].append(t)
|
|
131
|
+
indegree[t] += 1
|
|
132
|
+
|
|
133
|
+
# --- cycle detection (Kahn's algorithm over the well-formed graph) -------
|
|
134
|
+
if _has_cycle(node_ids, adjacency, indegree):
|
|
135
|
+
# If a self-loop already explained one cycle, keep both; both are real.
|
|
136
|
+
errors.append(LintError(code="cycle",
|
|
137
|
+
message="execution graph contains a cycle",
|
|
138
|
+
location=None))
|
|
139
|
+
|
|
140
|
+
# --- orphan nodes (a multi-node spec must wire every node) ---------------
|
|
141
|
+
if len(spec.nodes) > 1:
|
|
142
|
+
touched: set[str] = set()
|
|
143
|
+
for edge in spec.edges:
|
|
144
|
+
if edge.from_ in node_ids:
|
|
145
|
+
touched.add(edge.from_)
|
|
146
|
+
if edge.to in node_ids:
|
|
147
|
+
touched.add(edge.to)
|
|
148
|
+
for n in spec.nodes:
|
|
149
|
+
if n.node_id not in touched:
|
|
150
|
+
errors.append(LintError(code="orphan_node",
|
|
151
|
+
message=f"node '{n.node_id}' is not connected to any edge",
|
|
152
|
+
location=n.node_id))
|
|
153
|
+
|
|
154
|
+
return errors
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def is_valid(spec: Spec) -> bool:
|
|
158
|
+
"""True iff the Spec has no structural defects."""
|
|
159
|
+
|
|
160
|
+
return not lint(spec)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _has_cycle(
|
|
164
|
+
node_ids: set[str],
|
|
165
|
+
adjacency: dict[str, list[str]],
|
|
166
|
+
indegree: dict[str, int],
|
|
167
|
+
) -> bool:
|
|
168
|
+
"""Kahn's: if we can't emit every node, a cycle exists."""
|
|
169
|
+
|
|
170
|
+
indeg = dict(indegree) # copy; Kahn mutates
|
|
171
|
+
q: deque[str] = deque(n for n, d in indeg.items() if d == 0)
|
|
172
|
+
emitted = 0
|
|
173
|
+
while q:
|
|
174
|
+
n = q.popleft()
|
|
175
|
+
emitted += 1
|
|
176
|
+
for nxt in adjacency.get(n, []):
|
|
177
|
+
indeg[nxt] -= 1
|
|
178
|
+
if indeg[nxt] == 0:
|
|
179
|
+
q.append(nxt)
|
|
180
|
+
return emitted != len(node_ids)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Provider-agnostic LLM abstraction (spec §3 Judge/Architect).
|
|
2
|
+
|
|
3
|
+
Both thinking components — the Judge and the Architect — talk
|
|
4
|
+
to a model only through the `LLMClient` protocol. This is the **single seam**
|
|
5
|
+
where "real vs fake" lives: tests plug in `ScriptedLLM` (deterministic, free,
|
|
6
|
+
crashable); a run plugs in a real provider via `make_client(provider)`.
|
|
7
|
+
|
|
8
|
+
The real provider adapters (`anthropic`, `openai`, `groq`, `gemini`) import their
|
|
9
|
+
SDKs lazily inside `__init__`, so importing THIS package never requires any
|
|
10
|
+
provider SDK to be installed — only an actual run with that provider does, at
|
|
11
|
+
which point a missing SDK surfaces as a clear `LLMError` rather than a bare
|
|
12
|
+
`ImportError`. The adapter classes are NOT re-exported here to keep the import
|
|
13
|
+
graph SDK-free; `make_client(provider)` resolves them by name on demand.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from archforge.config import ALL_PROVIDERS, REAL_PROVIDERS
|
|
21
|
+
from archforge.llm.base import (
|
|
22
|
+
Completion,
|
|
23
|
+
LLMClient,
|
|
24
|
+
LLMError,
|
|
25
|
+
Message,
|
|
26
|
+
Role,
|
|
27
|
+
Usage,
|
|
28
|
+
)
|
|
29
|
+
from archforge.llm.scripted import ScriptedLLM
|
|
30
|
+
|
|
31
|
+
# `REAL_PROVIDERS` / `ALL_PROVIDERS` are re-exported (below in __all__) straight
|
|
32
|
+
# from archforge.config — the single source of truth the CLI also imports.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def make_client(provider: str, **kwargs: Any) -> LLMClient:
|
|
36
|
+
"""Build a real `LLMClient` for `provider`.
|
|
37
|
+
|
|
38
|
+
Resolves the adapter lazily (importing the SDK is deferred to here). Raises
|
|
39
|
+
`LLMError` for an unknown provider so the CLI surfaces one clear message
|
|
40
|
+
across the provider seam. `kwargs` (api_key/base_url) pass straight through.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
if provider == "scripted":
|
|
44
|
+
raise LLMError("'scripted' has no real client; use ScriptedLLM directly")
|
|
45
|
+
table = {
|
|
46
|
+
"anthropic": "archforge.llm.anthropic:AnthropicClient",
|
|
47
|
+
"openai": "archforge.llm.openai:OpenAIClient",
|
|
48
|
+
"groq": "archforge.llm.groq:GroqClient",
|
|
49
|
+
"gemini": "archforge.llm.gemini:GeminiClient",
|
|
50
|
+
}
|
|
51
|
+
ref = table.get(provider)
|
|
52
|
+
if ref is None:
|
|
53
|
+
raise LLMError(f"unknown LLM provider {provider!r}; expected one of {REAL_PROVIDERS}")
|
|
54
|
+
mod_name, cls_name = ref.split(":")
|
|
55
|
+
import importlib
|
|
56
|
+
|
|
57
|
+
module = importlib.import_module(mod_name)
|
|
58
|
+
cls = getattr(module, cls_name)
|
|
59
|
+
return cls(**kwargs) # type: ignore[no-any-return]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"Completion", "LLMClient", "LLMError", "Message", "Role", "Usage", "ScriptedLLM",
|
|
64
|
+
"make_client", "REAL_PROVIDERS", "ALL_PROVIDERS",
|
|
65
|
+
]
|