Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,42 @@
> **Purpose:** Living document tracking all changes, decisions, and reasoning during implementation. Updated as work progresses for easy reference and review.


---

## 🔎 fix(groups): I14 PR #638 review round 1 (2026-08-08)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

**Repo:** EDDI (`feat/group-i14-voting`)

All 14 review comments (CodeQL ×6, code-quality ×1, CodeRabbit ×6, + enum-count CI failure) triaged; every one accepted and fixed:

- **CI failure**: `AgentGroupConfigurationTest.phaseType_allValues` pins the enum size at 11; VOTE is the 12th. Fixed here (and the same pin fixed for RETRO on the I8 branch — the local targeted regressions missed this class; noted for future enum-touching branches).
- **The moderator tiebreak is now budget-gated** (the one real architecture defect): it is an LLM turn, and it ran unguarded after `maxTurns` was exhausted or the cost ceiling fired — the only extra call in `PhaseExecutionEngine` without the gate `checkConvergence` and `runDissentRound` both carry. `recordVoteDecision` now takes `(turnCounter, maxTurns)`, blocks the tiebreak on either budget (keeping the honest NO_DECISION), and counts the turn it does spend.
- **Losing-side dissents survive a tie-policy resolution**: the unresolved tally's record necessarily has no dissents, and `moderatorTiebreak` reused it — so the minority report vanished for exactly the closest votes. `TallyOutcome` now carries the parsed ballots; the tiebreak computes `losingDissents(ballots, chosenOption)` against ITS choice.
- **Weighted-total ties compare with an epsilon** (1e-9), not `==`: totals are sums of non-representable doubles, so 0.1+0.2 vs 0.3 — a genuine tie — silently crowned one side on the last bit.
- **Ballot weights must be finite**: NaN passes every `<` comparison and poisons the totals; infinity decides every vote alone. Save-time rejection alongside the existing `>= 0`.
- **Slack tally lines are width-bounded** (`buildPreview`, 120 chars): a LAST_SYNTHESIS option can be a paragraph, and six of those pushed the whole decision message past Slack's limit — `postSafe` then swallowed the loss, winner and all.
- **CodeQL log injection ×6** in `PhaseExecutionEngine` sanitized (`LogSanitizer` on conversation/phase/outcome/exception values); the flagged useless null check in `moderatorTiebreak` removed (control flow guarantees non-null there).

