Skip to content

feat(groups): voting with structural ballot independence (I14) - #638

Merged
ginccc merged 3 commits into
mainfrom
feat/group-i14-voting
Aug 8, 2026
Merged

feat(groups): voting with structural ballot independence (I14)#638
ginccc merged 3 commits into
mainfrom
feat/group-i14-voting

Conversation

@ginccc

@ginccc ginccc commented Aug 7, 2026

Copy link
Copy Markdown
Member

Second Wave 2 queue item from planning/group-collaboration-NEXT.md §3 (design: plan §I14). A VOTE phase collects explicit ballots; the deliverable is the auditable process artifact — weighted tally, every raw ballot, losing-side dissents — because LLM ballots are correlated (shared priors, sycophancy) and the plan says so out loud.

Design

  • Independence is engineered, not advised. Save-time validation (AgentGroupStore.validateVotePhases) HARD-rejects a VOTE phase that is not PARALLEL + contextScope: NONE. Ballots are cast blind against the pre-fan-out snapshot, and VOTE transcript entries stay peer-hidden until their phase completes — F4's commit-reveal visibility matrix covered the new phase type the moment mapPhaseToEntryType mapped it (the new PhaseType.VOTE enum value flushed every exhaustive switch at compile time).
  • Ballot contract + three-tier parse (VoteTallyEngine, mirroring DebateVerdictParser's discipline incl. FAIL_ON_TRAILING_TOKENS): strict JSON → JSON embedded in prose/fence → a reply naming exactly ONE option's text. Out-of-contract votes are non-ballots, never write-ins; non-ballots and abstentions count against quorum — a mostly-silent team has not reached quorum, and that is signal. "Option B" label votes resolve positionally.
  • VoteConfig: MAJORITY|APPROVAL, EXPLICIT options (the reliable path) or LAST_SYNTHESIS Option A: … extraction from the newest synthesis, quorum (default 0.5), per-agent weights, weightByConfidence (default off, correlated-self-report caveat in the Javadoc), tiePolicy.
  • Ties/quorum failures → tiePolicy: MODERATOR_DECIDES runs one moderator turn under a separate __vote_tiebreak conversation key (the I2 judge's rule: contract prompts must not become the moderator's recent history), resolved by the same exact-scan rule as a ballot; NO_DECISION records an honest type: NONE and the discussion continues. HUMAN_DECIDES is save-time rejected until I6 ships human members — the queue sequences I14 before I6, and a silently-degrading enum value would be a lie (deviation recorded in the changelog; I6 wires it).
  • decision_reached finally fires — the §4 gap folded in as the plan prescribed: producers for vote decisions AND I3 debate verdicts (fired after the dissent round so the event carries merged dissents). Slack renders a bounded, instanceof-guarded tally block.

Tests

121 across the touched classes; full engine.internal suite green; checkstyle clean.

  • Parse tiers: strict/embedded JSON, APPROVAL multi-select with duplicate collapse, label voting, exactly-one-option scan, ambiguous-two-options refused, out-of-contract refused.
  • Tally: weighted majority; exact tie → unresolved (never a winner by list position); confidence weighting on/off flips a tie; quorum pinned to the "2 of 5" message with garbage counting against it; dissents from losing statements; raw ballots in the tally for audit.
  • Save-time validation matrix (PARALLEL, NONE, EXPLICIT <2 options, negative weights, HUMAN_DECIDES).
  • Service E2E: majority vote records the decision and fires decision_reached with the winner; tie + MODERATOR_DECIDES resolves via one tiebreak turn (vote+moderator-tiebreak); tie + NO_DECISION does not fail the discussion; ballots land as VOTE entries.
  • Slack: tally block, malformed-tally no-throw.

Summary by CodeRabbit

  • New Features
    • Added configurable VOTE discussion phases with majority or approval voting.
    • Supports explicit or synthesis-derived options, quorum requirements, weighted and confidence-based ballots, dissents, and tie policies.
    • Records vote decisions and emits decision-reached events.
    • Displays vote tallies and participation counts in Slack.
  • Bug Fixes
    • Added validation for unsupported or invalid vote configurations and ballots.
  • Documentation
    • Documented vote phases, ballot formats, quorum behavior, tie handling, and tally rendering.
  • Tests
    • Added coverage for voting, validation, tallying, events, and Slack output.

VOTE phases collect explicit ballots; the deliverable is the auditable
artifact - weighted tally, raw ballots, losing-side dissents - because LLM
ballots are correlated. Independence is enforced at save time (PARALLEL +
NONE scope, hard-rejected otherwise), ballots stay peer-hidden via F4's
commit-reveal, and parsing is three-tier with out-of-contract votes as
non-ballots that count against quorum (as do abstentions). Ties go to the
tiePolicy: one moderator tiebreak turn under a separate conversation key,
or an honest NONE. HUMAN_DECIDES is save-time rejected until I6 ships.
Also folds in the paragraph-4 gap: decision_reached finally has producers
(votes and debate verdicts), with a bounded Slack tally block.
@ginccc
ginccc requested a review from rolandpickl as a code owner August 7, 2026 23:26
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

⚠️ Deprecation Warning: The deny-licenses option is deprecated for possible removal in the next major release. For more information, see issue 997.

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@ginccc, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 12 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c874ef88-4ed0-4e81-b084-3e68741f53bd

📥 Commits

Reviewing files that changed from the base of the PR and between 5966172 and 51a7092.

📒 Files selected for processing (10)
  • docs/changelog.md
  • src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java
  • src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java
  • src/main/java/ai/labs/eddi/engine/internal/groups/PhaseExecutionEngine.java
  • src/main/java/ai/labs/eddi/engine/internal/groups/VoteTallyEngine.java
  • src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java
  • src/test/java/ai/labs/eddi/configs/groups/model/AgentGroupConfigurationTest.java
  • src/test/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStoreTest.java
  • src/test/java/ai/labs/eddi/engine/internal/groups/PhaseExecutionEngineTest.java
  • src/test/java/ai/labs/eddi/engine/internal/groups/VoteTallyEngineTest.java
📝 Walkthrough

Walkthrough

This PR adds independent VOTE phases with configurable ballot methods, option sources, quorum, weights, and tie policies. It validates configurations at save time, parses and tallies ballots, records decisions and dissents, emits events, and renders vote totals in Slack.

Changes

VOTE phase configuration and validation

Layer / File(s) Summary
Vote configuration and save-time validation
src/main/java/ai/labs/eddi/configs/groups/model/AgentGroupConfiguration.java, src/main/java/ai/labs/eddi/configs/groups/model/DiscussionStylePresets.java, src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java, src/test/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStoreTest.java, docs/group-conversations.md, docs/changelog.md
Adds PhaseType.VOTE, VoteConfig, voting enums, a default ballot prompt, and validation for independent parallel execution and valid vote settings.

Ballot parsing and tallying

Layer / File(s) Summary
Ballot parsing and tallying
src/main/java/ai/labs/eddi/engine/internal/groups/VoteTallyEngine.java, src/test/java/ai/labs/eddi/engine/internal/groups/VoteTallyEngineTest.java
Resolves options, parses strict or embedded JSON and unambiguous text, applies weighted majority or approval tallying, evaluates quorum and ties, and records dissents and audit data.

Vote execution and output

Layer / File(s) Summary
Vote execution and decision events
src/main/java/ai/labs/eddi/engine/internal/groups/GroupContextBuilder.java, src/main/java/ai/labs/eddi/engine/internal/groups/PhaseExecutionEngine.java, src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java, src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceExtendedTest.java
Builds vote prompts and transcript entries, records final-repeat vote decisions, handles moderator tiebreaks, preserves dissents, and emits decision_reached.
Slack vote tally output
src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java, src/test/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListenerTest.java
Adds bounded per-option totals and ballot participation counts to vote decision messages, with defensive handling for malformed tally data.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GroupContextBuilder
  participant GroupMembers
  participant VoteTallyEngine
  participant PhaseExecutionEngine
  participant SlackGroupDiscussionListener
  GroupContextBuilder->>GroupMembers: provide independent vote prompt and ballot contract
  GroupMembers->>VoteTallyEngine: produce ballot transcript entries
  VoteTallyEngine->>PhaseExecutionEngine: return tally outcome
  PhaseExecutionEngine->>SlackGroupDiscussionListener: emit decision_reached with decision
  SlackGroupDiscussionListener-->>GroupMembers: post winner and bounded tally
Loading

Possibly related PRs

  • labsai/EDDI#626: Adds related GroupContextBuilder, PhaseExecutionEngine, and GroupConversationService changes extended here for VOTE phases.

Suggested reviewers: rolandpickl

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.65% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding group voting with structurally independent ballots.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/group-i14-voting

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
src/main/java/ai/labs/eddi/engine/internal/groups/GroupContextBuilder.java (1)

189-196: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Share one default VoteConfig between the prompt and the tally.

Line 193 creates new VoteConfig() when the phase carries none. PhaseExecutionEngine.recordVoteDecision repeats the same fallback at its own line 432. The ballot prompt and the tally must agree on method, options, and optionsSource. If either default changes independently, the prompt asks for one contract and the tally parses another.

Extract the fallback into one accessor, for example a static VoteTallyEngine.configOf(DiscussionPhase), and call it from both sites.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/ai/labs/eddi/engine/internal/groups/GroupContextBuilder.java`
around lines 189 - 196, Centralize the null fallback for vote configuration in a
shared accessor such as VoteTallyEngine.configOf(DiscussionPhase). Update
GroupContextBuilder’s VOTE branch and PhaseExecutionEngine.recordVoteDecision to
use this accessor, ensuring both prompt generation and tallying share the same
method, options, and optionsSource defaults.
src/test/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListenerTest.java (1)

499-513: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the tally truncation branch.

The two new tests cover a well-formed tally and a malformed one. They do not cover the MAX_TALLY_LINES overflow branch at SlackGroupDiscussionListener lines 314-316. Add a case with seven or more options and assert the message contains "… and 1 more option(s)". Use a LinkedHashMap so the retained six are deterministic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListenerTest.java`
around lines 499 - 513, The test suite needs coverage for the MAX_TALLY_LINES
overflow path in SlackGroupDiscussionListener. Add a test alongside
onDecisionReached_voteWithTally_postsTheTallyBlock that builds the totals with a
LinkedHashMap containing at least seven options, invokes onDecisionReached with
the tally decision, and verifies the posted message contains "… and 1 more
option(s)"; preserve deterministic ordering so exactly six options are retained.
src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceExtendedTest.java (1)

193-207: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an assertion that the tiebreak runs under its own conversation key.

TIEBREAK_CONVERSATION_KEY exists so the tiebreak prompt never becomes the moderator's recent history. This test proves the moderator broke the tie, but it passes equally if the tiebreak reuses the moderator's own conversation. Assert the key that memberConversationIds gained, or assert the conversation id that startConversation was called with for "mod".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceExtendedTest.java`
around lines 193 - 207, The votePhase_tie_moderatorDecides_viaOneTiebreakTurn
test only verifies the result, not that the moderator tiebreak uses
TIEBREAK_CONVERSATION_KEY. Add an assertion against the recorded
memberConversationIds entry or startConversation invocation for "mod",
confirming the tiebreak uses its dedicated conversation key rather than the
moderator’s normal conversation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/changelog.md`:
- Line 8: Update the changelog entry for “feat(groups): I14” to use the actual
publication date of August 7, 2026, or explicitly mark it as planned if the
future date is intentional.

In `@src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java`:
- Around line 149-152: Update the weight validation in the vote configuration
loop of AgentGroupStore to reject any non-finite value, including NaN and
positive infinity, before storage or use by VoteTallyEngine. Preserve the
existing null and negative-value rejection and the current error path
identifying the specific weight key.

In `@src/main/java/ai/labs/eddi/engine/internal/groups/PhaseExecutionEngine.java`:
- Around line 429-469: Update recordVoteDecision to accept AtomicInteger
turnCounter and int maxTurns, and before invoking moderatorTiebreak enforce the
same turn and cost-ceiling checks used by checkConvergence and runDissentRound;
increment turnCounter when the tiebreak call is allowed. Pass both budget
parameters from GroupConversationService.executeDiscussion at its
recordVoteDecision call site, preserving the existing decision flow when the
tiebreak is blocked.

In `@src/main/java/ai/labs/eddi/engine/internal/groups/VoteTallyEngine.java`:
- Around line 247-259: The unresolved branch in VoteTallyEngine’s tally flow
currently discards losing-side dissent data needed by
PhaseExecutionEngine.moderatorTiebreak. Preserve the parsed ballots or an
equivalent dissent-calculation callback in TallyOutcome, then have the
moderator-resolved path compute dissents against the selected option using
losingDissents rather than reusing the tie record’s empty list; keep normal
winner handling unchanged.
- Around line 242-245: Update the leader filtering in the tally calculation
around max and leaders to compare each total against max using a small
floating-point tolerance rather than exact equality. Ensure mathematically tied
weighted totals are included in leaders so the existing tie policy can handle
them, while preserving the max > 0.0 requirement.

In
`@src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java`:
- Around line 300-323: Update appendVoteTally to bound each option key before
appending it to the tally output, reusing the existing buildPreview helper for
truncation. Keep the MAX_TALLY_LINES limit and value rendering unchanged, while
ensuring every formatted tally line has bounded option text so the complete
decision message remains deliverable.

---

Nitpick comments:
In `@src/main/java/ai/labs/eddi/engine/internal/groups/GroupContextBuilder.java`:
- Around line 189-196: Centralize the null fallback for vote configuration in a
shared accessor such as VoteTallyEngine.configOf(DiscussionPhase). Update
GroupContextBuilder’s VOTE branch and PhaseExecutionEngine.recordVoteDecision to
use this accessor, ensuring both prompt generation and tallying share the same
method, options, and optionsSource defaults.

In
`@src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceExtendedTest.java`:
- Around line 193-207: The votePhase_tie_moderatorDecides_viaOneTiebreakTurn
test only verifies the result, not that the moderator tiebreak uses
TIEBREAK_CONVERSATION_KEY. Add an assertion against the recorded
memberConversationIds entry or startConversation invocation for "mod",
confirming the tiebreak uses its dedicated conversation key rather than the
moderator’s normal conversation.

In
`@src/test/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListenerTest.java`:
- Around line 499-513: The test suite needs coverage for the MAX_TALLY_LINES
overflow path in SlackGroupDiscussionListener. Add a test alongside
onDecisionReached_voteWithTally_postsTheTallyBlock that builds the totals with a
LinkedHashMap containing at least seven options, invokes onDecisionReached with
the tally decision, and verifies the posted message contains "… and 1 more
option(s)"; preserve deterministic ordering so exactly six options are retained.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 36aacf4c-56e7-42ad-a064-55c35a88c89b

📥 Commits

Reviewing files that changed from the base of the PR and between 22852f0 and 5966172.

📒 Files selected for processing (14)
  • docs/changelog.md
  • docs/group-conversations.md
  • src/main/java/ai/labs/eddi/configs/groups/model/AgentGroupConfiguration.java
  • src/main/java/ai/labs/eddi/configs/groups/model/DiscussionStylePresets.java
  • src/main/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStore.java
  • src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java
  • src/main/java/ai/labs/eddi/engine/internal/groups/GroupContextBuilder.java
  • src/main/java/ai/labs/eddi/engine/internal/groups/PhaseExecutionEngine.java
  • src/main/java/ai/labs/eddi/engine/internal/groups/VoteTallyEngine.java
  • src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java
  • src/test/java/ai/labs/eddi/configs/groups/mongo/AgentGroupStoreTest.java
  • src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceExtendedTest.java
  • src/test/java/ai/labs/eddi/engine/internal/groups/VoteTallyEngineTest.java
  • src/test/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListenerTest.java

Comment thread docs/changelog.md
Comment thread src/main/java/ai/labs/eddi/engine/internal/groups/VoteTallyEngine.java Outdated
ginccc added 2 commits August 8, 2026 01:45
AgentGroupConfigurationTest.phaseType_allValues pins the enum size;
I14 added VOTE as the 12th value.
…sent carry

- recordVoteDecision now takes (turnCounter, maxTurns): the moderator
  tiebreak is a real LLM turn, gated on both the turn budget and the
  cost ceiling like every other extra call, and counted when it runs.
- TallyOutcome carries the parsed ballots so a tie-policy resolution
  computes losing-side dissents against ITS chosen option; reusing the
  unresolved record dropped the minority report for the closest votes.
- Weighted totals tie by epsilon (1e-9), not ==; ballot weights must
  be finite at save time (NaN passes every < comparison).
- Slack tally lines width-bounded via buildPreview so a synthesis-
  derived option paragraph cannot push the message past Slack limits.
- CodeQL: 6 log-injection sinks sanitized; useless null check removed.
@ginccc
ginccc merged commit 41de0a5 into main Aug 8, 2026
43 checks passed
@ginccc
ginccc deleted the feat/group-i14-voting branch August 8, 2026 09:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants