Pre-built council templates and the aggregator / iteration primitives that back them. Use these to assemble a council without writing a defmodule … use CouncilEx … end block, or to compose richer custom flows on top.

Council vs ensemble

A classical ensemble runs N models on the same task in parallel and combines outputs through a flat aggregator (vote / mean / majority). One round, no roles, no cross-talk.

A council is a strict superset: members have roles (drafter, reviewer, chair, specialist), execute in rounds, can see each other's prior outputs, can iterate until convergence, and end with optional chair synthesis. Of the seven topologies below, only Voting reduces to an ensemble shape (parallel members + aggregator); adding the chair already makes it richer than a plain ensemble.

CapabilityEnsembleCouncil
Parallel membersyesyes
Aggregatoryes (sole reducer)yes (:vote/:rank rounds only)
Rolesnoyes (drafter, reviewer, chair, specialist)
Multiple roundsnoyes
Members see each othernoyes (:debate, :peer_review, :critique)
Iteration to convergencenoyes (Rounds.Iterate)
Chair synthesisnoyes (optional)
Sub-councils / nestingnoyes
Dynamic routingnoyes (Router)

Topology → ensemble mapping: Voting ≈ ensemble + chair. ParallelPanel/Chairman, PeerReview, Specialist, Consensus, Tournament add structure ensembles cannot express.

Static module-form DSL (use CouncilEx)

The use CouncilEx DSL assembles a runnable council module at compile time. All macros are order-independent within the defmodule block.

MacroPurpose
member :id, Module, optsAdd a member with a Member module + capability opts. Three-arg form.
member :id, ModuleTwo-arg form. Capabilities resolved from default_profile / app config.
member :id do … endInline-block form. Define system_prompt, tools, provider, model, etc. without a separate Member module.
round :type, optsAdd a round (:independent_analysis, :peer_review, :vote, :pairwise_elimination, custom).
chair Module, optsOptional final synthesis member.
router Module or inline fnFilter members per round (council-level or per-round override). 2-arity fn (input, ctx) supported here; dynamic form requires a registered module name.
default_profile MyProfileFallback Profile for members that don't specify their own.
output_schema MySchemaInside a member block only: cast LLM output to an Ecto schema. Not valid at the council top level.
defmodule MyApp.MyCouncil do
  use CouncilEx

  default_profile CouncilEx.Profiles.OpenAIMini

  member :researcher, MyApp.Members.Researcher
  member :critic,     MyApp.Members.Critic
  round :peer_review
  chair MyApp.Members.Synthesizer, profile: CouncilEx.Profiles.OpenAIBalanced
end

For common topologies, the prebuilt Councils.* templates below generate a static module via Module.create/3 from a one-call new/1 — no defmodule block needed.

Default councils

Nine pre-built topologies in CouncilEx.Councils.*. Each exposes new/1 that builds a runnable council module via Module.create/3. Pass :as for an explicit module name; otherwise a unique-suffix name is generated (don't call new/1 in a hot loop without :as: atom table grows).

CouncilTopologyRounds
ParallelPanelindependent_analysis → chair-Synthesis2
Chairman(alias of ParallelPanel)2
PeerReviewdrafter+reviewers → critique → peer_review → chair4
Votingindependent_analysis → vote (with aggregator) → chair3
Specialistspecialists → peer_review → chair3
Consensusindependent_analysis → iterate(critique) → chair3
Tournamentindependent_analysis → iterate(pairwise_elimination) → chair3
WeightedConsensusindependent_analysis → weighted_synthesis → chair2
JuryWithRetryiterate(independent_analysis until conf ≥ threshold) → chair2

WeightedConsensus is the Council Mode topology from Wu et al. (arXiv:2604.02923): heterogeneous members aggregated by reliability weight rather than equal-weight chair synthesis. See COUNCIL_MODE_PAPER.md for the full paper-to- implementation mapping.

JuryWithRetry runs K judges in parallel and re-samples on low average confidence. Default threshold 0.7, max 2 iterations. Judges do NOT see each other across iterations (independent re-sample, not debate). This respects the Wu 2025 finding that visible majority pressure hurts correctness. See RELATED_WORK.md for the Chaos-MoA / Adjudicator pattern convergence.

council =
  CouncilEx.Councils.Specialist.new(
    as: MyApp.MyCouncil,
    members: [
      {:seo, MyApp.Members.Seo, [provider: :openai, model: "gpt-4o-mini"]},
      {:tech, MyApp.Members.Tech, [provider: :openai, model: "gpt-4o-mini"]}
    ],
    chair: {MyApp.Members.Synth, [provider: :openai, model: "gpt-4o"]}
  )

{:ok, result} = CouncilEx.run(council, %{topic: "..."})

The same templates have data-form variants under Councils.{Specialist, Consensus,Tournament,WeightedConsensus}.new_dynamic/1 that return a %DynamicCouncil{}. (JuryWithRetry ships static-only for now; the iterate+retry shape is non-trivial to round-trip through JSON serialization.) See DYNAMIC_COUNCILS.md.

Aggregators

Voting and Ranking rounds delegate to a configurable aggregator:

AggregatorUse case
CouncilEx.Aggregators.Pluralitymost-votes-wins, configurable tie-break
CouncilEx.Aggregators.BordaBorda count over rankings
CouncilEx.Aggregators.Condorcetpairwise; reports cycle when no Condorcet winner
CouncilEx.Aggregators.WeightedMeanconfidence-weighted vote totals
CouncilEx.Aggregators.Medianoutlier-robust median over confidences

Pass via round :vote, aggregator: CouncilEx.Aggregators.WeightedMean or through CouncilEx.Councils.Voting.new(aggregator: ...).

Custom aggregators implement the CouncilEx.Aggregator behaviour (aggregate/2).

Iteration

CouncilEx.Rounds.Iterate wraps another round and repeats until a convergence callback returns true or max_iterations is reached:

round :iterate,
  wrap: :critique,
  until: fn _prev, curr -> stable?(curr) end,
  max_iterations: 5

The result's RoundResult.metadata.history carries every intermediate iteration's full RoundResult.

Iterate is what Councils.Consensus uses internally to drive convergence of a critique round, and what Councils.Tournament uses to drive pairwise elimination across bracket rounds.