**Tests:** +6 (floating-point tie; outcome-carries-ballots + dissent-vs-choice; NaN/∞ weight rejection ×2 scenarios; tiebreak blocked at budget spends nothing; tiebreak within budget counts its turn AND carries the loser's dissent — the last one fails against the pre-fix code on both the counter and the dissent assertions). `engine.internal` + `configs.groups` suites: 1711 green; checkstyle clean.

---

## 🗳️ feat(groups): I14 — voting with structural ballot independence (2026-08-08)

**Repo:** EDDI (`feat/group-i14-voting`)

Second Wave 2 queue item. A `VOTE` phase collects explicit ballots; the deliverable is the **auditable process artifact** (weighted tally, raw ballots, losing-side dissents), not epistemics — LLM ballots are correlated and the plan says so out loud.

- **Model:** `PhaseType.VOTE` (the new enum value flushed every exhaustive switch at compile time — `mapPhaseToEntryType` now maps to F4's existing `TranscriptEntryType.VOTE`, so commit-reveal peer-hiding worked before any new code ran); `VoteConfig` (MAJORITY|APPROVAL, EXPLICIT|LAST_SYNTHESIS options, quorum, per-agent weights, `weightByConfidence` — default off with the correlated-self-report caveat in its Javadoc — and `tiePolicy`) as a 12th `DiscussionPhase` component with the usual compat constructor.
- **Independence is enforced, not advised:** `AgentGroupStore.validateVotePhases` HARD-rejects a VOTE phase that is not PARALLEL + `ContextScope.NONE` (plus: `targetEachPeer`, EXPLICIT with <2 options, negative weights). `HUMAN_DECIDES` is **save-time rejected until I6 ships human members** — the plan sequences I14 before I6, so shipping a silently-degrading enum value would be a lie; the queue's I6 item wires it. Deviation recorded here.
- **`VoteTallyEngine`:** three-tier ballot parse mirroring `DebateVerdictParser` (strict JSON with `FAIL_ON_TRAILING_TOKENS` → embedded JSON → exactly-one-option text scan; out-of-contract votes are non-ballots, never write-ins), `Option A:` line extraction from the newest synthesis, weighted tally, quorum with abstentions/garbage counting against the denominator, dissents from losing statements.
- **Wiring:** the discussion loop tallies on the VOTE phase's last repeat; `PhaseExecutionEngine.recordVoteDecision` applies the tie policy — `MODERATOR_DECIDES` runs one moderator turn under `__vote_tiebreak` (the judge's separate-conversation-key rule: a "reply with ONLY the option" prompt must not become the moderator's recent history), resolved by the same exact-scan rule as a ballot.
- **`decision_reached` finally fires (the §4 gap, folded in as planned):** `fireDecisionReached` runs for vote decisions AND for I3 debate verdicts — after the dissent round, so the event's record carries the merged dissents. Slack renders a bounded tally block for VOTE records (instanceof-guarded — the tally map crossed serialization).

**Tests (121 across the touched classes green; full `engine.internal` suite green; checkstyle clean):** parse tiers incl. ambiguous-two-options and out-of-contract refusals; label voting ("Option B" → positional); LAST_SYNTHESIS extraction (newest synthesis, colon and dash forms); weighted majority; exact tie → unresolved (never a winner by list position); confidence weighting on/off flips a tie; quorum arithmetic pinned to the "2 of 5" message; dissents + raw-ballot audit; tiebreak choice resolution; save-time validation matrix (PARALLEL/NONE/options/weights/HUMAN_DECIDES); service-level E2E: majority vote records the decision and fires `decision_reached` with the winner; tie + MODERATOR_DECIDES resolves via one tiebreak turn with method `vote+moderator-tiebreak`; tie + NO_DECISION records an honest NONE and the discussion does not fail; ballots land as VOTE entries. Slack tally block + malformed-tally no-throw.

**Files:** `AgentGroupConfiguration` (VOTE + VoteConfig/VoteMethod/OptionsSource/TiePolicy), `AgentGroupStore`, `DiscussionStylePresets` (TEMPLATE_VOTE), `GroupContextBuilder` (VOTE branch + entry-type mapping), `VoteTallyEngine` (new), `PhaseExecutionEngine` (recordVoteDecision + fireDecisionReached), `GroupConversationService` (loop wiring), `SlackGroupDiscussionListener` (tally block), `docs/group-conversations.md`, 4 test classes.

---

## 🔀 merge: bring `origin/main` (PR #627 HITL request pinning) into the branch (2026-08-07)
Expand Down
44 changes: 44 additions & 0 deletions docs/group-conversations.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,50 @@ Both caps are enforced independently: `maxPerTurn` bounds a runaway single turn,
discussion cap counts only agent-filed tasks, so a large planned backlog does not
exhaust it. A rejected call does not consume the per-turn budget.

## Voting

A `VOTE` phase collects **explicit ballots** instead of another round of prose.
LLM ballots are correlated (shared priors, sycophancy) — the durable value is
the auditable artifact: the weighted tally, every raw ballot, and the losing
side's statements recorded as dissents on the `DecisionRecord`.

```json
{
"name": "Ballot", "type": "VOTE",
"turnOrder": "PARALLEL", "contextScope": "NONE",
"voteConfig": {
"method": "MAJORITY",
"optionsSource": "EXPLICIT",
"options": ["Adopt PostgreSQL", "Stay on MongoDB"],
"quorum": 0.5,
"weights": { "senior-architect": 2.0 },
"weightByConfidence": false,
"tiePolicy": "MODERATOR_DECIDES"
}
}
```

**Independence is enforced structurally, not advised.** Save-time validation
rejects a VOTE phase that is not `PARALLEL` + `contextScope: NONE`; ballots are
cast blind against the pre-fan-out snapshot, and `VOTE` entries stay
peer-hidden until their phase completes (commit-reveal).

- **Ballot contract:** `{"vote": "<exact option text>", "confidence": <0..1>,
"statement": "..."}` (`APPROVAL` uses `"votes": [...]`). Three-tier parse:
strict JSON → JSON embedded in prose → a reply naming exactly one option's
text. Anything else is a non-ballot and **counts against quorum** — as do
abstentions; a mostly-silent team has not reached quorum, and that is signal.
- **Options:** `EXPLICIT` is the reliable path. `LAST_SYNTHESIS` extracts
`Option A: …` lines from the newest synthesis — instruct that synthesis to
emit them.
- **Ties and quorum failures** go to `tiePolicy`: `MODERATOR_DECIDES` runs one
moderator turn choosing among the unresolved options (method
`vote+moderator-tiebreak`); `NO_DECISION` (default) records an honest
`type: NONE` and the discussion continues. `HUMAN_DECIDES` is reserved for
human group members (I6) and is rejected at save time until then.
- The result fires the `decision_reached` SSE event (which this feature also
wires for debate verdicts) and renders a tally block in Slack.

## Nested Groups (Group-of-Groups)

Members can be other groups. The sub-group runs its own discussion and its synthesized answer becomes the member's response.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ public enum DiscussionStyle {
*/
public record DiscussionPhase(String name, PhaseType type, String participants, TurnOrder turnOrder, ContextScope contextScope,
boolean targetEachPeer, String inputTemplate, int repeats, boolean requiresApproval, ConvergenceConfig convergence,
boolean allowAbstention) {
boolean allowAbstention, VoteConfig voteConfig) {

/**
* Convenience constructor with defaults: participants=ALL,
Expand Down Expand Up @@ -248,6 +248,102 @@ public DiscussionPhase(String name, PhaseType type, String participants, TurnOrd
boolean targetEachPeer, String inputTemplate, int repeats, boolean requiresApproval, ConvergenceConfig convergence) {
this(name, type, participants, turnOrder, contextScope, targetEachPeer, inputTemplate, repeats, requiresApproval, convergence, false);
}

/**
* Backward-compatible constructor without {@code voteConfig} (I14) —
* {@code null} means a VOTE phase runs with {@link VoteConfig}'s defaults and
* every other phase type ignores it entirely.
*/
public DiscussionPhase(String name, PhaseType type, String participants, TurnOrder turnOrder, ContextScope contextScope,
boolean targetEachPeer, String inputTemplate, int repeats, boolean requiresApproval, ConvergenceConfig convergence,
boolean allowAbstention) {
this(name, type, participants, turnOrder, contextScope, targetEachPeer, inputTemplate, repeats, requiresApproval, convergence,
allowAbstention, null);
}
}

/**
* Ballot rules for a {@link PhaseType#VOTE} phase (I14).
* <p>
* LLM ballots are <b>correlated</b> — shared priors, sycophancy — so the
* durable value of a vote is the auditable process artifact (tally, raw
* ballots, losing-side dissents), not the epistemics. Independence is
* engineered structurally: save-time validation forces VOTE phases to
* {@code PARALLEL} + {@code ContextScope.NONE}, so ballots are cast blind
* against the pre-fan-out transcript snapshot — commit-reveal for LLM purposes,
* not an instruction the model could ignore.
*
* @param method
* {@code MAJORITY} — one option per ballot, highest weighted count
* wins; {@code APPROVAL} — a ballot may approve several options.
* Default MAJORITY
* @param optionsSource
* where the ballot options come from. {@code EXPLICIT} (the reliable
* path) takes {@code options} verbatim; {@code LAST_SYNTHESIS}
* extracts {@code Option A: …} lines from the latest SYNTHESIS entry
* — instruct the synthesis to emit that shape. Default
* LAST_SYNTHESIS
* @param options
* the explicit option texts, required (≥ 2) for EXPLICIT
* @param quorum
* the fraction of participants that must cast a valid ballot for the
* vote to decide, in (0, 1]. Abstentions and unparseable replies
* count toward the denominator only — a mostly-silent team has NOT
* reached quorum, and that is signal. Out-of-range values fall back
* to the default 0.5
* @param weights
* per-agentId ballot weights, default 1.0 each. Negative weights are
* rejected at save time
* @param weightByConfidence
* multiply each ballot by its self-reported 0..1 confidence
* (ReConcile-style). Default off — self-reported confidence is
* exactly as correlated as the ballots themselves; treat the
* weighted tally as process record, not probability
* @param tiePolicy
* what resolves a tie or a quorum failure: {@code MODERATOR_DECIDES}
* (one moderator turn choosing among the tied options),
* {@code HUMAN_DECIDES} (reserved for I6 human members — rejected at
* save time until that ships), or {@code NO_DECISION} (default:
* record {@code type=NONE} and carry on)
*/
public record VoteConfig(VoteMethod method, OptionsSource optionsSource, List<String> options, double quorum,
Map<String, Double> weights, boolean weightByConfidence, TiePolicy tiePolicy) {

public static final double DEFAULT_QUORUM = 0.5;

/** Same normalization choke point as {@link GroupTaskConfig}. */
public VoteConfig {
method = method == null ? VoteMethod.MAJORITY : method;
optionsSource = optionsSource == null ? OptionsSource.LAST_SYNTHESIS : optionsSource;
options = options == null ? List.of() : List.copyOf(options);
if (quorum <= 0.0 || quorum > 1.0) {
quorum = DEFAULT_QUORUM;
}
weights = weights == null ? Map.of() : Map.copyOf(weights);
tiePolicy = tiePolicy == null ? TiePolicy.NO_DECISION : tiePolicy;
}

/**
* All defaults: MAJORITY, LAST_SYNTHESIS, quorum 0.5, unweighted, NO_DECISION.
*/
public VoteConfig() {
this(VoteMethod.MAJORITY, OptionsSource.LAST_SYNTHESIS, List.of(), DEFAULT_QUORUM, Map.of(), false, TiePolicy.NO_DECISION);
}
}

/** How a {@link VoteConfig} counts ballots (I14). */
public enum VoteMethod {
MAJORITY, APPROVAL
}

/** Where a {@link VoteConfig}'s ballot options come from (I14). */
public enum OptionsSource {
LAST_SYNTHESIS, EXPLICIT
}

/** What resolves a tied or quorum-failed vote (I14). */
public enum TiePolicy {
MODERATOR_DECIDES, HUMAN_DECIDES, NO_DECISION
}

/**
Expand Down Expand Up @@ -323,7 +419,15 @@ public enum PhaseType {
/** Task execution by assigned agents. */
EXECUTE,
/** Verification of task results. */
VERIFY
VERIFY,
/**
* Explicit ballots (I14). Save-time validation forces VOTE phases to
* {@code PARALLEL} + {@code ContextScope.NONE} — ballot independence is
* enforced structurally (the pre-fan-out snapshot plus the F4 peer-visibility
* matrix mean no ballot can see another cast this phase), not advised in a
* prompt.
*/
VOTE
}

public enum TurnOrder {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,26 @@ private DiscussionStylePresets() {
]
```""";

/**
* The ballot prompt (I14). The JSON contract line is what
* {@code VoteTallyEngine}'s three-tier parse reads; the "vote independently"
* line is honesty, not the mechanism — independence is enforced structurally
* (PARALLEL + NONE scope + the pre-fan-out snapshot), so a model ignoring the
* instruction still cannot see any ballot cast this phase.
*/
public static final String TEMPLATE_VOTE = """
The group must decide:
"{question}"

The options are:
{#for option in options}
- {option}
{/for}

As {displayName}, vote independently — you cannot see anyone else's ballot.
Respond with ONLY this JSON, no other text:
{ballotContract}""";

// Template lookup by phase type
private static final Map<PhaseType, String> DEFAULT_TEMPLATES = Map.ofEntries(
Map.entry(PhaseType.OPINION, TEMPLATE_OPINION_INDEPENDENT),
Expand All @@ -284,7 +304,8 @@ private DiscussionStylePresets() {
Map.entry(PhaseType.SYNTHESIS, TEMPLATE_SYNTHESIS),
Map.entry(PhaseType.PLAN, TEMPLATE_PLAN),
Map.entry(PhaseType.EXECUTE, TEMPLATE_EXECUTE),
Map.entry(PhaseType.VERIFY, TEMPLATE_VERIFY));
Map.entry(PhaseType.VERIFY, TEMPLATE_VERIFY),
Map.entry(PhaseType.VOTE, TEMPLATE_VOTE));

/**
* Returns the default template for a given phase type.
Expand Down
Loading
Loading