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.
| Capability | Ensemble | Council |
|---|---|---|
| Parallel members | yes | yes |
| Aggregator | yes (sole reducer) | yes (:vote/:rank rounds only) |
| Roles | no | yes (drafter, reviewer, chair, specialist) |
| Multiple rounds | no | yes |
| Members see each other | no | yes (:debate, :peer_review, :critique) |
| Iteration to convergence | no | yes (Rounds.Iterate) |
| Chair synthesis | no | yes (optional) |
| Sub-councils / nesting | no | yes |
| Dynamic routing | no | yes (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.
| Macro | Purpose |
|---|---|
member :id, Module, opts | Add a member with a Member module + capability opts. Three-arg form. |
member :id, Module | Two-arg form. Capabilities resolved from default_profile / app config. |
member :id do … end | Inline-block form. Define system_prompt, tools, provider, model, etc. without a separate Member module. |
round :type, opts | Add a round (:independent_analysis, :peer_review, :vote, :pairwise_elimination, custom). |
chair Module, opts | Optional final synthesis member. |
router Module or inline fn | Filter 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 MyProfile | Fallback Profile for members that don't specify their own. |
output_schema MySchema | Inside 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
endFor 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).
| Council | Topology | Rounds |
|---|---|---|
ParallelPanel | independent_analysis → chair-Synthesis | 2 |
Chairman | (alias of ParallelPanel) | 2 |
PeerReview | drafter+reviewers → critique → peer_review → chair | 4 |
Voting | independent_analysis → vote (with aggregator) → chair | 3 |
Specialist | specialists → peer_review → chair | 3 |
Consensus | independent_analysis → iterate(critique) → chair | 3 |
Tournament | independent_analysis → iterate(pairwise_elimination) → chair | 3 |
WeightedConsensus | independent_analysis → weighted_synthesis → chair | 2 |
JuryWithRetry | iterate(independent_analysis until conf ≥ threshold) → chair | 2 |
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:
| Aggregator | Use case |
|---|---|
CouncilEx.Aggregators.Plurality | most-votes-wins, configurable tie-break |
CouncilEx.Aggregators.Borda | Borda count over rankings |
CouncilEx.Aggregators.Condorcet | pairwise; reports cycle when no Condorcet winner |
CouncilEx.Aggregators.WeightedMean | confidence-weighted vote totals |
CouncilEx.Aggregators.Median | outlier-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: 5The 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.