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.
Files changed (47) hide show
  1. archforge/__init__.py +76 -0
  2. archforge/__main__.py +10 -0
  3. archforge/architect.py +442 -0
  4. archforge/cli.py +881 -0
  5. archforge/config.py +140 -0
  6. archforge/config_init.py +150 -0
  7. archforge/diff.py +206 -0
  8. archforge/engine.py +444 -0
  9. archforge/gatekeeper.py +290 -0
  10. archforge/host/__init__.py +20 -0
  11. archforge/host/adapters/__init__.py +41 -0
  12. archforge/host/adapters/base.py +311 -0
  13. archforge/host/adapters/helpers.py +163 -0
  14. archforge/host/adapters/langgraph.py +726 -0
  15. archforge/host/base.py +105 -0
  16. archforge/host/fake.py +380 -0
  17. archforge/judge/__init__.py +20 -0
  18. archforge/judge/base.py +257 -0
  19. archforge/judge/scripted.py +145 -0
  20. archforge/lint.py +180 -0
  21. archforge/llm/__init__.py +65 -0
  22. archforge/llm/_common.py +94 -0
  23. archforge/llm/anthropic.py +90 -0
  24. archforge/llm/base.py +90 -0
  25. archforge/llm/gemini.py +112 -0
  26. archforge/llm/groq.py +63 -0
  27. archforge/llm/openai.py +63 -0
  28. archforge/llm/scripted.py +134 -0
  29. archforge/middleware.py +181 -0
  30. archforge/models.py +435 -0
  31. archforge/mutate.py +214 -0
  32. archforge/otel.py +613 -0
  33. archforge/runlog.py +103 -0
  34. archforge/runner.py +153 -0
  35. archforge/spec_builder.py +126 -0
  36. archforge/stores/__init__.py +22 -0
  37. archforge/stores/_jsonl.py +81 -0
  38. archforge/stores/attempt_store.py +161 -0
  39. archforge/stores/spec_store.py +188 -0
  40. archforge/stores/trace_store.py +42 -0
  41. archforge/suite.py +248 -0
  42. archforge/userconfig.py +144 -0
  43. archforge_optimizer-0.1.0.dist-info/METADATA +420 -0
  44. archforge_optimizer-0.1.0.dist-info/RECORD +47 -0
  45. archforge_optimizer-0.1.0.dist-info/WHEEL +4 -0
  46. archforge_optimizer-0.1.0.dist-info/entry_points.txt +2 -0
  47. archforge_optimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ ]