# Council and voting

Two ways to let several participants reach one answer. `CouncilExecutor` asks several models the same question, has them rank each other's answers, and lets a chairman model write the final answer. `ConsensusBuilder` and `GroupDecisionFramework` ask agents to vote on a fixed list of options.

## Council

`CouncilExecutor` in `com.tnsai.coordination.council` works with `LLMClient` instances, not agents. `deliberate(question)` runs three stages:

1. **Answers.** Every member gets the question and answers it on its own virtual thread. A member that throws, or has not answered when the timeout ends, has no answer.
2. **Reviews.** Every member, including one that failed stage 1, gets all the successful answers and is asked to evaluate them and end with a line such as `FINAL RANKING: Model C, Model A, Model B`. Reviewers also see and rank their own answer.
3. **Synthesis.** The chairman gets the answers with their labels and real names, the reviews under the reviewers' real names, and the aggregate ranking, and writes the final answer.

If no member answers in stage 1, stages 2 and 3 are skipped and the final answer is `Council deliberation failed: no responses collected`.

### Building a council

| Builder method | Default | Meaning |
|---|---|---|
| `members(list)`, `addMember(client)` | required | At least 2 members, otherwise `build()` throws `IllegalArgumentException`. `members` replaces the list. |
| `chairman(client)` | required | Writes the final answer. It can also be a member. |
| `anonymize(boolean)` | `true` | Reviewers see `Model A`, `Model B` and so on instead of model names |
| `timeout(duration)` | 5 minutes | Applies to stage 1 and to stage 2 separately. Stage 3 has no timeout. |

Members are identified by `getModel()`. Give each member a different model name: two members with the same name share one label, and one of their answers is lost from the reviews.

<!-- java-contract: src/main/java/com/example/tnsai/docs/CouncilExample.java -->
```java
package com.example.tnsai.docs;

import com.tnsai.coordination.council.CouncilExecutor;
import com.tnsai.coordination.council.CouncilResponse;
import com.tnsai.coordination.council.CouncilResult;
import com.tnsai.coordination.council.CouncilReview;
import com.tnsai.llm.LLMClient;

import java.time.Duration;
import java.util.List;

/** Compilation contract for multi-agent/council.md council. */
public final class CouncilExample {

    private CouncilExample() {
    }

    static String deliberate(List<LLMClient> members, LLMClient chairman) {
        CouncilExecutor council = CouncilExecutor.builder()
            .members(members)                 // at least 2, each with a different model name
            .chairman(chairman)
            .anonymize(true)                  // reviewers see "Model A", "Model B", ...
            .timeout(Duration.ofMinutes(3))   // applies to stage 1 and to stage 2
            .build();

        CouncilResult result = council.deliberate("How should we architect the auth system?");

        for (CouncilResponse response : result.stage1Responses()) {
            System.out.println(response.llmName() + (response.isSuccess() ? " answered" : " failed")
                + " in " + response.durationMs() + " ms");
        }
        for (CouncilReview review : result.stage2Reviews()) {
            if (review.hasRanking()) {
                System.out.println(review.reviewerName() + " ranked " + review.parsedRanking());
            }
        }
        result.topRanked().ifPresent(top ->
            System.out.println("Top: " + top.llmName() + " (average rank " + top.averageRank() + ")"));

        System.out.println(result.successfulResponses() + " of " + result.memberCount() + " members answered");
        return result.finalAnswer();
    }
}
```

### Rankings

Each review's ranking is read from the text after `FINAL RANKING:`, up to the next blank line, as a comma-separated list or as numbered lines. A review without that marker has an empty `parsedRanking`.

A member's average rank is the mean of its 1-based positions, taken only over the reviews that list it. Lower is better, so 1.0 means every review that ranked it put it first. `aggregateRanking` is sorted by average rank, and `topRanked()` returns its first entry. A label the reviewer invented is kept as it was written.

A review that times out has the reviewer name `unknown` and the evaluation `Review unavailable: timeout`. When the chairman fails, the final answer starts with `Synthesis failed:`.

### The result

| `CouncilResult` | Contents |
|---|---|
| `stage1Responses()` | One `CouncilResponse(llmName, response, durationMs)` per member. `isSuccess()` is false when `response` is null. A member that timed out appears as `unknown`. |
| `stage2Reviews()` | `CouncilReview(reviewerName, evaluation, parsedRanking)`, with `hasRanking()` |
| `stage3Synthesis()` | `CouncilSynthesis(chairmanName, synthesis)` |
| `aggregateRanking()` | `RankedMember(llmName, averageRank, rankingsCount)`, best first |
| `memberCount()`, `duration()` | Number of members and total time |
| `finalAnswer()` | The chairman's answer |
| `topRanked()` | The first entry of `aggregateRanking`, if any |
| `successfulResponses()` | Number of members that answered in stage 1 |

## Ensemble calls

For one prompt sent to several models without peer review, `ParallelLLM` has static methods. Each one sends the prompt to every model in parallel. `agent.getEnsembleExecutor()` offers the same five calls; it does not add the agent's system prompt or history.

| Method | Returns |
|---|---|
| `fanOut(prompt, llms)` | Every result, successful or failed. The timeout is 120 seconds, or pass your own. |
| `race(prompt, llms, timeout, unit)` | The first successful response |
| `consensus(prompt, llms, scorer)` | The successful response with the highest score |
| `majorityVote(prompt, llms)` | The response that resembles the most other responses. Two responses resemble each other when more than half of their distinct words are shared. |
| `ensemble(prompt, llms, aggregator)` | One answer that the aggregator model writes from all the responses. If that call fails, the first response. |

<!-- java-contract: src/main/java/com/example/tnsai/docs/EnsembleExample.java -->
```java
package com.example.tnsai.docs;

import com.tnsai.llm.LLMClient;
import com.tnsai.llm.ParallelLLM;

import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;

public final class EnsembleExample {
    private EnsembleExample() {}

    static List<String> all(List<LLMClient> models) {
        ParallelLLM.ParallelResults results = ParallelLLM.fanOut("Explain quantum computing", models);
        results.failed().forEach(r -> System.err.println(r.llmName() + ": " + r.error()));
        return results.responses();
    }

    static Optional<String> fastest(List<LLMClient> models) {
        return ParallelLLM.race("Quick question", models, 10, TimeUnit.SECONDS)
            .map(ParallelLLM.LLMResult::response);
    }

    static Optional<String> longest(List<LLMClient> models) {
        return ParallelLLM.consensus("Review this code", models, response -> (double) response.length())
            .map(ParallelLLM.LLMResult::response);
    }

    static Optional<String> synthesized(List<LLMClient> models, LLMClient aggregator) {
        return ParallelLLM.ensemble("Compare the two designs", models, aggregator);
    }
}
```

## Voting

`com.tnsai.coordination.voting` has five `VotingMethod` values:

| Method | Winner |
|---|---|
| `MAJORITY` | The option with more than half of the votes |
| `SUPERMAJORITY` | The option whose share of the votes is above the threshold, 0.667 by default |
| `UNANIMOUS` | The only option chosen, when every vote is the same |
| `RANKED_CHOICE` | Instant runoff: the option with the fewest votes is dropped until one has more than half |
| `WEIGHTED` | The option with more than half of the total vote weight |

There is no winner when fewer votes arrive than the quorum, or when no option reaches the method's bar.

### Voting with `ConsensusBuilder`

`ConsensusBuilder.create()` asks each agent once and tallies the answers. `vote()` throws `IllegalStateException` if the topic, options or voters are missing.

| Builder method | Default | Meaning |
|---|---|---|
| `topic(text)` | required | The question |
| `options(...)` | required | The choices, as strings or a list |
| `among(agents)` | required | The voters |
| `method(method)` | `MAJORITY` | How votes are tallied |
| `timeout(duration)` | 30 seconds | How long to wait for votes. Later votes are dropped. |
| `quorum(n)` | 1 | Minimum number of votes for a winner |
| `supermajorityThreshold(share)` | 0.667 | Used by `SUPERMAJORITY`. A value outside 0–1 falls back to 0.667. |
| `listener(listener)` | none | Can be called more than once to add several listeners |

Each agent gets `Vote on: <topic>`, the options and `Respond with your chosen option.` through `chat`, without its history and without tools. Up to 10 agents are asked at a time. An agent that throws casts no vote.

The answer is matched against the options in the order you listed them: the first option whose text appears in the answer, ignoring case, is the vote. An empty answer, or one that names no option, counts as a vote for the **first** option. Avoid options that contain each other, such as `Ship` and `Do not ship`, and put a neutral option first if a non-answer should not favour a real choice.

Every vote is a single choice with weight 1. With `RANKED_CHOICE` the runoff therefore only drops options; with `WEIGHTED` the result is the same as `MAJORITY`. To tally ranked or weighted ballots, build `Vote.ofRanked` or `Vote.ofWeighted` yourself and call `RankedChoiceVoting.tally` or `MajorityVoting.tally`.

A `VotingListener` has three default methods:

- `onVoteReceived(vote)` runs on the voting thread as each vote arrives.
- `onQuorumReached(current, required)` runs once collection has finished, if the quorum was met. It is not a live signal.
- `onComplete(result)` runs after the tally.

`VotingResult` has `winner` (null without a winner), `voteCounts`, `quorumMet`, `unanimous`, `method` and `totalVotes`, plus `hasWinner()` and `winnerVotes()`.

### Group decisions

`GroupDecisionFramework` runs a vote inside an `AgentGroup` ([Group topologies](topologies.md)) and records the result. `DefaultGroupDecisionFramework` takes a `DecisionTraceStore`; `InMemoryDecisionTraceStore` keeps results in memory.

- `propose(topic, options, method)` creates a `DecisionProposal` with a 30-second timeout and a quorum of 1. For other values use `DecisionProposal.of(topic, options, method, timeout, quorum)`. A proposal needs at least two options.
- `vote(group, proposal)` asks every member, with the same prompt and matching as `ConsensusBuilder`, including the first-option fallback. The group must be active, otherwise it throws `IllegalStateException`.
- `converge(group, votes, proposal)` tallies the votes, saves the `DecisionResult` in the store and broadcasts its `summary()` to the group.
- `makeDecision(group, proposal)` runs `vote` and then `converge`.

`converge` always tallies by majority rules: `SUPERMAJORITY` uses 0.667, and `RANKED_CHOICE` is counted as `MAJORITY`. When there is no winner and the group has a leader, the leader is asked to choose, and its answer is matched like a vote, so it always yields an option unless the call throws. The result then has `leaderOverride` and `consensusReached` set to true.

<!-- java-contract: src/main/java/com/example/tnsai/docs/VotingExample.java -->
```java
package com.example.tnsai.docs;

import com.tnsai.agents.Agent;
import com.tnsai.agents.groups.AgentGroup;
import com.tnsai.coordination.decision.DecisionProposal;
import com.tnsai.coordination.decision.DecisionResult;
import com.tnsai.coordination.decision.DecisionTraceStore;
import com.tnsai.coordination.decision.DefaultGroupDecisionFramework;
import com.tnsai.coordination.decision.GroupDecisionFramework;
import com.tnsai.coordination.decision.InMemoryDecisionTraceStore;
import com.tnsai.coordination.voting.ConsensusBuilder;
import com.tnsai.coordination.voting.Vote;
import com.tnsai.coordination.voting.VotingListener;
import com.tnsai.coordination.voting.VotingMethod;
import com.tnsai.coordination.voting.VotingResult;

import java.time.Duration;
import java.util.List;

/** Compilation contract for multi-agent/council.md voting and group decisions. */
public final class VotingExample {

    private VotingExample() {
    }

    static VotingResult chooseDatabase(List<Agent> voters) {
        VotingResult result = ConsensusBuilder.create()
            .topic("Which database should the new service use?")
            .options("PostgreSQL", "MySQL", "MongoDB")
            .among(voters)
            .method(VotingMethod.SUPERMAJORITY)
            .supermajorityThreshold(0.75)
            .timeout(Duration.ofSeconds(30))
            .quorum(2)
            .listener(new VotingListener() {
                @Override
                public void onVoteReceived(Vote vote) {
                    System.out.println(vote.voterId() + " voted " + vote.choice());
                }

                @Override
                public void onComplete(VotingResult done) {
                    System.out.println("Counts: " + done.voteCounts());
                }
            })
            .vote();

        if (result.hasWinner()) {
            System.out.println(result.winner() + " with " + result.winnerVotes() + " of " + result.totalVotes());
        } else if (!result.quorumMet()) {
            System.out.println("Too few votes: " + result.totalVotes());
        }
        return result;
    }

    static DecisionResult decideRelease(AgentGroup group) {
        DecisionTraceStore traces = new InMemoryDecisionTraceStore();
        GroupDecisionFramework framework = new DefaultGroupDecisionFramework(traces);

        DecisionProposal proposal = DecisionProposal.of(
            "Release on Friday?",
            List.of("Release", "Postpone"),
            VotingMethod.UNANIMOUS,
            Duration.ofSeconds(45),   // how long vote() waits
            3);                       // quorum

        DecisionResult result = framework.makeDecision(group, proposal);   // vote, then converge

        if (result.leaderOverride()) {
            System.out.println("No consensus; the group leader chose " + result.winner());
        }
        System.out.println(result.summary());
        System.out.println(traces.listByTopic("Release on Friday?").size() + " recorded decisions");
        return result;
    }
}
```

`DecisionResult` has `decisionId`, `topic`, `winner`, `voteCounts`, `consensusReached`, `leaderOverride`, `method`, `totalVotes` and `timestamp`, plus `hasWinner()` and `summary()`. The store offers `get(decisionId)`, `list()`, `listByTopic(topic)`, `size()` and `clear()`.

## Related

- [Debate and peer review](debate.md)
- [Judge](judge.md)
- [Shared state and trust](shared-state.md)
- [Negotiation](negotiation.md)
- [Group topologies](topologies.md)
