Topology Optimization API¶
spec ¶
Serializable IR for Orqest's orchestration primitives.
Closes the gap that an LLM cannot emit a composition topology at runtime because Pipeline / Parallel / Router / RefinementLoop accept Python callables (conditions, merges, state-updaters) that have no JSON representation.
This module provides Pydantic v2 models that round-trip cleanly to JSON and
hydrate back to live runtime objects via :mod:orqest.orchestration.hydrate.
Design:
- One discriminated union (:data:
OperationSpec) covers every shape that can appear in a step position — atomic (AgentStepSpec/FunctionStepSpec) and composite (PipelineSpec/ParallelSpec/RouterSpec/RefinementLoopSpec). The single union keeps recursion uniform: a Pipeline's step list, a Route's step, and a RefinementLoop's step all accept the same type. - All callables go through name registries. Conditions, merges, state
updaters, and FunctionStep functions are referenced by string name; the
hydrator resolves them against a :class:
CallableRegistry. Agents are referenced either by registry name (string) or inlined as an :class:AgentSpecfor spawn-fresh-per-execution. This is the load-bearing safety property: the meta agent's emission surface is names from an allowlist, never code. - TopologySpec is the narrower top-level union (composites only) — used
by :class:
TopologyGeneto type the genome's seed.
OperationSpec
module-attribute
¶
OperationSpec = Annotated[
"AgentStepSpec | FunctionStepSpec | PipelineSpec | ParallelSpec | RouterSpec | RefinementLoopSpec",
Field(discriminator="kind"),
]
Any composable operation. Both atomic (AgentStep / FunctionStep) and
composite (Pipeline / Parallel / Router / RefinementLoop) shapes share this
union so recursion is uniform: a Pipeline's step entry, a Route's step,
and a RefinementLoop's step all accept OperationSpec.
The string-form annotation defers Pydantic's discriminator resolution until all four composite classes are defined (forward references).
TopologySpec
module-attribute
¶
TopologySpec = Annotated[
PipelineSpec
| ParallelSpec
| RouterSpec
| RefinementLoopSpec,
Field(discriminator="kind"),
]
The narrower top-level union — composite topologies only. Used by
:class:TopologyGene to type the genome seed: a meta-agent-emitted candidate
must be one of the four composite shapes (an atomic AgentStep alone is not
a "topology" worth evolving).
StepConfigSpec ¶
Per-step error-handling config used inside :class:PipelineSpec.
Mirrors :class:orqest.orchestration.types.StepConfig but with a string
on_error literal instead of the runtime ErrorStrategy Enum (so it
serializes cleanly to JSON).
AgentStepSpec ¶
An atomic step that wraps a single :class:BaseAgent.
The agent is resolved by agent_name against the hydrator's agent
registry, OR constructed fresh from inline_spec (an :class:AgentSpec).
Exactly one of the two must be present — the validator enforces this.
FunctionStepSpec ¶
An atomic step that wraps a registered async callable.
PipelineStepEntry ¶
One entry inside a :class:PipelineSpec's steps list.
Wraps an :data:OperationSpec with optional per-step :class:StepConfigSpec.
Keeping the entry shape uniform makes JSON emission predictable for the
meta agent (no mixed-list-shape footgun).
PipelineSpec ¶
Sequential composition: each entry's output feeds the next entry's input.
Hydrates to :class:orqest.orchestration.pipeline.Pipeline.
steps
class-attribute
instance-attribute
¶
At least one step is required. Empty pipelines raise at hydration
(mirroring :class:Pipeline's own ValueError).
ParallelSpec ¶
Concurrent composition: all entries run on input_data, results merge.
Hydrates to :class:orqest.orchestration.parallel.Parallel.
merge
class-attribute
instance-attribute
¶
Merge strategy name. "collect_all" and "first_wins" resolve to
:class:orqest.orchestration.parallel.MergeStrategy built-ins; any other
value is looked up in the hydrator's callable_registry.
timeout
class-attribute
instance-attribute
¶
Wall-clock timeout in seconds. None means no limit.
RouteSpec ¶
One named branch in a :class:RouterSpec.
The step may be an atomic (AgentStep / FunctionStep) or another nested
topology — the hydrator wraps composite operations in a Step adapter so
they conform to the :class:Step protocol.
condition_name
class-attribute
instance-attribute
¶
Registered predicate name; None makes this route classifier-only.
The :class:RouterSpec itself enforces: at least one route must have
a condition_name OR a classifier must be set.
RouterSpec ¶
Dispatch input to one of N routes via classifier or rule conditions.
Hydrates to :class:orqest.orchestration.router.Router.
classifier
class-attribute
instance-attribute
¶
Optional classifier. str is an agent_registry lookup; an
:class:AgentSpec spawns fresh. The :class:Router constructor requires
EITHER a classifier OR at least one route with a condition — the hydrator
surfaces the underlying ValueError if neither is present.
fallback_step
class-attribute
instance-attribute
¶
Step to run when no route matches. None raises RouterError on
no-match.
RefinementLoopSpec ¶
Iterate a step with evaluator feedback until convergence or limits.
Hydrates to :class:orqest.orchestration.loop.RefinementLoop.
evaluator
instance-attribute
¶
Required evaluator. str is an agent_registry name (the agent must
return an :class:EvalResult-shaped output); an :class:AgentSpec spawns
fresh. A pure-callable evaluator is also accepted via the registry — pass
its name as the str and have it resolve via the agent_registry's
callable-fallback (or use a tiny BaseAgent wrapper around it).
state_updater_name
instance-attribute
¶
Required callable_registry name for the
(current_input, output, eval_result) -> next_input updater.
hydrate ¶
Hydrate :mod:orqest.orchestration.spec Pydantic models into runtime objects.
The spec layer is the canonical serialization surface; this module owns the deserialization side. Two responsibilities:
- :class:
CallableRegistry— a name→callable allowlist. The meta-agent emits names from this registry's keys; the hydrator looks them up here. Noeval/execever runs — the security perimeter is "names from a user-controlled allowlist," nothing more. - :func:
topology_from_spec— dispatches onspec.kindand recursively builds a live Pipeline / Parallel / Router / RefinementLoop. Nested composites (a Pipeline inside a Route, a Parallel inside a RefinementLoop) are wrapped in :class:_TopologyAsStepso they conform to the :class:Stepprotocol the runtime classes expect.
Agents are resolved two ways:
- By name against
agent_registry— adict[str, Callable[[], BaseAgent]]of factories (not instances). Factories match the existing pattern used by :class:Evaluator.agent_factoryand avoid cached-_agentproblems when the same logical agent is instantiated multiple times across a search. - By inline :class:
AgentSpec— spawned fresh via :class:AgentFactory. Requires a factory to be provided to the hydrator.
CallableRegistry ¶
Name → callable map. Explicit, no introspection magic.
The meta agent's design step receives registry.names() as its allowed
vocabulary; any name it emits MUST be in here, or hydration raises
:class:KeyError. This is the load-bearing security property of the W3.A
layer: there is no eval, no exec, no name forgery — the registry holds
the actual callables, the meta agent can only refer to them by name.
Source code in orqest/orchestration/hydrate.py
register ¶
Register fn under name. Re-registering replaces silently.
Source code in orqest/orchestration/hydrate.py
get ¶
Look up name. Raises :class:KeyError with all known names on miss.
Source code in orqest/orchestration/hydrate.py
pipeline_from_spec ¶
pipeline_from_spec(
spec: PipelineSpec,
*,
callable_registry: CallableRegistry,
agent_registry: dict[str, Callable[[], BaseAgent]],
agent_factory: AgentFactory | None = None,
) -> Pipeline
Hydrate a :class:PipelineSpec into a live :class:Pipeline.
Source code in orqest/orchestration/hydrate.py
parallel_from_spec ¶
parallel_from_spec(
spec: ParallelSpec,
*,
callable_registry: CallableRegistry,
agent_registry: dict[str, Callable[[], BaseAgent]],
agent_factory: AgentFactory | None = None,
) -> Parallel
Hydrate a :class:ParallelSpec into a live :class:Parallel.
Source code in orqest/orchestration/hydrate.py
router_from_spec ¶
router_from_spec(
spec: RouterSpec,
*,
callable_registry: CallableRegistry,
agent_registry: dict[str, Callable[[], BaseAgent]],
agent_factory: AgentFactory | None = None,
) -> Router
Hydrate a :class:RouterSpec into a live :class:Router.
Source code in orqest/orchestration/hydrate.py
refinement_loop_from_spec ¶
refinement_loop_from_spec(
spec: RefinementLoopSpec,
*,
callable_registry: CallableRegistry,
agent_registry: dict[str, Callable[[], BaseAgent]],
agent_factory: AgentFactory | None = None,
) -> RefinementLoop
Hydrate a :class:RefinementLoopSpec into a live :class:RefinementLoop.
Source code in orqest/orchestration/hydrate.py
topology_from_spec ¶
topology_from_spec(
spec: TopologySpec,
*,
callable_registry: CallableRegistry,
agent_registry: dict[str, Callable[[], BaseAgent]],
agent_factory: AgentFactory | None = None,
) -> Pipeline | Parallel | Router | RefinementLoop
Dispatch on spec.kind and hydrate to the right runtime class.
Recursive: nested composites inside step positions are hydrated and
wrapped via :class:_TopologyAsStep so the parent runtime sees a Step.
Source code in orqest/orchestration/hydrate.py
topology ¶
Topology evolution: gene + evaluator atop the orchestration spec layer.
Two pieces:
- :class:
TopologyGene— a Pydantic gene whose value is a serialized :data:TopologySpecJSON string. Mirrors the encode/decode contract of :class:PromptGene, so it slots into existing :class:Genomemachinery (when the consumer wants to combine prompt + topology evolution in one GEPA run via :class:TopologyGEPAAdapter). - :class:
TopologyEvaluator— an :class:Evaluatorsubclass that hydrates the genome's topology spec into a live runtime, runs it on each example, and unpacks composite results (ParallelResult.merged/LoopResult.output) before scoring. Addsn_agentsanddepthto :attr:MetricBundle.rawas proxies for cost / latency ceilings — these flow through to the optimizer's Pareto frontier without any further wiring.
YAGNI note: :class:TopologyGene is not added to the existing :data:Gene
discriminated union in :mod:orqest.optimization.genome. The recommended path
for combining ADAS+GEPA is the two-phase one (ADAS first, GEPA on the winner),
which uses two distinct genome shapes — there's no need for a unified Gene
union today. If users prove the combined-single-genome case matters, extending
the union is a one-line change.
TopologyGene ¶
A topology-shaped gene whose initial value is a :data:TopologySpec.
Encodes to JSON (the GEPA wire format is dict[str, str]) and decodes
back via Pydantic validation. Resilient on malformed reflection output:
a candidate that fails JSON parsing or schema validation falls back to
the gene's initial rather than raising — same defensive posture as
:class:PromptGene (we'd rather lose one iteration than crash a search).
name
instance-attribute
¶
Logical slot identifier — the key the meta agent emits and the
:class:TopologyEvaluator reads from the decoded genome.
initial
instance-attribute
¶
Seed topology. The meta-agent's W0 candidate; also the resilient fallback for malformed reflection output.
constraints
class-attribute
instance-attribute
¶
Optional natural-language guardrail surfaced to the meta agent in its design prompt (e.g. "must include the existing classifier as a Pipeline first step").
allowed_step_kinds
class-attribute
instance-attribute
¶
Whitelisted leaf-step kinds. Surfaced to the meta agent in its prompt as the only legal atomic operations. Default permits both.
max_depth
class-attribute
instance-attribute
¶
Maximum nesting depth the meta agent is allowed to emit. Past this
depth the evaluator rejects the candidate (max_depth_exceeded error)
so the optimizer pivots away from runaway-recursion designs.
encode ¶
decode ¶
Decode a meta-agent-proposed JSON string back to a TopologySpec.
Three failure modes, all resilient:
None(key missing from the candidate dict) →initial- Malformed JSON →
initial - Valid JSON but failing TopologySpec schema →
initial
Source code in orqest/optimization/topology.py
TopologyEvaluator ¶
TopologyEvaluator(
*,
score_fn: Callable[
[Any, GoldExample[Any, Any]],
float | Awaitable[float],
],
callable_registry: CallableRegistry,
agent_registry: dict[
str, Callable[[], BaseAgent[Any, Any]]
],
topology_gene_name: str = "main",
agent_factory: AgentFactory | None = None,
confidence_protocol: ConfidenceProtocol | None = None,
cost_estimator: Callable[[Any], float] | None = None,
timer: Callable[[], float] = time.monotonic,
)
Score a topology candidate against gold examples.
Replaces :class:Evaluator's single-BaseAgent execution path with topology
hydration. Adds n_agents and depth structural metrics to
:attr:MetricBundle.raw so the optimizer's Pareto front can prefer
smaller / shallower topologies on accuracy ties.
Cost handling: Unlike single-agent evaluation, a topology run does
not have a single Usage object. We surface cost_usd=0.0 by
default; consumers wanting cost-as-fitness should pass a
cost_estimator callable that walks the topology and sums per-agent
usage (see the concept doc for the recipe). This is a known limitation
documented in the W3 risks section.
Wire the per-evaluation building blocks.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score_fn
|
Callable[[Any, GoldExample[Any, Any]], float | Awaitable[float]]
|
|
required |
callable_registry
|
CallableRegistry
|
Registry of named conditions / merges / state-updaters / function-steps. The meta agent's allowlist. |
required |
agent_registry
|
dict[str, Callable[[], BaseAgent[Any, Any]]]
|
Map from agent name to a factory producing a
fresh :class: |
required |
topology_gene_name
|
str
|
Key under which the decoded genome carries
the :data: |
'main'
|
agent_factory
|
AgentFactory | None
|
Optional :class: |
None
|
confidence_protocol
|
ConfidenceProtocol | None
|
Reserved for future use — topologies don't produce confidence directly today (would require per-step EnrichedOutput plumbing); accepted for signature parity. |
None
|
cost_estimator
|
Callable[[Any], float] | None
|
Optional callable for translating downstream usage into USD. See class docstring caveat. |
None
|
timer
|
Callable[[], float]
|
Monotonic clock; override only in tests. |
monotonic
|
Source code in orqest/optimization/topology.py
evaluate_one
async
¶
Hydrate the topology, run on the example, return a MetricBundle.
Failures (hydration errors, runtime errors, score errors) yield a
zero-accuracy bundle with the failure captured in
:attr:MetricBundle.raw["error"]. This matches the parent's
never-raise contract — GEPA's adapter Protocol forbids raising on
per-example evaluation failures.
Source code in orqest/optimization/topology.py
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 | |
unpack_topology_output ¶
Extract the meaningful payload from a topology's .run() result.
- :class:
ParallelResult→.merged(the merge-strategy output) - :class:
LoopResult→.output(the converged value) - :class:
AgentRunResult(if a topology somehow returns one) →.output - Anything else → identity (Pipeline / Router return their final value)
Source code in orqest/optimization/topology.py
meta_agent ¶
ADAS-style Meta Agent Search loop for topology evolution.
Faithful to ADAS in shape (design → reflexion → evaluate → archive,
arXiv 2408.08435) but not in surface:
the meta agent emits typed :data:TopologySpec JSON, never raw Python. This
sidesteps ADAS's exec()-in-process gap (paper claimed containerization;
public repo ships in-process exec) — there is no code to sandbox because
there is no code, only Pydantic-validated structure.
The loop owes its archive-strategy menu to the
2510.06711 critique:
top_k (default) and parallel outperform ADAS's original cumulative
on most benchmarks. Users who want to reproduce the ADAS paper verbatim flip
MetaAgentConfig.archive_strategy="cumulative".
Three pieces in this module:
- :class:
MetaAgentConfig— frozen dataclass, search-loop knobs. - :class:
Archive+ :class:ArchiveEntry— manages the candidate population with strategy-pluggable retention and prompt-serialization. - :class:
MetaAgentSearch— the loop. Returns an :class:OptimizationResultshaped identically to GEPA's so downstream consumers (apply_result, notebook visualizations) work without dispatch.
MetaAgentConfig
dataclass
¶
MetaAgentConfig(
n_generations: int = 10,
archive_strategy: ArchiveStrategy = "top_k",
archive_size: int = 5,
reflexion_passes: int = 2,
debug_max: int = 3,
minibatch_size: int = 5,
seed: int = 42,
weights: MetricWeights = MetricWeights(),
)
Knobs for :class:MetaAgentSearch.
Distinct from :class:OptimizationConfig (which governs GEPA's prompt
evolution loop) because the search-loop parameters are different in kind
— generations of designed-and-evaluated topologies, not GEPA's sampled
minibatches.
n_generations
class-attribute
instance-attribute
¶
Number of design-evaluate cycles after the seed evaluation.
archive_strategy
class-attribute
instance-attribute
¶
How to surface the archive to the next design step.
"top_k"(default) — show only the toparchive_sizeby aggregate score. Per the 2510.06711 critique, the strongest signal-to- noise ratio for the meta agent."cumulative"— show every entry seen so far. ADAS-paper-faithful; tends to overflow context after ~20 entries and dilute strong signals."parallel"— show nothing. Meta agent designs from scratch each generation; selection happens at the end. Surprisingly competitive per the critique.
archive_size
class-attribute
instance-attribute
¶
Cap for top_k and the ring-buffer for cumulative if you want
one (unlimited by default for cumulative). Ignored for parallel.
reflexion_passes
class-attribute
instance-attribute
¶
Number of reflexion-revision passes after the design step. ADAS uses 2 by default; 0 disables reflexion entirely (raw design only).
debug_max
class-attribute
instance-attribute
¶
How many times to retry a generation that fails Pydantic validation or hydration. The ValidationError surface is fed back to the meta agent as feedback (analogue of ADAS's traceback retry).
minibatch_size
class-attribute
instance-attribute
¶
Validation subset size per candidate evaluation. Caps cost: each candidate evaluates against this many examples (sampled deterministically from the valset), not the full set. The seed and final winner are re-evaluated against the full set.
seed
class-attribute
instance-attribute
¶
Random seed for minibatch sampling and reproducibility.
weights
class-attribute
instance-attribute
¶
Aggregation weights for converting per-example MetricBundles to a scalar score for archive ranking. Defaults match GEPA's recommended weights so users see consistent ranking across optimizers.
ArchiveEntry ¶
One archived candidate.
generation
instance-attribute
¶
Generation index. -1 means the seed (pre-search baseline).
spec_json
instance-attribute
¶
The TopologySpec serialized to JSON — the GEPA wire format.
bundles
instance-attribute
¶
Per-example MetricBundles from this entry's evaluation.
aggregate_score
instance-attribute
¶
Mean scalarized score across bundles — the archive ranking key.
thought
class-attribute
instance-attribute
¶
Meta-agent's design reasoning. Empty for the seed.
parent_idx
class-attribute
instance-attribute
¶
Index of the archive entry whose design inspired this one. None
for the seed and for parallel-strategy entries (no parent context).
TopologyDesign ¶
Structured output for the meta agent.
The meta agent's pydantic-ai :class:Agent returns this shape every
design / reflexion / debug step.
Archive ¶
Strategy-pluggable archive of evaluated candidates.
All entries are retained in storage (so :meth:pareto and :meth:best
see the full history); serialize_for_prompt is the strategy-aware
view shown to the meta agent on the next design step.
Source code in orqest/optimization/meta_agent.py
add ¶
serialize_for_prompt ¶
The archive view passed to the meta agent for its next design.
Strategy-dependent:
"cumulative"— every entry, JSON-serialized."top_k"— topsizeentries by aggregate_score, JSON-serialized."parallel"— empty string. Meta agent designs without context.
Source code in orqest/optimization/meta_agent.py
best ¶
The highest-aggregate-score entry. Raises if empty.
pareto ¶
Distinct entries on the accuracy / cost / latency Pareto front.
An entry is on the front if no other entry dominates it (strictly better on every axis). We negate cost and latency so "more is better" applies uniformly.
Source code in orqest/optimization/meta_agent.py
MetaAgentSearch ¶
MetaAgentSearch(
config: MetaAgentConfig,
*,
gene: TopologyGene,
evaluator: TopologyEvaluator,
meta_agent_model: str,
api_key: str,
bus: EventBus | None = None,
)
Run an ADAS-style search over :data:TopologySpec candidates.
Three-stage generation: design → reflexion (×N) → evaluate (with debug
retry). Pydantic ValidationError + hydration KeyError are caught and fed
back to the meta agent as debug feedback. Non-debuggable failures
(after debug_max retries) skip the generation; the search continues.
Wire the search.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
MetaAgentConfig
|
Frozen :class: |
required |
gene
|
TopologyGene
|
The :class: |
required |
evaluator
|
TopologyEvaluator
|
A :class: |
required |
meta_agent_model
|
str
|
|
required |
api_key
|
str
|
API key for the meta agent's model. |
required |
bus
|
EventBus | None
|
Optional :class: |
None
|
Source code in orqest/optimization/meta_agent.py
run
async
¶
run(
trainset: list[GoldExample[Any, Any]],
valset: list[GoldExample[Any, Any]] | None = None,
) -> OptimizationResult
Run the search; return an :class:OptimizationResult.
trainset is currently used only to size the minibatch sampler;
the actual evaluation runs against valset (or against trainset
if no valset is provided). The asymmetry mirrors GEPA's setup but
is less load-bearing here — topology evaluation doesn't have a
train/val distinction in the gradient sense.