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
archforge/__init__.py ADDED
@@ -0,0 +1,76 @@
1
+ """ArchForge — a self-improving meta-layer over multi-agent systems.
2
+
3
+ The **adapter kit** (the public API) lets an external project wrap any MAS as a
4
+ ``HostAdapter`` and evolve it; the core organs (Engine, Architect, Judge,
5
+ Gatekeeper, stores) drive the Propose-Evaluate-Commit loop. See
6
+ ``docs/superpowers/specs/2026-07-30-adapter-kit-design.md`` for the kit design.
7
+
8
+ Quick adapter sketch:
9
+
10
+ from archforge import (
11
+ BaseHostAdapter, BaseAgent, CallResult,
12
+ SpecBuilder, SEQUENCE, JOIN, run_loop, RunnerConfig,
13
+ )
14
+
15
+ class MyAgent(BaseAgent):
16
+ def call(self, prompt, cfg) -> CallResult: # the one per-node hook
17
+ ...
18
+
19
+ class MyAdapter(BaseHostAdapter):
20
+ def make_agent(self, node) -> BaseAgent: ...
21
+
22
+ spec = (SpecBuilder().node(...).edge("a","b", kind=SEQUENCE).build())
23
+ r = run_loop(MyAdapter(), spec, suite, config=RunnerConfig(provider="gemini"))
24
+ """
25
+ from __future__ import annotations
26
+
27
+ # ── the contract (host/base.py) — already the seam; re-exported so an adapter
28
+ # author imports everything from one place (`archforge`). Unchanged modules.
29
+ from archforge.host.base import Agent, AgentResponse, HostMAS, Runnable, Task
30
+
31
+ # ── the adapter kit (host/adapters) — what every adapter subclasses / uses.
32
+ from archforge.host.adapters import (
33
+ BaseAgent,
34
+ BaseHostAdapter,
35
+ BasePipeline,
36
+ CallResult,
37
+ KnobVote,
38
+ RunContext,
39
+ cfg_decay,
40
+ cfg_as_kwargs,
41
+ estimate_tokens,
42
+ run_id,
43
+ topo_order,
44
+ )
45
+
46
+ # ── Spec bootstrap DSL (spec_builder.py) — the universal floor.
47
+ from archforge.spec_builder import (
48
+ CONDITIONAL,
49
+ EdgeType,
50
+ FANOUT,
51
+ JOIN,
52
+ SEQUENCE,
53
+ SpecBuildError,
54
+ SpecBuilder,
55
+ )
56
+
57
+ # ── the run entrypoint (runner.py) — wraps the Engine for embedders.
58
+ from archforge.runner import RunnerConfig, run_cycle, run_loop
59
+
60
+ from archforge.config import VERSION as __version__ # noqa: F401 (public API)
61
+
62
+ __all__ = [
63
+ # contract
64
+ "HostMAS", "Agent", "Runnable", "Task", "AgentResponse",
65
+ # kit
66
+ "BaseHostAdapter", "BaseAgent", "BasePipeline", "CallResult",
67
+ "RunContext", "KnobVote", "cfg_decay", "cfg_as_kwargs",
68
+ "run_id", "topo_order", "estimate_tokens",
69
+ # Spec DSL
70
+ "SpecBuilder", "SpecBuildError", "EdgeType",
71
+ "JOIN", "FANOUT", "SEQUENCE", "CONDITIONAL",
72
+ # runner
73
+ "RunnerConfig", "run_cycle", "run_loop",
74
+ # version
75
+ "__version__",
76
+ ]
archforge/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Entry point for `python -m archforge`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from archforge.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
archforge/architect.py ADDED
@@ -0,0 +1,442 @@
1
+ """The Architect — the P-E-C "Propose" step (spec §3, §4, §6).
2
+
3
+ One cycle, one change. `next_attempt(...)` returns an `ArchitectResult`:
4
+
5
+ * status="proposed" — a candidate Spec (incumbent + ONE mutation) + a `Change`
6
+ record ready for the orchestrator to evaluate/commit.
7
+ * status="lint_rejected" — the candidate the Architect formed is structurally
8
+ invalid; the linter's reasons are returned. The
9
+ candidate NEVER reaches the SuiteRunner (spec E5).
10
+ * status="plateau" — no non-dedup-blocked proposal remains; the loop should
11
+ stop (spec E7/E8).
12
+
13
+ Boundaries that keep the optimizer safe:
14
+ * The Architect is WRITE-FREE over stores. It READS AttemptStore for dedup
15
+ (E7) but never appends an Attempt — the orchestrator does that, so
16
+ proposal and commitment stay cleanly separated.
17
+ * `Change` is the persisted *metadata* (kind/target/diff/rationale/scope); the
18
+ concrete edit lives in the candidate Spec returned alongside it, built with
19
+ `archforge.mutate`. dedup keys off (parent, kind, target).
20
+ * Structural change kinds auto-tag `scope=STRUCTURAL` (defined in
21
+ archforge.models) — that tag, not the Architect, drives the hybrid gate (I4).
22
+
23
+ The real `Architect` spends ONE LLM call per cycle (the locked budget); the
24
+ `ScriptedArchitect` decides the proposal deterministically for tests.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import Any, Literal, Protocol, Sequence, runtime_checkable
30
+
31
+ from pydantic import BaseModel, ConfigDict, Field
32
+
33
+ import archforge.models as m
34
+ from archforge.lint import LintError, lint
35
+ from archforge.llm.base import LLMClient, Message, Role
36
+ from archforge.mutate import apply_change
37
+ from archforge.stores.attempt_store import AttemptStore
38
+
39
+
40
+ # --------------------------------------------------------------------------- #
41
+ # Credit assignment — where did the run lose points?
42
+ # --------------------------------------------------------------------------- #
43
+
44
+
45
+ class CreditAssignment(BaseModel):
46
+ """The node/route blamed for the incumbent's rubric loss (Architect/credit-assignment input)."""
47
+
48
+ model_config = ConfigDict(extra="forbid")
49
+
50
+ node_id: str | None = None # the agent most responsible for the loss
51
+ route: str | None = None # "from->to" if a route is to blame
52
+ sub_rubric: str | None = None # the dimension lost
53
+ severity: float = 0.0 # 1.0 - blamed score, in [0, 1]
54
+
55
+
56
+ def credit_assign(
57
+ incumbent: m.Spec,
58
+ worst_task_scores: Sequence[m.RunScore],
59
+ ) -> CreditAssignment | None:
60
+ """Localize the rubric loss to a node from per-step StepScores.
61
+
62
+ Deterministic: across the worst task's repeat scores, average each node's
63
+ sub-rubric scores; return the (node, dimension) with the lowest mean. Returns
64
+ None if no step_scores were recorded (nothing to assign blame to).
65
+ """
66
+
67
+ if not worst_task_scores:
68
+ return None
69
+ # sum per (node, dimension) across repeats
70
+ acc: dict[str, dict[str, list[float]]] = {}
71
+ for score in worst_task_scores:
72
+ for ss in score.step_scores:
73
+ dims = acc.setdefault(ss.node_id, {})
74
+ for dim, val in ss.sub_rubrics.items():
75
+ dims.setdefault(dim, []).append(val)
76
+ if not acc:
77
+ return None
78
+
79
+ # find the (node, dim) with the lowest mean
80
+ worst_node, worst_dim, worst_mean = None, None, 1.0
81
+ for node_id, dims in acc.items():
82
+ for dim, vals in dims.items():
83
+ mean = sum(vals) / len(vals)
84
+ if mean < worst_mean:
85
+ worst_mean, worst_node, worst_dim = mean, node_id, dim
86
+ if worst_node is None:
87
+ return None
88
+ return CreditAssignment(node_id=worst_node, sub_rubric=worst_dim,
89
+ severity=1.0 - worst_mean)
90
+
91
+
92
+ # --------------------------------------------------------------------------- #
93
+ # Result + Proposal types
94
+ # --------------------------------------------------------------------------- #
95
+
96
+
97
+ ArchitectStatus = Literal["proposed", "lint_rejected", "plateau"]
98
+
99
+
100
+ class ArchitectProposal(BaseModel):
101
+ """A concrete candidate (incumbent + one mutation) + its Change metadata."""
102
+
103
+ model_config = ConfigDict(extra="forbid")
104
+
105
+ candidate: m.Spec
106
+ change: m.Change
107
+ blame: CreditAssignment | None = None
108
+
109
+
110
+ class ArchitectResult(BaseModel):
111
+ """The discriminated outcome of one P-E-C Propose step."""
112
+
113
+ model_config = ConfigDict(extra="forbid")
114
+
115
+ status: ArchitectStatus
116
+ proposal: ArchitectProposal | None = None
117
+ rejected_reasons: list[LintError] = Field(default_factory=list)
118
+ note: str | None = None # why we rejected/plateaued (for the report)
119
+
120
+ @property
121
+ def proposed(self) -> bool:
122
+ return self.status == "proposed"
123
+
124
+
125
+ # --------------------------------------------------------------------------- #
126
+ # Architect protocol — real + scripted both satisfy this
127
+ # --------------------------------------------------------------------------- #
128
+
129
+
130
+ @runtime_checkable
131
+ class ArchitectProtocol(Protocol):
132
+ def next_attempt(
133
+ self,
134
+ incumbent: m.Spec,
135
+ worst_task_scores: Sequence[m.RunScore],
136
+ *,
137
+ attempt_store: AttemptStore,
138
+ ) -> ArchitectResult: ...
139
+
140
+
141
+ # --------------------------------------------------------------------------- #
142
+ # Real Architect — one LLM call per cycle
143
+ # --------------------------------------------------------------------------- #
144
+
145
+
146
+ class Architect:
147
+ """LLM-backed P-E-C proposer. One `complete()` call per `next_attempt`."""
148
+
149
+ def __init__(self, llm: LLMClient, *, model: str) -> None:
150
+ self._llm = llm
151
+ self._model = model
152
+
153
+ def next_attempt(
154
+ self,
155
+ incumbent: m.Spec,
156
+ worst_task_scores: Sequence[m.RunScore],
157
+ *,
158
+ attempt_store: AttemptStore,
159
+ ) -> ArchitectResult:
160
+ blame = credit_assign(incumbent, worst_task_scores)
161
+ proposal = self._propose(incumbent, blame)
162
+ if proposal is None:
163
+ return _plateau("architect produced no proposal")
164
+
165
+ change = _change_from_payload(incumbent, proposal)
166
+ if change is None:
167
+ return _plateau("architect returned an unparseable change")
168
+
169
+ # Kind gate (shared): prompt_edit/model_swap apply ONLY to llm nodes.
170
+ mismatch = _kind_mismatch_note(incumbent, change)
171
+ if mismatch is not None:
172
+ return _plateau(mismatch)
173
+
174
+ # The LLM nests the edit under `payload`; mutate reads the inner fields.
175
+ edit = proposal.get("payload")
176
+ if not isinstance(edit, dict):
177
+ edit = {k: v for k, v in proposal.items() if k not in {"kind", "target", "rationale"}}
178
+
179
+ # structural mistakes that survive this far -> lint_rejected (E5)
180
+ try:
181
+ candidate = apply_change(incumbent, change, edit)
182
+ except Exception as exc: # noqa: BLE001 — mutate is defensive
183
+ return _lint_rejected(_as_lint_errors(f"mutation failed: {exc}"))
184
+
185
+ errors = lint(candidate)
186
+ if errors:
187
+ return _lint_rejected(errors)
188
+
189
+ # dedup: skip a change already rejected/rolled-back on (parent, kind, target) (E7)
190
+ blocked = attempt_store.blocking(
191
+ incumbent.spec_id or incumbent.compute_spec_id(),
192
+ change.kind.value, change.target,
193
+ )
194
+ if blocked:
195
+ return _plateau(
196
+ f"(parent={incumbent.spec_id}, kind={change.kind.value}, "
197
+ f"target={change.target}) already tried: "
198
+ f"{[a.attempt_id for a in blocked]}"
199
+ )
200
+
201
+ return ArchitectResult(
202
+ status="proposed",
203
+ proposal=ArchitectProposal(candidate=candidate, change=change, blame=blame),
204
+ )
205
+
206
+ # --------------------------------------------------------------- internals
207
+ def _propose(
208
+ self, incumbent: m.Spec, blame: CreditAssignment | None
209
+ ) -> dict[str, Any] | None:
210
+ """One LLM call → a structured change payload. None on no proposal.
211
+
212
+ A valid-JSON-but-not-a-change response (no `kind`) is *not* a provider
213
+ outage: the model answered, just without a usable change this cycle. We
214
+ return the dict and let `_change_from_payload` turn the missing `kind`
215
+ into a `None` → plateau (E7/E8). A true outage (non-JSON) raises from the
216
+ client itself and propagates as `LLMError` for the loop to retry (E9).
217
+ """
218
+ messages = self._build_messages(incumbent, blame)
219
+ completion = self._llm.complete(
220
+ messages, model=self._model, temperature=0.2, response_format="json",
221
+ )
222
+ parsed = completion.parsed
223
+ return parsed if isinstance(parsed, dict) else None
224
+
225
+ def _build_messages(
226
+ self, incumbent: m.Spec, blame: CreditAssignment | None
227
+ ) -> list[Message]:
228
+ blame_text = (
229
+ f"Blame: node='{blame.node_id}', sub_rubric='{blame.sub_rubric}', "
230
+ f"severity={blame.severity:.2f}.\n"
231
+ if blame else "No clear blame node (runs near-perfect). Be conservative.\n"
232
+ )
233
+ return [
234
+ Message(
235
+ role=Role.SYSTEM,
236
+ content=(
237
+ "You are the Architect of a multi-agent pipeline optimizer. "
238
+ "Propose EXACTLY ONE small change to improve the incumbent Spec. "
239
+ "Return JSON: {kind, target, rationale, payload}. `kind` ∈ "
240
+ "{prompt_edit, knob, add_node, remove_node, rewire, model_swap}. "
241
+ "`target` = node_id (or 'from,to' for rewire). `payload` carries "
242
+ "the edit: {prompt} | {knobs:{...}} | {model} | {node, wiring} | "
243
+ "{remove:[from,to], add:[from,to,type]}. Each Spec node has a "
244
+ "`kind` ∈ {llm, rule, retriever, tool, symbolic} and a `tunable` "
245
+ "allowlist of the EXTRA knobs it owns. `prompt_edit`/`model_swap` "
246
+ "apply ONLY to `llm` nodes; `knob` may set `temperature`/`retries`/"
247
+ "`max_tokens` on ANY node, plus any EXTRA key listed in that node's "
248
+ "`tunable` (any other extra key is rejected); for non-`llm` nodes "
249
+ "prefer `knob` edits to the node's own params (`top_k`,`threshold`,…) "
250
+ "or graph edits. Make the change address the blame."
251
+ ),
252
+ ),
253
+ Message(
254
+ role=Role.USER,
255
+ content=(
256
+ f"INCUMBENT SPEC (nodes + edges):\n{_spec_summary(incumbent)}\n\n"
257
+ + blame_text
258
+ + "Return the one-change JSON."
259
+ ),
260
+ ),
261
+ ]
262
+
263
+
264
+ # --------------------------------------------------------------------------- #
265
+ # ScriptedArchitect — deterministic, for tests
266
+ # --------------------------------------------------------------------------- #
267
+
268
+
269
+ class ScriptedArchitect:
270
+ """Deterministic Architect whose proposals are scripted by the test.
271
+
272
+ `propose(change, payload)` queues a proposal (popped per next_attempt). Use
273
+ `force_lint_error()` to make the next proposal intentionally malformed (E5).
274
+ `force_plateau()` makes the next call return a plateau. If the queue is empty
275
+ when next_attempt is called, it plateaus.
276
+ """
277
+
278
+ def __init__(self) -> None:
279
+ self._queue: list[tuple[m.Change, dict[str, Any]]] = []
280
+ self._lint_error: bool = False
281
+ self._force_plateau: bool = False
282
+ self.calls: list[m.Spec] = [] # incumbents seen, for assertions
283
+
284
+ def propose(self, change: m.Change, payload: dict[str, Any]) -> "ScriptedArchitect":
285
+ self._queue.append((change, payload))
286
+ return self
287
+
288
+ def force_lint_error(self) -> "ScriptedArchitect":
289
+ self._lint_error = True
290
+ return self
291
+
292
+ def force_plateau(self) -> "ScriptedArchitect":
293
+ self._force_plateau = True
294
+ return self
295
+
296
+ def next_attempt(
297
+ self,
298
+ incumbent: m.Spec,
299
+ worst_task_scores: Sequence[m.RunScore],
300
+ *,
301
+ attempt_store: AttemptStore,
302
+ ) -> ArchitectResult:
303
+ self.calls.append(incumbent)
304
+ if self._force_plateau:
305
+ self._force_plateau = False
306
+ return _plateau("scripted plateau")
307
+ if not self._queue:
308
+ return _plateau("no scripted proposal left")
309
+
310
+ change, payload = self._queue.pop(0)
311
+ if self._lint_error:
312
+ self._lint_error = False
313
+ # build something the linter will reject (orphan edge to a ghost node)
314
+ return _lint_rejected(_as_lint_errors("scripted malformed candidate"))
315
+
316
+ blame = credit_assign(incumbent, worst_task_scores)
317
+
318
+ # Kind gate (shared with the real Architect): prompt_edit/model_swap on a
319
+ # non-llm node plateaus deterministically — nothing committed. Mirrors the
320
+ # real path's `_kind_mismatch_note` so a scripted E2E exercises the same
321
+ # rule (the plan's verification step 5).
322
+ mismatch = _kind_mismatch_note(incumbent, change)
323
+ if mismatch is not None:
324
+ return _plateau(mismatch)
325
+
326
+ try:
327
+ candidate = apply_change(incumbent, change, payload)
328
+ except Exception as exc: # noqa: BLE001
329
+ return _lint_rejected(_as_lint_errors(f"mutation failed: {exc}"))
330
+
331
+ errors = lint(candidate)
332
+ if errors:
333
+ return _lint_rejected(errors)
334
+
335
+ blocked = attempt_store.blocking(
336
+ incumbent.spec_id or incumbent.compute_spec_id(),
337
+ change.kind.value, change.target,
338
+ )
339
+ if blocked:
340
+ return _plateau(
341
+ f"scripted change already tried on (parent={incumbent.spec_id}, "
342
+ f"kind={change.kind.value}, target={change.target})"
343
+ )
344
+
345
+ return ArchitectResult(
346
+ status="proposed",
347
+ proposal=ArchitectProposal(candidate=candidate, change=change, blame=blame),
348
+ )
349
+
350
+
351
+ # --------------------------------------------------------------------------- #
352
+ # helpers
353
+ # --------------------------------------------------------------------------- #
354
+
355
+
356
+ def _change_from_payload(incumbent: m.Spec, payload: dict[str, Any]) -> m.Change | None:
357
+ """Build a Change (metadata) from the LLM's structured proposal + rationale."""
358
+ try:
359
+ kind = m.ChangeKind(payload["kind"])
360
+ target = str(payload["target"])
361
+ rationale = str(payload.get("rationale", ""))
362
+ except (KeyError, ValueError):
363
+ return None
364
+ diff = _describe_diff(kind, target, payload.get("payload", payload))
365
+ return m.Change.for_kind(kind, target, diff, rationale)
366
+
367
+
368
+ def _describe_diff(kind: m.ChangeKind, target: str, payload: Any) -> str:
369
+ if kind is m.ChangeKind.PROMPT_EDIT:
370
+ return f"rewrite system_prompt of '{target}'"
371
+ if kind is m.ChangeKind.KNOB:
372
+ return f"tune knobs of '{target}'"
373
+ if kind is m.ChangeKind.MODEL_SWAP:
374
+ return f"swap model on '{target}'"
375
+ if kind is m.ChangeKind.ADD_NODE:
376
+ return f"add node '{target}'"
377
+ if kind is m.ChangeKind.REMOVE_NODE:
378
+ return f"remove node '{target}'"
379
+ if kind is m.ChangeKind.REWIRE:
380
+ return f"rewire edge at '{target}'"
381
+ return f"{kind.value} on '{target}'"
382
+
383
+
384
+ def _node_opt(spec: m.Spec, node_id: str) -> m.Node | None:
385
+ """Safe node lookup that returns None instead of raising (decoupled from
386
+ mutate's private `_node`). Used by the kind gate so a missing target falls
387
+ through to the normal apply/lint path instead of being mis-plateaued here."""
388
+ return next((n for n in spec.nodes if n.node_id == node_id), None)
389
+
390
+
391
+ def _kind_mismatch_note(spec: m.Spec, change: m.Change) -> str | None:
392
+ """Kind gate for prompt_edit/model_swap: they apply ONLY to ``llm`` nodes.
393
+
394
+ A change of one of those kinds aimed at a non-llm node is valid metadata
395
+ (not "unparseable") but a kind mismatch, so the candidate should plateau with
396
+ a precise note (same E7/E8 outcome, nothing committed) rather than mutate
397
+ silently editing an empty prompt/model on a node that ignores it (a no-op
398
+ candidate masquerading as a real change). Returns the plateau note when the
399
+ change must be dropped, or ``None`` to proceed. A missing target is NOT a
400
+ kind mismatch — return None so it falls through to apply/lint below, which
401
+ rejects the structural defect instead of being mis-plateaued here.
402
+
403
+ Shared by the real ``Architect`` and ``ScriptedArchitect`` so the rule is
404
+ enforced uniformly and is deterministically testable through the scripted path.
405
+ """
406
+ if change.kind not in (m.ChangeKind.PROMPT_EDIT, m.ChangeKind.MODEL_SWAP):
407
+ return None
408
+ tgt = _node_opt(spec, change.target)
409
+ if tgt is None or tgt.kind is m.NodeKind.LLM:
410
+ return None
411
+ return (f"{change.kind.value} on non-llm node '{change.target}' "
412
+ f"(kind={tgt.kind.value})")
413
+
414
+
415
+ def _spec_summary(spec: m.Spec) -> str:
416
+ nodes = "\n".join(
417
+ f"- {n.node_id} [{n.role}] kind={n.kind.value} model={n.model or '—'} "
418
+ f"tunable={list(n.knobs.tunable) or '—'} prompt={n.system_prompt!r}"
419
+ for n in spec.nodes
420
+ )
421
+ edges = "\n".join(f"- {e.from_} -> {e.to} ({e.type.value})" for e in spec.edges)
422
+ return f"NODES:\n{nodes}\nEDGES:\n{edges}"
423
+
424
+
425
+ def _plateau(note: str) -> ArchitectResult:
426
+ return ArchitectResult(status="plateau", note=note)
427
+
428
+
429
+ def _lint_rejected(reasons: list[LintError]) -> ArchitectResult:
430
+ return ArchitectResult(status="lint_rejected", rejected_reasons=reasons,
431
+ note="candidate failed the Spec Linter")
432
+
433
+
434
+ def _as_lint_errors(text: str) -> list[LintError]:
435
+ return [LintError(code="self_loop", message=text, location=None)]
436
+
437
+
438
+ __all__ = [
439
+ "CreditAssignment", "credit_assign",
440
+ "ArchitectProposal", "ArchitectResult", "ArchitectStatus",
441
+ "ArchitectProtocol", "Architect", "ScriptedArchitect",
442
+ ]