From b782bee3791dc58cf7db846d76a44350a9826035 Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sat, 4 Jul 2026 17:35:54 +0200 Subject: [PATCH 01/16] Added product design document for votee library and .gitignore --- .gitignore | 43 +++++++++++ votee/docs/product-design.md | 133 +++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 .gitignore create mode 100644 votee/docs/product-design.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1c0b26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# votee-scala is its own git repository (cloned from github.com/Hiis-io/Votee) - +# excluded here to avoid nesting git repos. +votee-scala/ + +# Build output +target/ +build/ +out/ +bin/ + +# Maven +.mvn/wrapper/maven-wrapper.jar + +# Gradle +.gradle/ + +# Compiled class files +*.class + +# Logs +*.log + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.classpath +.project +.factorypath +nbproject/ + +# Scala / Metals tooling +.metals/ +.bloop/ +.bsp/ +.ammonite/ +project/metals.sbt +project/.bloop/ + +# OS files +.DS_Store +Thumbs.db diff --git a/votee/docs/product-design.md b/votee/docs/product-design.md new file mode 100644 index 0000000..a48bd65 --- /dev/null +++ b/votee/docs/product-design.md @@ -0,0 +1,133 @@ +# Votee (Java) - Product Design Document + +| | | +|---|---| +| **Author** | Ludovic Temgoua Abanda | +| **Status** | Draft | +| **Date** | 2026-07-04 | +| **Related docs** | `votee/docs/low-level-design.md` (follow-up, not yet written) | +| **Reference implementation** | [`votee-scala`](../../votee-scala) (`com.ludovictemgoua.votee`, github.com/Hiis-io/Votee) | + +## 1. Overview + +`votee` is a Java library of pluggable vote-counting algorithms for elections. It is a Java port of `votee-scala`, an existing Scala 3 library of mine that implements the same domain. The goal of this port is behavioral parity with the Scala original, expressed in idiomatic Java rather than a mechanical line-by-line translation - every place where Scala and Java diverge in what's idiomatic is a deliberate design decision, not an accident. + +This document defines *what* is being built and *why*. The follow-up Low-Level Design document defines *how* - concrete class shapes, method signatures, and algorithm-by-algorithm implementation notes. + +## 2. Background + +`votee-scala` implements nine election-counting methods (Majority, Super Majority, Approval, Veto, Borda Count, Baldwin, Contingent Vote, Coombs' Method, Exhaustive Ballot) against a small, extensible domain model: a `Candidate`, a `Ballot` of ranked or weighted `Candidate` preferences, and an `Election` contract that turns a set of ballots into a list of `Winner`s. Vote weights and scores are tracked as exact rationals (via `spire.math.Rational`) rather than floating-point numbers, since tallies must never drift due to rounding. + +The library was designed to be extended by consumers: they can supply their own `Candidate` and `Ballot` implementations, or rely on the built-in `PreferentialCandidate` / `PreferentialBallot` defaults. This extensibility is a first-class requirement carried over into the Java port. + +## 3. Goals + +- Full behavioral parity with `votee-scala` for all nine currently-implemented algorithms: given the same candidates and ballots, the Java port produces the same winners. +- An idiomatic Java 21 API - a Java developer reading this library should not feel like they're reading translated Scala. +- The same extensibility story as the original: default implementations (`PreferentialCandidate`, `PreferentialBallot`) provided out of the box, with contracts (`Candidate`, `Ballot`, `Election`) that consumers can implement themselves. +- Exact rational arithmetic for all vote tallying - no floating-point rounding error in scores or thresholds. +- Test parity: the same known-good JSON fixtures used by `votee-scala` validate the Java port, so both implementations are provably checking the same cases. +- Ship as a fully publishable Maven library - versioned, packaged (main, sources, and javadoc jars), and deployed to a private GitHub Packages registry. This is a deliberate exercise of the full library-release workflow, not just the algorithm code (see §8.1). + +## 4. Non-Goals + +- Replicating Scala's variance and higher-kinded type model exactly. Java's type system can't express `Ballot[+C <: Candidate, +T[+CC >: C <: Candidate] <: Ballot[CC, T]]` directly; the Java port uses the nearest idiomatic equivalent (see §9) rather than fighting the type system for 1:1 fidelity. +- Implementing the algorithms still on `votee-scala`'s own TODO list (Instant Runoff 2-Round, Kemeny-Young, Minimax Condorcet, Nanson, Oklahoma, PAV, Preferential Block Voting, Random Ballot, SAV). These aren't implemented in the reference either, so there's nothing to port yet - tracked as future work in both libraries. +- A REST or CLI wrapper around the library. This is a library-only port, matching the current scope of `votee-scala`. + +## 5. Target Users & Use Cases + +Same audience as the reference implementation: JVM developers who need a pluggable vote-counting component - for a small internal poll, a governance tool, or as a building block in a larger application - without hand-rolling tallying logic or getting floating-point edge cases wrong. A consumer picks an algorithm (e.g. `Majority.run(...)`), supplies candidates and ballots, and gets back a ranked list of winners. + +## 6. Functional Requirements + +### 6.1 Core domain model + +| Concept | Responsibility | +|---|---| +| `Candidate` | Base contract for anything that can appear on a ballot. Ships with a `PreferentialCandidate` default (id, name, optional party). | +| `Ballot` | Base contract for a voter's submitted preferences, carrying an id, a weight, and an ordered list of candidate preferences. Ships with a `PreferentialBallot` default. Supports excluding/including candidates (used internally by elimination-style algorithms). | +| `Election` | Contract turning a list of ballots + candidates + vacancy count into a list of `Winner`s, given a pluggable tie-resolution strategy. | +| `TieResolver` | Strategy for ordering candidates that end up tied on score. Ships with three defaults: do-nothing (deterministic, order-preserving), random (shuffle), reverse. | +| `Winner` | A candidate paired with their final score. | +| `Rational` | Exact fraction type (arbitrary-precision numerator/denominator) used everywhere a vote weight or score is represented, so tallies never accumulate floating-point error. | + +### 6.2 Voting algorithms + +All nine algorithms below must be ported with matching behavior, verified against the same fixture data used by `votee-scala`: + +| Algorithm | Summary | Reference | +|---|---|---| +| Majority | Winner needs strictly more than half the first-preference votes. | [wikipedia.org/wiki/Majority_rule](https://en.wikipedia.org/wiki/Majority_rule) | +| Super Majority | Like Majority, but against a configurable threshold above one half. | [wikipedia.org/wiki/Supermajority](https://en.wikipedia.org/wiki/Supermajority) | +| Approval | Every candidate a voter includes on their ballot gets one full vote; most approvals wins. | [wikipedia.org/wiki/Approval_voting](https://en.wikipedia.org/wiki/Approval_voting) | +| Veto | Every candidate on a ballot scores a point except the voter's least-preferred choice. | - | +| Borda Count | Candidates score points based on rank position on each ballot; points summed across all ballots. | [wikipedia.org/wiki/Borda_count](https://en.wikipedia.org/wiki/Borda_count) | +| Baldwin | Repeated Borda-Count elimination: drop the lowest Borda scorer each round until one candidate remains. | [wikipedia.org/wiki/Nanson's_method#Baldwin_method](https://en.wikipedia.org/wiki/Nanson%27s_method#Baldwin_method) | +| Contingent Vote | Top two first-preference candidates advance; other ballots' next usable preference is redistributed to decide the winner. | [wikipedia.org/wiki/Contingent_vote](https://en.wikipedia.org/wiki/Contingent_vote) | +| Coombs' Method | Repeated elimination of the candidate ranked *last* most often, until one candidate has a majority. | [wikipedia.org/wiki/Coombs'_method](https://en.wikipedia.org/wiki/Coombs%27_method) | +| Exhaustive Ballot | Repeated elimination of the lowest first-preference scorer until two candidates remain. | [wikipedia.org/wiki/Exhaustive_ballot](https://en.wikipedia.org/wiki/Exhaustive_ballot) | + +## 7. Non-Functional Requirements + +- **Exactness**: all scoring arithmetic uses `Rational`; no `double`/`float` in the tallying path. +- **Immutability**: domain model types (`Candidate`, `Ballot`, `Winner`, `Rational`) are immutable value types. +- **Determinism**: given the same inputs and the same `TieResolver`, an algorithm always produces the same output. +- **Extensibility without inheritance-for-reuse**: consumers extend behavior by implementing the `Candidate`/`Ballot`/`TieResolver` contracts, not by subclassing concrete algorithm classes. +- **Test parity**: every algorithm has at least one test driven by the ported JSON fixtures, plus the existing Scala test suite's expected-winner assertions. + +## 8. High-Level Architecture + +- **Build tool**: Maven, targeting **Java 21 (LTS)**. +- **Package**: `com.ludovictemgoua.votee` - namespaced under the author's own domain rather than mirrored from the Scala source's `io.hiis.votee`, since this artifact is meant to actually be published (see §8.1). Domain-based reverse-DNS naming is kept even though GitHub Packages doesn't require it, so the coordinates need not change if the library is later promoted to Maven Central. +- **Module layout** (indicative - finalized in the LLD): + - `com.ludovictemgoua.votee.model` - `Candidate`, `PreferentialCandidate`, `Ballot`, `PreferentialBallot`, `Election`, `TieResolver`, `Winner`, `Rational` + - `com.ludovictemgoua.votee.algorithms` - one class per algorithm +- **Dependencies**: no runtime dependencies beyond the JDK. A JSON library (e.g. Jackson) is a test-scope-only dependency, used solely to load the ported fixture files. +- **Testing**: JUnit 5, with the `01-candidates.json` / `01-ballots.json` / `02-ballots.json` / `03-ballots.json` fixtures ported verbatim from `votee-scala/src/main/resources` into the Java module's test resources. + +### 8.1 Versioning & Publishing Strategy + +- **Versioning scheme**: Semantic Versioning 2.0.0, applied from initial development under SemVer's own "major version zero" clause - **Early SemVer**. The library starts at `0.1.0`; while the major version stays `0`, a breaking API change bumps MINOR (`0.(x+1).0`) and a backward-compatible change bumps PATCH (`0.x.(y+1)`). The jump to `1.0.0` marks the point where the public API is declared stable. +- **Coordinates**: `groupId=com.ludovictemgoua`, `artifactId=votee`. +- **Target registry**: a private GitHub Packages Maven registry (under the author's GitHub account/org), configured via Maven's `distributionManagement` and authenticated with a `GITHUB_TOKEN` - the same mechanism `votee-scala` itself already uses to publish, via `sbt-github-packages`. Maven Central remains a possible future upgrade (see §11) but isn't required for this pass's success criteria. +- **Release artifacts**: main jar and sources jar at minimum; a javadoc jar is included if time allows, but isn't required for GitHub Packages the way it would be for Central. + +## 9. Key Design Decisions & Rationale + +| Decision | Chosen approach | Alternatives considered | Rationale | +|---|---|---|---| +| Ballot self-type | Curiously Recurring Generic Pattern: `Ballot>`, with `exclude`/`include` returning `SELF` | (a) Interface methods return the base `Ballot` type; (b) drop the interface, keep only `PreferentialBallot` | Java has no equivalent to Scala's self-referential higher-kinded type parameter. CRGP is the standard idiomatic Java answer to "an interface method must return the implementing type" - it preserves both the extensible-contract story and fluent, type-safe chaining after `exclude`/`include`. | +| Rational arithmetic | Hand-written immutable `Rational` (`BigInteger` numerator/denominator, GCD-reduced on construction) | Third-party library (e.g. Apache Commons Math `BigFraction`) | No new runtime dependency, arbitrary precision (no overflow), and it's a self-contained value-object exercise that fits this repo's purpose. | +| Build tool | Maven | Gradle | More universal expectation in traditional enterprise Java codebases; lowest-friction first impression for someone skimming the repo. | +| Language level | Java 21 (LTS) | Java 17 (LTS) | Latest LTS; enables idiomatic use of records, sealed interfaces, and pattern-matching `switch` where they fit, and signals current Java fluency. | +| Port scope | All 9 implemented algorithms in one pass | Core model + 3–4 representative algorithms first | Matches `votee-scala`'s current scope exactly, so "parity" has one unambiguous meaning instead of a moving target. | +| Test data | Port the existing JSON fixtures verbatim | Fresh inline JUnit test data per algorithm | Reuses known-good test vectors and enables a direct "same input, same output across languages" comparison between the two implementations. | +| Versioning scheme | Early SemVer, starting at `0.1.0` | Start at `1.0.0` immediately | The API surface (especially the CRGP-based `Ballot` generics) is new and likely to shift once real usage/tests expose friction; SemVer's own major-version-zero clause exists for exactly this, and signals to any consumer that the API isn't frozen yet. | + +## 10. Success Criteria + +- All 9 algorithms implemented and passing tests driven by the ported fixture data. +- For every fixture case, the Java port's winner(s) match `votee-scala`'s winner(s) exactly. +- Public API (model + algorithm entry points) is Javadoc'd. +- The module builds and tests cleanly via `mvn test` with no warnings from the compiler about raw types or unchecked generics. +- The module is versioned, packaged, and successfully deployed to the private GitHub Packages registry, resolvable by a separate consuming project given a valid `GITHUB_TOKEN`. + +## 11. Risks & Open Questions + +- **Semantic drift risk**: the biggest real risk isn't arithmetic (arbitrary-precision `Rational` sidesteps overflow/rounding) but subtle behavioral differences introduced while translating Scala's collection operations (e.g. `groupMapReduce`, tail-recursive elimination loops) into Java's `Stream`/`Collection` APIs. Mitigated by the fixture-based parity tests in §6.2/§9. +- **Tie-resolution parity**: Scala's `given`/`using` implicit default parameters become explicit method overloads in Java (a default-tie-resolver overload calling through to a full-parameter overload). This is a mechanical, low-risk translation, detailed further in the LLD. +- **Publishing setup risk**: GitHub Packages requires a `GITHUB_TOKEN` with the right scopes for both publishing and (for consumers) resolving the artifact - a lighter setup than Maven Central, but still an administrative dependency outside the codebase itself. +- **Future work**: promoting to Maven Central once the API stabilizes past `0.x` remains an option, since the domain-based `com.ludovictemgoua` coordinates already satisfy Central's namespace-ownership requirement - no renaming needed if that path is taken later. +- **Open question**: whether `votee` should eventually depend on or be compared against `votee-scala` in an automated cross-language parity test (e.g. a shared fixture-runner), versus the two test suites simply being reviewed by eye for now. Deferred - not required for this port's success criteria. + +## 12. Out of Scope / Future Work + +- The unimplemented algorithms already tracked in `votee-scala`'s own README TODO list. +- Any REST/CLI layer on top of the library. +- Automated cross-language (Scala vs. Java) regression testing. + +## 13. References + +- Reference implementation: [`votee-scala`](https://github.com/icemc/votee) +- Root repository context: [`/README.md`](../../README.md) From 876089eedc8beddb858b8478c55ebb31983905fb Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sat, 4 Jul 2026 22:08:33 +0200 Subject: [PATCH 02/16] Added Low level design document (LLD) --- votee/docs/low-level-design.md | 591 +++++++++++++++++++++++++++++++++ votee/docs/product-design.md | 2 +- 2 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 votee/docs/low-level-design.md diff --git a/votee/docs/low-level-design.md b/votee/docs/low-level-design.md new file mode 100644 index 0000000..0ae065a --- /dev/null +++ b/votee/docs/low-level-design.md @@ -0,0 +1,591 @@ +# Votee (Java) - Low-Level Design Document + +| | | +|---|---| +| Author | Ludovic Temgoua Abanda | +| Status | Draft | +| Date | 2026-07-04 | +| Related docs | votee/docs/product-design.md (PDD, approved) | +| Reference implementation | votee-scala (io.hiis.votee) | + +## 1. Purpose and Scope + +The PDD defines what is being built and why. This document defines how: concrete class shapes, method signatures, the algorithm-by-algorithm translation strategy, the Maven module layout, the publishing configuration, and the test plan. Anything that is a judgment call rather than a mechanical translation is called out explicitly with its rationale, so the reasoning survives even after the code is written. + +This document is a design reference for manual implementation, not generated production code. Two or three algorithms are worked through in full as pattern examples; the rest are specified as pseudocode against those same patterns. + +## 2. Maven Module Layout + +``` +votee/ + pom.xml + docs/ + product-design.md + low-level-design.md + src/ + main/ + java/ + com/ludovictemgoua/votee/ + model/ + Candidate.java + PreferentialCandidate.java + Ballot.java + PreferentialBallot.java + Rational.java + Election.java + AbstractPreferentialElection.java + TieResolver.java + TieResolvers.java + Winner.java + algorithms/ + Majority.java + SuperMajority.java + Approval.java + Veto.java + BordaCount.java + Baldwin.java + Contingent.java + Coombs.java + ExhaustiveBallot.java + test/ + java/ + com/ludovictemgoua/votee/ + algorithms/ + MajorityTest.java + SuperMajorityTest.java + ApprovalTest.java + VetoTest.java + BordaCountTest.java + BaldwinTest.java + ContingentTest.java + CoombsTest.java + ExhaustiveBallotTest.java + support/ + FixtureLoader.java + resources/ + fixtures/ + 01-candidates.json + 01-ballots.json + 02-ballots.json + 03-ballots.json +``` + +Class naming note: the Scala source spells two algorithms `BaldWin` and `Coomb`. The Java port uses the conventionally capitalized `Baldwin` and the correctly spelled `Coombs`, since there is no external consumer depending on the old names yet (this is a new artifact, not a maintained public API). + +## 3. Package Structure + +Two packages under the root `com.ludovictemgoua.votee`: + +- `com.ludovictemgoua.votee.model`: the domain contracts and value types (Candidate, Ballot, Election, TieResolver, Winner, Rational) plus their default implementations. +- `com.ludovictemgoua.votee.algorithms`: one class per voting algorithm, each a thin static entry point plus an instance-level implementation. + +No sub-packages beyond this; nine algorithm classes in one package is small enough not to need further nesting. + +## 4. Domain Model + +### 4.1 Rational + +Exact fraction type. Implemented as a Java record with a compact constructor that normalizes on construction, since records support validation and field reassignment in a compact constructor before the canonical fields are set. + +```java +public record Rational(BigInteger numerator, BigInteger denominator) implements Comparable { + + public static final Rational ZERO = new Rational(BigInteger.ZERO, BigInteger.ONE); + public static final Rational ONE = new Rational(BigInteger.ONE, BigInteger.ONE); + + public Rational { + if (denominator.signum() == 0) { + throw new ArithmeticException("Rational denominator cannot be zero"); + } + if (denominator.signum() < 0) { + numerator = numerator.negate(); + denominator = denominator.negate(); + } + BigInteger gcd = numerator.gcd(denominator); + if (gcd.signum() != 0 && !gcd.equals(BigInteger.ONE)) { + numerator = numerator.divide(gcd); + denominator = denominator.divide(gcd); + } + } + + public static Rational of(long numerator, long denominator) { + return new Rational(BigInteger.valueOf(numerator), BigInteger.valueOf(denominator)); + } + + public static Rational whole(long value) { + return of(value, 1); + } + + public Rational add(Rational other) { /* cross multiply, see below */ } + public Rational subtract(Rational other) { return add(other.negate()); } + public Rational multiply(Rational other) { /* numerator*numerator, denominator*denominator, let the constructor reduce */ } + public Rational divide(Rational other) { /* multiply by other's reciprocal */ } + public Rational negate() { return new Rational(numerator.negate(), denominator); } + + @Override + public int compareTo(Rational other) { + return numerator.multiply(other.denominator).compareTo(other.numerator.multiply(denominator)); + } + + @Override + public String toString() { + return denominator.equals(BigInteger.ONE) ? numerator.toString() : numerator + "/" + denominator; + } +} +``` + +Design notes: + +- The constructor always reduces to lowest terms and keeps the denominator positive, so `equals`/`hashCode` (record-generated, field-based) are reliable for map keys and test assertions without a custom implementation. +- `BigInteger` gives arbitrary precision, so there is no overflow risk from repeated addition across many ballots, unlike a `long`-based fraction. +- No `doubleValue()` conversion is required anywhere in the algorithms themselves; it can be added later purely for display purposes if needed. + +### 4.2 Candidate and PreferentialCandidate + +```java +public interface Candidate { + String id(); +} + +public record PreferentialCandidate(String id, String name, String party) implements Candidate { + public PreferentialCandidate(String id, String name) { + this(id, name, null); + } +} +``` + +Design note: the Scala version models `party` as `Option[String]`. The Java port keeps `party` as a plain nullable `String` record component rather than `Optional`, following the standard Java guidance against using `Optional` as a field or record component type. Callers who want an `Optional` wrap it at the call site: `Optional.ofNullable(candidate.party())`. This also sidesteps needing the extra Jackson module required to (de)serialize `Optional` fields when loading the JSON test fixtures. + +### 4.3 Ballot and PreferentialBallot + +Scala's `Ballot` uses a self-referential higher-kinded type parameter so `exclude`/`include` return the concrete ballot type. Java has no equivalent construct; the nearest idiomatic translation is the Curiously Recurring Generic Pattern (CRGP), per the PDD decision log: + +```java +public interface Ballot> { + int id(); + Rational weight(); + List preferences(); + SELF exclude(Collection candidates); + SELF include(Collection candidates); +} + +public record PreferentialBallot(int id, Rational weight, List preferences) + implements Ballot> { + + public PreferentialBallot { + preferences = List.copyOf(preferences); + } + + public static PreferentialBallot of(int id, List preferences) { + return new PreferentialBallot<>(id, Rational.ONE, preferences); + } + + @Override + public PreferentialBallot exclude(Collection candidates) { + return new PreferentialBallot<>(id, weight, preferences.stream() + .filter(c -> !candidates.contains(c)) + .toList()); + } + + @Override + public PreferentialBallot include(Collection candidates) { + List combined = new ArrayList<>(candidates); + combined.addAll(preferences); + return new PreferentialBallot<>(id, weight, combined); + } +} +``` + +Design note: Scala's `include`/`exclude` signatures allow widening the candidate type (`CC >: C`), since Scala's collections are covariant. Java generics are invariant, so `include`/`exclude` here stay fixed at `C` (accepting `Collection`, not a supertype). This is an accepted deviation per the PDD non-goals: the library only ever operates on one concrete candidate type per election in practice, so this loss of flexibility has no real-world effect on the nine algorithms. + +Also note `List.copyOf` in the compact constructor: this is where "immutable value types" from the PDD's non-functional requirements is actually enforced, since a caller could otherwise hand in a mutable `ArrayList` and mutate it after construction. + +### 4.4 TieResolver and TieResolvers + +```java +@FunctionalInterface +public interface TieResolver { + List> resolve(List> tiedScores); +} + +public final class TieResolvers { + private TieResolvers() {} + + public static TieResolver doNothing() { + return tied -> tied; + } + + public static TieResolver random() { + return tied -> { + List> shuffled = new ArrayList<>(tied); + Collections.shuffle(shuffled); + return shuffled; + }; + } + + public static TieResolver reverse() { + return tied -> { + List> reversed = new ArrayList<>(tied); + Collections.reverse(reversed); + return reversed; + }; + } +} +``` + +Design note: Scala expresses the three built-in resolvers as members of `Election.TieResolvers`, reached via `given`/`using` implicit resolution so a default is supplied automatically when the caller omits one. Java has no implicits; the Java port makes this explicit in two ways: `TieResolvers` is a plain static factory class, and every algorithm exposes a `run(...)` overload without a `TieResolver` parameter that forwards to the full overload with `TieResolvers.doNothing()`. This is a mechanical, low-risk translation (also called out as a risk in the PDD, section 11). + +### 4.5 Winner + +```java +public record Winner(C candidate, Rational score) { + public static Winner of(Map.Entry entry) { + return new Winner<>(entry.getKey(), entry.getValue()); + } +} +``` + +### 4.6 Election and AbstractPreferentialElection + +```java +public interface Election, W> { + List run(List ballots, List candidates, int vacancies, TieResolver tieResolver); + + default List run(List ballots, List candidates, int vacancies) { + return run(ballots, candidates, vacancies, TieResolvers.doNothing()); + } +} + +public abstract class AbstractPreferentialElection> + implements Election> { + + protected static final Rational MAJORITY_THRESHOLD = Rational.of(1, 2); + + protected final List> resolveTies( + List> sortedScores, TieResolver tieResolver) { + List> result = new ArrayList<>(); + int i = 0; + while (i < sortedScores.size()) { + Rational score = sortedScores.get(i).getValue(); + int j = i; + while (j < sortedScores.size() && sortedScores.get(j).getValue().equals(score)) { + j++; + } + result.addAll(tieResolver.resolve(sortedScores.subList(i, j))); + i = j; + } + return result; + } + + protected final Map countFirstVotes(List ballots, List candidates) { + return countPreference(ballots, candidates, List::getFirst); + } + + protected final Map countLastVotes(List ballots, List candidates) { + return countPreference(ballots, candidates, List::getLast); + } + + private Map countPreference( + List ballots, List candidates, Function, C> pick) { + Map scores = new LinkedHashMap<>(); + for (B ballot : ballots) { + List valid = ballot.preferences().stream().filter(candidates::contains).toList(); + if (!valid.isEmpty()) { + C candidate = pick.apply(valid); + scores.merge(candidate, ballot.weight(), Rational::add); + } + } + return scores; + } +} +``` + +Design notes: + +- `Election` stays an interface (the public contract, matching the Scala `Election` trait). `AbstractPreferentialElection` is an abstract class rather than an interface, because it needs `protected` helper methods (`resolveTies`, `countFirstVotes`, `countLastVotes`) that are implementation detail, not part of the public contract. Java interfaces cannot have `protected` members, so an abstract class is the correct tool here, not a design compromise. +- `countFirstVotes`/`countLastVotes` are unified into one private `countPreference` helper parameterized by which end of the (filtered) preference list to pick, since the Scala versions are identical except for `find` vs `findLast`. This is a small simplification beyond a literal translation, justified because both call sites in the reference are otherwise copy-pasted. +- The accumulator map is a `LinkedHashMap`, not `HashMap`. This is a deliberate choice, not an oversight: see section 6 below on determinism. + +## 5. Algorithm Implementations + +### 5.1 Shape summary + +| Algorithm | Shape | Notes | +|---|---|---| +| Majority | A: threshold filter | Fixed threshold of one half | +| Super Majority | A: threshold filter | Same as Majority, threshold is a constructor/parameter argument | +| Approval | B: full tally and rank | Every listed preference scores a full vote | +| Veto | B: full tally and rank | Every preference except a voter's last choice scores a point | +| Borda Count | B: full tally and rank | Score by rank position, weighted | +| Coombs' Method | C: iterative elimination | Eliminate the most-last-ranked candidate each round | +| Baldwin | C: iterative elimination | Eliminate the lowest Borda scorer each round | +| Exhaustive Ballot | C: iterative elimination | Eliminate the lowest first-preference scorer each round | +| Contingent Vote | D: single runoff | One elimination round, not iterated | + +Each algorithm class follows the same static entry point pattern used throughout the Scala reference's companion objects, adapted to Java: + +```java +public final class Majority> + extends AbstractPreferentialElection { + + public static > List> run( + List ballots, List candidates, int vacancies) { + return run(ballots, candidates, vacancies, TieResolvers.doNothing()); + } + + public static > List> run( + List ballots, List candidates, int vacancies, TieResolver tieResolver) { + return new Majority().run(ballots, candidates, vacancies, tieResolver); + } + + @Override + public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { + Rational threshold = Rational.whole(ballots.size()).multiply(MAJORITY_THRESHOLD); + List> sorted = countFirstVotes(ballots, candidates).entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + return resolveTies(sorted, tieResolver).stream() + .filter(e -> e.getValue().compareTo(threshold) > 0) + .limit(vacancies) + .map(Winner::of) + .toList(); + } +} +``` + +This gives every algorithm two static overloads (with and without an explicit `TieResolver`) plus one instance method carrying the actual logic, mirroring `object Majority { def run(...) = new Majority[C, B]{}.run(...) }` from the Scala source, but without needing an anonymous class since `AbstractPreferentialElection` is directly instantiable here. + +### 5.2 Worked example: Approval (shape B) + +```java +@Override +public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { + Map scores = new LinkedHashMap<>(); + for (B ballot : ballots) { + for (C candidate : ballot.preferences()) { + scores.merge(candidate, ballot.weight(), Rational::add); + } + } + List> sorted = scores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + return resolveTies(sorted, tieResolver).stream() + .limit(vacancies) + .map(Winner::of) + .toList(); +} +``` + +Veto follows the same shape, scoring every preference on a ballot except the last one (guarding the single-preference-ballot edge case the same way the Scala source does: a ballot with exactly one preference does not veto it). Borda Count also follows this shape, but the inner loop scores `candidates.size() - 1 - index` points per ranked position instead of a flat 1 point, and filters preferences down to the currently eligible `candidates` list first (relevant once Borda scoring is reused inside Baldwin's elimination loop). + +### 5.3 Worked example: Coombs' Method (shape C, iterative elimination) + +The Scala version is written as `@tailrec` self-recursion. The JVM does not guarantee tail-call optimization for javac-compiled bytecode the way Scala's compiler verifies and rewrites `@tailrec` methods into loops at compile time, so a literal recursive translation risks a `StackOverflowError` on a large enough candidate list. Every algorithm in shape C is written as an explicit `while` loop in Java instead of recursion. This is a deliberate, LLD-level decision, not just a style preference. + +```java +@Override +public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { + List remaining = new ArrayList<>(candidates); + while (!remaining.isEmpty()) { + Map firstVotes = countFirstVotes(ballots, remaining); + Rational majorityThreshold = MAJORITY_THRESHOLD.multiply(Rational.whole(ballots.size())); + List> overMajority = firstVotes.entrySet().stream() + .filter(e -> e.getValue().compareTo(majorityThreshold) > 0) + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + if (!overMajority.isEmpty()) { + return resolveTies(overMajority, tieResolver).stream().limit(1).map(Winner::of).toList(); + } + Map lastVotes = countLastVotes(ballots, remaining); + List> sortedLast = lastVotes.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + C mostLastRanked = resolveTies(sortedLast, tieResolver).get(0).getKey(); + remaining.remove(mostLastRanked); + } + return List.of(); +} +``` + +Baldwin follows the same while-loop shape but eliminates the lowest Borda scorer each round and stops once one candidate remains (returning it directly, matching the Scala base case). Exhaustive Ballot follows the same shape, eliminating the lowest first-preference scorer via `ballot.exclude(...)` each round (this is the one place `Ballot.exclude` is actually exercised) and stopping once two candidates remain. + +### 5.4 Worked example: Contingent Vote (shape D, single runoff) + +```java +@Override +public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { + Map scores = new LinkedHashMap<>(countFirstVotes(ballots, candidates)); + List> sorted = resolveTies(scores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(), tieResolver); + + if (sorted.get(0).getValue().compareTo(MAJORITY_THRESHOLD) > 0) { + return List.of(Winner.of(sorted.get(0))); + } + + List topTwo = sorted.stream().limit(2).map(Map.Entry::getKey).toList(); + for (B ballot : ballots) { + if (!topTwo.contains(ballot.preferences().getFirst())) { + ballot.preferences().stream() + .filter(topTwo::contains) + .findFirst() + .ifPresent(candidate -> scores.merge(candidate, ballot.weight(), Rational::add)); + } + } + + List> finalRound = resolveTies(scores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(), tieResolver); + return List.of(Winner.of(finalRound.get(0))); +} +``` + +## 6. Determinism and Tie-Break Ordering + +The PDD (section 6.2/9) defines parity as identical winner output for identical input, not identical internal iteration order. That distinction matters here: Scala's `mutable.HashMap` iteration order depends on the case class's Scala-generated `hashCode` (MurmurHash3-based), which has no equivalent in Java's record-generated `hashCode`. If two candidates end up exactly tied on score and no `TieResolver` breaks the tie deterministically by content, the two implementations could order them differently even though each is internally consistent. + +Mitigation used throughout section 5: every accumulator map is a `LinkedHashMap`, not a `HashMap`, so iteration order always matches ballot-processing order (first-seen order) rather than an unspecified hash-bucket order. Combined with `Stream.sorted`, which is documented as a stable sort, this makes the Java port's own output deterministic and reproducible across runs. It does not guarantee bit-identical ordering to the Scala side in a tie, but the fixture data checked in section 7.1 below produces no ties under `TieResolvers.doNothing()`, so this has no effect on the parity tests defined for this pass. It is recorded here so it is not rediscovered as a surprise if new fixture data introduces a real tie later. + +## 7. Test Plan + +### 7.1 Fixture files and expected winners + +All fixture files are ported verbatim from `votee-scala/src/main/resources` into `votee/src/test/resources/fixtures`. `01-candidates.json` (candidates a, b, c, d) is used by every test. `02-ballots.json` is ported for parity even though no existing Scala spec currently exercises it. + +| Algorithm | Ballots file | Expected winner(s) | +|---|---|---| +| Majority | 03-ballots.json | a | +| Super Majority (threshold 6/10) | 03-ballots.json | (none) | +| Approval | 03-ballots.json | d | +| Veto | 03-ballots.json | d | +| Borda Count | 03-ballots.json | a | +| Baldwin | 03-ballots.json | a | +| Contingent Vote | 03-ballots.json | a | +| Coombs' Method | 03-ballots.json | a | +| Exhaustive Ballot | 01-ballots.json | b | + +These are the same fixture-to-expected-winner pairs already proven correct by the existing `votee-scala` test suite; the Java tests assert the exact same pairs. + +### 7.2 Fixture loading utility + +```java +public final class FixtureLoader { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private FixtureLoader() {} + + public static List candidates(String fileName) throws IOException { + try (InputStream in = FixtureLoader.class.getResourceAsStream("/fixtures/" + fileName)) { + return MAPPER.readValue(in, new TypeReference>() {}); + } + } + + public static List> ballots(String fileName) throws IOException { + try (InputStream in = FixtureLoader.class.getResourceAsStream("/fixtures/" + fileName)) { + return MAPPER.readValue(in, new TypeReference>>() {}); + } + } +} +``` + +This mirrors the Scala `Parser` utility's role, using Jackson instead of `play-json` (a test-scope-only dependency, per the PDD). Ballot deserialization needs a small custom Jackson module or a `@JsonCreator` constructor on `PreferentialBallot`, since the JSON's `weight` field is a plain number and needs converting into a `Rational` rather than the record's own `BigInteger`-pair shape; this is flagged here as an implementation detail to work out with a `@JsonCreator`-annotated static factory rather than the canonical constructor. + +### 7.3 Test class pattern + +One JUnit 5 test class per algorithm, following: + +```java +class MajorityTest { + + @Test + void picksTheFirstPreferenceMajorityWinner() throws IOException { + var candidates = FixtureLoader.candidates("01-candidates.json"); + var ballots = FixtureLoader.ballots("03-ballots.json"); + + var winners = Majority.run(ballots, candidates, 1); + + assertThat(winners).extracting(Winner::candidate) + .containsExactly(candidates.stream().filter(c -> c.id().equals("a")).findFirst().orElseThrow()); + } +} +``` + +AssertJ is used for the fluent assertion style shown above; this needs to be added as a test-scope dependency alongside JUnit 5 and Jackson. + +## 8. Build and Publishing Configuration + +Indicative `pom.xml` shape (exact plugin versions to be filled in at implementation time): + +```xml + + com.ludovictemgoua + votee + 0.1.0-SNAPSHOT + jar + + + 21 + UTF-8 + + + + + com.fasterxml.jackson.core + jackson-databind + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + github + GitHub Packages + https://maven.pkg.github.com/icemc/votee + + + + + + + org.apache.maven.plugins + maven-source-plugin + + + + +``` + +Notes: + +- The `github` server id in `distributionManagement` must have matching credentials in the local Maven `settings.xml` (`github...${env.GITHUB_TOKEN}`), the same pattern the Scala project already documents in its own README for `sbt-github-packages`. +- `distributionManagement` URL assumes the Java port is pushed to its own repository at `github.com/icemc/votee`; adjust the path if the artifact instead publishes under a different repository name. +- Starting version is `0.1.0-SNAPSHOT` during active development; the first `mvn deploy` that is meant to be consumed drops the `-SNAPSHOT` suffix per the Early SemVer scheme in the PDD (section 8.1). + +## 9. Deviations from the Scala Reference + +Consolidated list of every place this design deliberately departs from a literal translation, for quick review: + +1. Ballot generics use the Curiously Recurring Generic Pattern instead of Scala's higher-kinded self-type (PDD decision log). +2. `Candidate.party` is a nullable `String`, not `Optional` (section 4.2). +3. `Ballot.exclude`/`include` are invariant in `C`, not covariant-widening like the Scala version (section 4.3). +4. Tie-resolver defaults are explicit method overloads, not implicit `given`/`using` parameters (section 4.4). +5. `countFirstVotes`/`countLastVotes` share one private helper instead of two near-identical methods (section 4.6). +6. Recursive (`@tailrec`) algorithms are rewritten as `while` loops, since javac gives no tail-call guarantee (section 5.3). +7. Score accumulators use `LinkedHashMap` for deterministic iteration order, rather than relying on hash-bucket order (section 6). +8. Two algorithm names are corrected in casing/spelling: `BaldWin` becomes `Baldwin`, `Coomb` becomes `Coombs` (section 2). + +## 10. Open Items for Implementation + +- Exact Jackson binding strategy for `Rational` and `PreferentialBallot.weight` (custom deserializer vs. `@JsonCreator`) needs to be finalized once implementation starts; section 7.2 flags the shape of the problem but not the final code. +- Plugin versions in the `pom.xml` skeleton (section 8) are left unpinned; fill in current stable versions at implementation time rather than pinning them in a design document that may go stale. +- Whether `Rational` also needs a `toBigDecimal(MathContext)` convenience method for any future display/reporting use case outside the nine algorithms. Not required by anything in this design; add only if a concrete need shows up. diff --git a/votee/docs/product-design.md b/votee/docs/product-design.md index a48bd65..4406899 100644 --- a/votee/docs/product-design.md +++ b/votee/docs/product-design.md @@ -6,7 +6,7 @@ | **Status** | Draft | | **Date** | 2026-07-04 | | **Related docs** | `votee/docs/low-level-design.md` (follow-up, not yet written) | -| **Reference implementation** | [`votee-scala`](../../votee-scala) (`com.ludovictemgoua.votee`, github.com/Hiis-io/Votee) | +| **Reference implementation** | [`votee-scala`](https://github.com/icemc/votee) (`com.ludovictemgoua.votee`, github.com/icemc/Votee) | ## 1. Overview From 40101bfe9b6c5a18900b5797ed6e315c71df5cf5 Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sun, 5 Jul 2026 01:46:37 +0200 Subject: [PATCH 03/16] Added base models and Implemented Majority vote countung algorithm --- votee/pom.xml | 81 +++++++++++++++++++ .../votee/algorithms/Majority.java | 31 +++++++ .../model/AbstractPreferentialElection.java | 49 +++++++++++ .../ludovictemgoua/votee/model/Ballot.java | 26 ++++++ .../ludovictemgoua/votee/model/Candidate.java | 6 ++ .../ludovictemgoua/votee/model/Election.java | 12 +++ .../votee/model/PreferentialBallot.java | 30 +++++++ .../votee/model/PreferentialCandidate.java | 7 ++ .../ludovictemgoua/votee/model/Rational.java | 72 +++++++++++++++++ .../votee/model/TieResolver.java | 9 +++ .../votee/model/TieResolvers.java | 32 ++++++++ .../ludovictemgoua/votee/model/Winner.java | 9 +++ 12 files changed, 364 insertions(+) create mode 100644 votee/pom.xml create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/algorithms/Majority.java create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/model/AbstractPreferentialElection.java create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/model/Ballot.java create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/model/Candidate.java create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/model/Election.java create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/model/PreferentialBallot.java create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/model/PreferentialCandidate.java create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/model/Rational.java create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/model/TieResolver.java create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/model/TieResolvers.java create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/model/Winner.java diff --git a/votee/pom.xml b/votee/pom.xml new file mode 100644 index 0000000..c344f1c --- /dev/null +++ b/votee/pom.xml @@ -0,0 +1,81 @@ + + + 4.0.0 + + com.ludovictemgoua + votee + 0.1.0-SNAPSHOT + jar + + votee + Java library of pluggable vote-counting algorithms for elections, ported from votee-scala. + https://github.com/icemc/votee + + + 21 + UTF-8 + + 5.10.2 + 3.25.3 + 2.17.0 + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + test + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + + jar + + + + + + + + + + github + GitHub Packages + https://maven.pkg.github.com/icemc/votee + + + diff --git a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Majority.java b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Majority.java new file mode 100644 index 0000000..ed97fb9 --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Majority.java @@ -0,0 +1,31 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.*; + +import java.util.List; +import java.util.Map; + +public final class Majority> extends AbstractPreferentialElection { + + public static > List> elect( + List ballots, List candidates, int vacancies, TieResolver tieResolver) { + return new Majority().run(ballots, candidates, vacancies, tieResolver); + } + + public static > List> elect(List ballots, List candidates, int vacancies) { + return new Majority().run(ballots, candidates, vacancies, TieResolvers.doNothing()); + } + + @Override + public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { + Rational threshold = Rational.whole(ballots.size()).multiply(MAJORITY_THRESHOLD); + List> sorted = countFirstVotes(ballots, candidates).entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + return resolveTies(sorted, tieResolver).stream() + .filter(e -> e.getValue().compareTo(threshold) > 0) + .limit(vacancies) + .map(Winner::of) + .toList(); + } +} diff --git a/votee/src/main/java/com/ludovictemgoua/votee/model/AbstractPreferentialElection.java b/votee/src/main/java/com/ludovictemgoua/votee/model/AbstractPreferentialElection.java new file mode 100644 index 0000000..73ac429 --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/model/AbstractPreferentialElection.java @@ -0,0 +1,49 @@ +package com.ludovictemgoua.votee.model; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +public abstract class AbstractPreferentialElection> implements Election> { + + protected static final Rational MAJORITY_THRESHOLD = Rational.of(1, 2); + + protected final Map countFirstVotes(List ballots, List candidates) { + return countPreference(ballots, candidates, List::getFirst); + } + + protected final List> resolveTies( + List> sortedScores, TieResolver tieResolver) { + List> result = new ArrayList<>(); + int i = 0; + while (i < sortedScores.size()) { + Rational score = sortedScores.get(i).getValue(); + int j = i; + while (j < sortedScores.size() && sortedScores.get(j).getValue().equals(score)) { + j++; + } + result.addAll(tieResolver.resolve(sortedScores.subList(i, j))); + i = j; + } + return result; + } + + protected final Map countLastVotes(List ballots, List candidates) { + return countPreference(ballots, candidates, List::getLast); + } + + private Map countPreference( + List ballots, List candidates, Function, C> pick) { + Map scores = new LinkedHashMap<>(); + for (B ballot : ballots) { + List valid = ballot.preferences().stream().filter(candidates::contains).toList(); + if (!valid.isEmpty()) { + C candidate = pick.apply(valid); + scores.merge(candidate, ballot.weight(), Rational::add); + } + } + return scores; + } +} diff --git a/votee/src/main/java/com/ludovictemgoua/votee/model/Ballot.java b/votee/src/main/java/com/ludovictemgoua/votee/model/Ballot.java new file mode 100644 index 0000000..d559630 --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/model/Ballot.java @@ -0,0 +1,26 @@ +package com.ludovictemgoua.votee.model; + +import java.util.Collection; +import java.util.List; + +public interface Ballot > { + int id(); + Rational weight(); + List preferences(); + + /** + * Filters preferences to exclude the specified candidates and returns a new ballot with the remaining preferences. + * @param candidates the candidates to exclude + * @return a new ballot with the specified candidates excluded + */ + SELF exclude(Collection candidates); + + /** + * Filters the preferences to include only the specified candidates and returns a new ballot with the filtered preferences. + * + * @param candidates the candidates to include + * @return a new ballot with the filtered preferences + */ + SELF include(Collection candidates); +} + diff --git a/votee/src/main/java/com/ludovictemgoua/votee/model/Candidate.java b/votee/src/main/java/com/ludovictemgoua/votee/model/Candidate.java new file mode 100644 index 0000000..7b29c3e --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/model/Candidate.java @@ -0,0 +1,6 @@ +package com.ludovictemgoua.votee.model; + +public interface Candidate { + String id(); +} + diff --git a/votee/src/main/java/com/ludovictemgoua/votee/model/Election.java b/votee/src/main/java/com/ludovictemgoua/votee/model/Election.java new file mode 100644 index 0000000..2ab0a15 --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/model/Election.java @@ -0,0 +1,12 @@ +package com.ludovictemgoua.votee.model; + +import java.util.List; + +public interface Election, W extends Winner> { + List run(List ballots, List candidates, int vacancies, TieResolver tieResolver); + + default List run(List ballots, List candidates, int vacancies) { + return run(ballots, candidates, vacancies, TieResolvers.doNothing()); + } +} + diff --git a/votee/src/main/java/com/ludovictemgoua/votee/model/PreferentialBallot.java b/votee/src/main/java/com/ludovictemgoua/votee/model/PreferentialBallot.java new file mode 100644 index 0000000..9de602e --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/model/PreferentialBallot.java @@ -0,0 +1,30 @@ +package com.ludovictemgoua.votee.model; + +import java.util.Collection; +import java.util.List; + +public record PreferentialBallot(int id, Rational weight, + List preferences) implements Ballot> { + + public PreferentialBallot { + preferences = List.copyOf(preferences); + } + + public static PreferentialBallot of(int id, List preferences) { + return new PreferentialBallot<>(id, Rational.ONE, preferences); + } + + @Override + public PreferentialBallot exclude(Collection candidates) { + return new PreferentialBallot<>(id, weight, preferences.stream() + .filter(candidate -> !candidates.contains(candidate)) + .toList()); + } + + @Override + public PreferentialBallot include(Collection candidates) { + return new PreferentialBallot<>(id, weight, preferences.stream() + .filter(candidates::contains) + .toList()); + } +} diff --git a/votee/src/main/java/com/ludovictemgoua/votee/model/PreferentialCandidate.java b/votee/src/main/java/com/ludovictemgoua/votee/model/PreferentialCandidate.java new file mode 100644 index 0000000..53318ea --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/model/PreferentialCandidate.java @@ -0,0 +1,7 @@ +package com.ludovictemgoua.votee.model; + +public record PreferentialCandidate(String id, String name, String party) implements Candidate { + public PreferentialCandidate(String id, String name) { + this(id, name, null); + } +} diff --git a/votee/src/main/java/com/ludovictemgoua/votee/model/Rational.java b/votee/src/main/java/com/ludovictemgoua/votee/model/Rational.java new file mode 100644 index 0000000..5fea9ef --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/model/Rational.java @@ -0,0 +1,72 @@ +package com.ludovictemgoua.votee.model; + +import java.math.BigInteger; + +public record Rational(BigInteger numerator, BigInteger denominator) implements Comparable { + + public static final Rational ZERO = new Rational(BigInteger.ZERO, BigInteger.ONE); + public static final Rational ONE = new Rational(BigInteger.ONE, BigInteger.ONE); + + // Validate the Rational number to ensure the denominator is not zero and reduce it to its simplest form + public Rational { + if (denominator.signum() == 0) { + throw new IllegalArgumentException("Denominator cannot be zero"); + } + if(denominator.signum() < 0) { + numerator = numerator.negate(); + denominator = denominator.negate(); + } + BigInteger gcd = numerator.gcd(denominator); + if(gcd.signum() != 0 && !gcd.equals(BigInteger.ONE)) { + numerator = numerator.divide(gcd); + denominator = denominator.divide(gcd); + } + } + + public static Rational of(long numerator, long denominator) { + return new Rational(BigInteger.valueOf(numerator), BigInteger.valueOf(denominator)); + } + + public static Rational whole(long value) { + return new Rational(BigInteger.valueOf(value), BigInteger.ONE); + } + public Rational add(Rational other) { + BigInteger newNumerator = this.numerator.multiply(other.denominator).add(other.numerator.multiply(this.denominator)); + BigInteger newDenominator = this.denominator.multiply(other.denominator); + return new Rational(newNumerator, newDenominator); + } + + public Rational subtract(Rational other) { + BigInteger newNumerator = this.numerator.multiply(other.denominator).subtract(other.numerator.multiply(this.denominator)); + BigInteger newDenominator = this.denominator.multiply(other.denominator); + return new Rational(newNumerator, newDenominator); + } + + public Rational multiply(Rational other) { + BigInteger newNumerator = this.numerator.multiply(other.numerator); + BigInteger newDenominator = this.denominator.multiply(other.denominator); + return new Rational(newNumerator, newDenominator); + } + + public Rational divide(Rational other) { + if (other.numerator.signum() == 0) { + throw new ArithmeticException("Cannot divide by zero"); + } + BigInteger newNumerator = this.numerator.multiply(other.denominator); + BigInteger newDenominator = this.denominator.multiply(other.numerator); + return new Rational(newNumerator, newDenominator); + } + + public Rational negate() { + return new Rational(this.numerator.negate(), this.denominator); + } + + public Rational abs() { + return new Rational(this.numerator.abs(), this.denominator); + } + + @Override + public int compareTo(Rational other) { + return numerator.multiply(other.denominator).compareTo(other.numerator.multiply(denominator)); + } +} diff --git a/votee/src/main/java/com/ludovictemgoua/votee/model/TieResolver.java b/votee/src/main/java/com/ludovictemgoua/votee/model/TieResolver.java new file mode 100644 index 0000000..52a1544 --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/model/TieResolver.java @@ -0,0 +1,9 @@ +package com.ludovictemgoua.votee.model; + +import java.util.List; +import java.util.Map; + +public interface TieResolver { + List> resolve(List> tiedScores); +} + diff --git a/votee/src/main/java/com/ludovictemgoua/votee/model/TieResolvers.java b/votee/src/main/java/com/ludovictemgoua/votee/model/TieResolvers.java new file mode 100644 index 0000000..5322266 --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/model/TieResolvers.java @@ -0,0 +1,32 @@ +package com.ludovictemgoua.votee.model; + +import java.sql.Array; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public final class TieResolvers { + private TieResolvers() { + } + + public static TieResolver doNothing() { + return (List> tiedScores) -> tiedScores; + } + + public static TieResolver random() { + return (List> tied) -> { + List> shuffled = new ArrayList<>(tied); + Collections.shuffle(shuffled); + return shuffled; + }; + } + + public static TieResolver reverse() { + return (List> tied) -> { + List> reversed = new ArrayList<>(tied); + Collections.reverse(reversed); + return reversed; + }; + } +} diff --git a/votee/src/main/java/com/ludovictemgoua/votee/model/Winner.java b/votee/src/main/java/com/ludovictemgoua/votee/model/Winner.java new file mode 100644 index 0000000..c2b0621 --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/model/Winner.java @@ -0,0 +1,9 @@ +package com.ludovictemgoua.votee.model; + +import java.util.Map; + +public record Winner(C candidate, Rational score) { + public static Winner of(Map.Entry entry) { + return new Winner<>(entry.getKey(), entry.getValue()); + } +} \ No newline at end of file From fe48c7eb0efd250f3b4b3d5e3403c1f3f4e02ee9 Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sun, 5 Jul 2026 02:22:00 +0200 Subject: [PATCH 04/16] Added README and tests --- votee/README.md | 77 +++ .../votee/algorithms/MajorityTest.java | 62 ++ .../votee/model/PreferentialBallotTest.java | 61 ++ .../votee/model/RationalTest.java | 95 +++ .../votee/model/TieResolversTest.java | 43 ++ .../votee/support/FixtureLoader.java | 77 +++ .../test/resources/fixtures/01-ballots.json | 640 ++++++++++++++++++ .../resources/fixtures/01-candidates.json | 18 + .../test/resources/fixtures/02-ballots.json | 200 ++++++ .../test/resources/fixtures/03-ballots.json | 200 ++++++ 10 files changed, 1473 insertions(+) create mode 100644 votee/README.md create mode 100644 votee/src/test/java/com/ludovictemgoua/votee/algorithms/MajorityTest.java create mode 100644 votee/src/test/java/com/ludovictemgoua/votee/model/PreferentialBallotTest.java create mode 100644 votee/src/test/java/com/ludovictemgoua/votee/model/RationalTest.java create mode 100644 votee/src/test/java/com/ludovictemgoua/votee/model/TieResolversTest.java create mode 100644 votee/src/test/java/com/ludovictemgoua/votee/support/FixtureLoader.java create mode 100644 votee/src/test/resources/fixtures/01-ballots.json create mode 100644 votee/src/test/resources/fixtures/01-candidates.json create mode 100644 votee/src/test/resources/fixtures/02-ballots.json create mode 100644 votee/src/test/resources/fixtures/03-ballots.json diff --git a/votee/README.md b/votee/README.md new file mode 100644 index 0000000..1a1a8e9 --- /dev/null +++ b/votee/README.md @@ -0,0 +1,77 @@ +# votee + +A Java library of pluggable vote-counting algorithms for elections - a Java port of [votee-scala](../votee-scala), an existing Scala 3 library of mine implementing the same domain. + +![Java](https://img.shields.io/badge/Java-21-orange) +![Build](https://img.shields.io/badge/build-Maven-blue) +![Status](https://img.shields.io/badge/status-in--development-yellow) + +## What this is + +Given a set of candidates and ballots, `votee` runs a chosen election algorithm (Majority, Approval, Borda Count, and so on) and returns the winner(s). Vote weights and scores are tracked as exact rationals rather than floating-point numbers, so tallies never drift due to rounding. Consumers can use the built-in `PreferentialCandidate`/`PreferentialBallot` types, or implement the `Candidate`/`Ballot` contracts themselves. + +The full rationale behind every design decision in this port (why Java's generics need a different shape than Scala's, why `Rational` is hand-written instead of a dependency, why algorithms are iterative instead of recursive, and so on) is written up in: + +- [`docs/product-design.md`](docs/product-design.md) - what is being built and why +- [`docs/low-level-design.md`](docs/low-level-design.md) - concrete class shapes, per-algorithm design, test plan, and build/publishing configuration + +## Status + +Domain model (`Candidate`, `Ballot`, `Election`, `TieResolver`, `Winner`, `Rational`) is implemented. Of the nine algorithms in the reference implementation: + +- [x] Majority +- [ ] Super Majority +- [ ] Approval +- [ ] Veto +- [ ] Borda Count +- [ ] Baldwin +- [ ] Contingent Vote +- [ ] Coombs' Method +- [ ] Exhaustive Ballot + +This list tracks the same nine algorithms `votee-scala` implements; see that project's own README for the longer list of voting methods neither library has implemented yet. + +## Getting started + +Requires JDK 21+ and Maven. + +``` +mvn test # run the test suite +mvn package # build the jar +``` + +## Usage + +```java +List candidates = List.of( + new PreferentialCandidate("a", "Alice"), + new PreferentialCandidate("b", "Bob"), + new PreferentialCandidate("c", "Carol") +); + +List> ballots = List.of( + PreferentialBallot.of(1, List.of(candidates.get(0), candidates.get(1), candidates.get(2))), + PreferentialBallot.of(2, List.of(candidates.get(0), candidates.get(2), candidates.get(1))), + PreferentialBallot.of(3, List.of(candidates.get(1), candidates.get(0), candidates.get(2))) +); + +List> winners = Majority.elect(ballots, candidates, 1); +``` + +`Majority.elect(...)` has an overload accepting an explicit `TieResolver` (see `TieResolvers` for the built-in `doNothing`/`random`/`reverse` strategies) for callers who need to control how tied scores are broken; the two-argument overload above defaults to `TieResolvers.doNothing()`. + +## Testing + +Tests live under `src/test/java`, split into: + +- `model/` - unit tests for the domain types (`Rational` arithmetic and reduction, `PreferentialBallot`'s `exclude`/`include`/immutability, the three `TieResolvers`) +- `algorithms/` - one test class per algorithm. `MajorityTest` covers a fixture-driven case (verified against the same JSON test data and expected winner as `votee-scala`'s own `MajoritySpec`) plus inline edge cases (an exact-half tie produces no winner; a clear majority wins) +- `support/FixtureLoader` - loads the JSON fixtures ported verbatim from `votee-scala/src/main/resources` into `src/test/resources/fixtures`, converting the plain JSON number for `weight` into a `Rational`. Kept test-scope-only (Jackson is a test dependency, not a runtime one) so the library itself stays dependency-free. + +## Publishing + +Coordinates: `com.ludovictemgoua:votee`, currently at `0.1.0-SNAPSHOT` (Early SemVer - see the PDD). Target registry is a private GitHub Packages Maven repository; the `pom.xml` `distributionManagement` block is already pointed at it. Maven Central remains a possible future upgrade, since the `com.ludovictemgoua` groupId already satisfies Central's domain-ownership requirement. + +## Reference implementation + +[`votee-scala`](../votee-scala) (`io.hiis.votee`) is the original Scala 3 library this port is based on, and is what every fixture-based test in this module is checked against for parity. diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/MajorityTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/MajorityTest.java new file mode 100644 index 0000000..fb0e550 --- /dev/null +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/MajorityTest.java @@ -0,0 +1,62 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.PreferentialBallot; +import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.Rational; +import com.ludovictemgoua.votee.model.TieResolvers; +import com.ludovictemgoua.votee.model.Winner; +import com.ludovictemgoua.votee.support.FixtureLoader; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MajorityTest { + + private final PreferentialCandidate a = new PreferentialCandidate("a", "A"); + private final PreferentialCandidate b = new PreferentialCandidate("b", "B"); + private final PreferentialCandidate c = new PreferentialCandidate("c", "C"); + + @Test + void picksTheFixtureWinnerJustLikeTheScalaReference() { + List candidates = FixtureLoader.candidates("01-candidates.json"); + List> ballots = FixtureLoader.ballots("03-ballots.json"); + + List> winners = Majority.elect(ballots, candidates, 1); + + assertThat(winners).extracting(winner -> winner.candidate().id()).containsExactly("a"); + } + + @Test + void returnsNoWinnerWhenTheTopTwoCandidatesSplitTheBallotsExactlyInHalf() { + List candidates = List.of(a, b); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a, b)), + PreferentialBallot.of(2, List.of(b, a)) + ); + + List> winners = Majority.elect(ballots, candidates, 1); + + assertThat(winners).isEmpty(); + } + + @Test + void picksTheCandidateWithStrictlyMoreThanHalfTheFirstPreferenceVotes() { + List candidates = List.of(a, b, c); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a, b, c)), + PreferentialBallot.of(2, List.of(a, b, c)), + PreferentialBallot.of(3, List.of(a, c, b)), + PreferentialBallot.of(4, List.of(b, a, c)), + PreferentialBallot.of(5, List.of(c, a, b)) + ); + + List> withDefaultResolver = Majority.elect(ballots, candidates, 1); + List> withExplicitResolver = + Majority.elect(ballots, candidates, 1, TieResolvers.doNothing()); + + assertThat(withDefaultResolver).containsExactly(new Winner<>(a, Rational.whole(3))); + assertThat(withExplicitResolver).isEqualTo(withDefaultResolver); + } +} diff --git a/votee/src/test/java/com/ludovictemgoua/votee/model/PreferentialBallotTest.java b/votee/src/test/java/com/ludovictemgoua/votee/model/PreferentialBallotTest.java new file mode 100644 index 0000000..3db2808 --- /dev/null +++ b/votee/src/test/java/com/ludovictemgoua/votee/model/PreferentialBallotTest.java @@ -0,0 +1,61 @@ +package com.ludovictemgoua.votee.model; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PreferentialBallotTest { + + private final PreferentialCandidate a = new PreferentialCandidate("a", "A"); + private final PreferentialCandidate b = new PreferentialCandidate("b", "B"); + private final PreferentialCandidate c = new PreferentialCandidate("c", "C"); + + @Test + void excludeDropsTheGivenCandidatesButKeepsTheRest() { + PreferentialBallot ballot = PreferentialBallot.of(1, List.of(a, b, c)); + + PreferentialBallot filtered = ballot.exclude(List.of(b)); + + assertThat(filtered.preferences()).containsExactly(a, c); + assertThat(filtered.id()).isEqualTo(ballot.id()); + assertThat(filtered.weight()).isEqualTo(ballot.weight()); + } + + @Test + void excludeIsANoOpWhenNoneOfTheGivenCandidatesAreOnTheBallot() { + PreferentialBallot ballot = PreferentialBallot.of(1, List.of(a, b)); + + PreferentialBallot filtered = ballot.exclude(List.of(c)); + + assertThat(filtered.preferences()).containsExactly(a, b); + } + + @Test + void includeRetainsOnlyTheGivenCandidates() { + PreferentialBallot ballot = PreferentialBallot.of(1, List.of(a, b, c)); + + PreferentialBallot filtered = ballot.include(List.of(a, c)); + + assertThat(filtered.preferences()).containsExactly(a, c); + } + + @Test + void preferencesAreDefensivelyCopiedOnConstruction() { + List mutablePreferences = new ArrayList<>(List.of(a, b)); + + PreferentialBallot ballot = PreferentialBallot.of(1, mutablePreferences); + mutablePreferences.add(c); + + assertThat(ballot.preferences()).containsExactly(a, b); + } + + @Test + void ofDefaultsToAWeightOfOne() { + PreferentialBallot ballot = PreferentialBallot.of(1, List.of(a, b)); + + assertThat(ballot.weight()).isEqualTo(Rational.ONE); + } +} diff --git a/votee/src/test/java/com/ludovictemgoua/votee/model/RationalTest.java b/votee/src/test/java/com/ludovictemgoua/votee/model/RationalTest.java new file mode 100644 index 0000000..af7fea2 --- /dev/null +++ b/votee/src/test/java/com/ludovictemgoua/votee/model/RationalTest.java @@ -0,0 +1,95 @@ +package com.ludovictemgoua.votee.model; + +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RationalTest { + + @Test + void reducesToLowestTermsOnConstruction() { + Rational sixEighths = new Rational(BigInteger.valueOf(6), BigInteger.valueOf(8)); + + assertThat(sixEighths.numerator()).isEqualTo(BigInteger.valueOf(3)); + assertThat(sixEighths.denominator()).isEqualTo(BigInteger.valueOf(4)); + } + + @Test + void normalizesANegativeDenominatorOntoTheNumerator() { + Rational negativeThreeQuarters = new Rational(BigInteger.valueOf(3), BigInteger.valueOf(-4)); + + assertThat(negativeThreeQuarters.numerator()).isEqualTo(BigInteger.valueOf(-3)); + assertThat(negativeThreeQuarters.denominator()).isEqualTo(BigInteger.valueOf(4)); + } + + @Test + void rejectsAZeroDenominator() { + assertThatThrownBy(() -> new Rational(BigInteger.ONE, BigInteger.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void addsAcrossDifferentDenominators() { + Rational oneHalf = Rational.of(1, 2); + Rational oneThird = Rational.of(1, 3); + + assertThat(oneHalf.add(oneThird)).isEqualTo(Rational.of(5, 6)); + } + + @Test + void subtractsAcrossDifferentDenominators() { + Rational oneHalf = Rational.of(1, 2); + Rational oneThird = Rational.of(1, 3); + + assertThat(oneHalf.subtract(oneThird)).isEqualTo(Rational.of(1, 6)); + } + + @Test + void multipliesTwoRationals() { + Rational twoThirds = Rational.of(2, 3); + Rational threeQuarters = Rational.of(3, 4); + + assertThat(twoThirds.multiply(threeQuarters)).isEqualTo(Rational.of(1, 2)); + } + + @Test + void dividesByTheReciprocalOfTheOther() { + Rational oneHalf = Rational.of(1, 2); + Rational oneThird = Rational.of(1, 3); + + assertThat(oneHalf.divide(oneThird)).isEqualTo(Rational.of(3, 2)); + } + + @Test + void rejectsDivisionByZero() { + Rational oneHalf = Rational.of(1, 2); + + assertThatThrownBy(() -> oneHalf.divide(Rational.ZERO)) + .isInstanceOf(ArithmeticException.class); + } + + @Test + void negateFlipsTheSign() { + assertThat(Rational.of(3, 4).negate()).isEqualTo(Rational.of(-3, 4)); + } + + @Test + void absDropsTheSign() { + assertThat(Rational.of(-3, 4).abs()).isEqualTo(Rational.of(3, 4)); + } + + @Test + void comparesByCrossMultiplication() { + assertThat(Rational.of(1, 2).compareTo(Rational.of(1, 3))).isPositive(); + assertThat(Rational.of(1, 3).compareTo(Rational.of(1, 2))).isNegative(); + assertThat(Rational.of(2, 4).compareTo(Rational.of(1, 2))).isZero(); + } + + @Test + void wholeBuildsAnIntegerRational() { + assertThat(Rational.whole(5)).isEqualTo(Rational.of(5, 1)); + } +} diff --git a/votee/src/test/java/com/ludovictemgoua/votee/model/TieResolversTest.java b/votee/src/test/java/com/ludovictemgoua/votee/model/TieResolversTest.java new file mode 100644 index 0000000..8dbb0bd --- /dev/null +++ b/votee/src/test/java/com/ludovictemgoua/votee/model/TieResolversTest.java @@ -0,0 +1,43 @@ +package com.ludovictemgoua.votee.model; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class TieResolversTest { + + private final PreferentialCandidate a = new PreferentialCandidate("a", "A"); + private final PreferentialCandidate b = new PreferentialCandidate("b", "B"); + private final PreferentialCandidate c = new PreferentialCandidate("c", "C"); + + private final List> tied = List.of( + Map.entry(a, Rational.ONE), + Map.entry(b, Rational.ONE), + Map.entry(c, Rational.ONE) + ); + + @Test + void doNothingReturnsTheOriginalOrderUnchanged() { + List> resolved = TieResolvers.doNothing().resolve(tied); + + assertThat(resolved).containsExactlyElementsOf(tied); + } + + @Test + void reverseFlipsTheOrder() { + List> resolved = TieResolvers.reverse().resolve(tied); + + assertThat(resolved).extracting(Map.Entry::getKey).containsExactly(c, b, a); + } + + @Test + void randomKeepsEveryEntryButMayReorderThem() { + List> resolved = TieResolvers.random().resolve(tied); + + assertThat(resolved).hasSameSizeAs(tied); + assertThat(resolved).extracting(Map.Entry::getKey).containsExactlyInAnyOrder(a, b, c); + } +} diff --git a/votee/src/test/java/com/ludovictemgoua/votee/support/FixtureLoader.java b/votee/src/test/java/com/ludovictemgoua/votee/support/FixtureLoader.java new file mode 100644 index 0000000..b5e94a2 --- /dev/null +++ b/votee/src/test/java/com/ludovictemgoua/votee/support/FixtureLoader.java @@ -0,0 +1,77 @@ +package com.ludovictemgoua.votee.support; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.ludovictemgoua.votee.model.PreferentialBallot; +import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.Rational; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.List; + +/** + * Loads the JSON test fixtures ported from votee-scala. Kept in the test tree (not main) so the + * Jackson dependency, and the Rational-from-JSON-number conversion it needs, never leak into the + * library's own runtime classpath. + */ +public final class FixtureLoader { + + private static final ObjectMapper MAPPER = buildMapper(); + + private FixtureLoader() { + } + + public static List candidates(String fileName) { + return read(fileName, new TypeReference>() { + }); + } + + public static List> ballots(String fileName) { + return read(fileName, new TypeReference>>() { + }); + } + + private static T read(String fileName, TypeReference type) { + String path = "/fixtures/" + fileName; + try (InputStream in = FixtureLoader.class.getResourceAsStream(path)) { + if (in == null) { + throw new IllegalArgumentException("Fixture not found on classpath: " + path); + } + return MAPPER.readValue(in, type); + } catch (IOException e) { + throw new UncheckedIOException("Failed to load fixture: " + path, e); + } + } + + private static ObjectMapper buildMapper() { + SimpleModule module = new SimpleModule(); + module.addDeserializer(Rational.class, new RationalDeserializer()); + return new ObjectMapper().registerModule(module); + } + + /** Converts a plain JSON number (the fixtures only use integer ballot weights) into a Rational. */ + private static final class RationalDeserializer extends StdDeserializer { + + private RationalDeserializer() { + super(Rational.class); + } + + @Override + public Rational deserialize(JsonParser parser, DeserializationContext context) throws IOException { + BigDecimal value = parser.getDecimalValue(); + if (value.scale() <= 0) { + return Rational.whole(value.longValueExact()); + } + BigInteger denominator = BigInteger.TEN.pow(value.scale()); + return new Rational(value.unscaledValue(), denominator); + } + } +} diff --git a/votee/src/test/resources/fixtures/01-ballots.json b/votee/src/test/resources/fixtures/01-ballots.json new file mode 100644 index 0000000..11acab9 --- /dev/null +++ b/votee/src/test/resources/fixtures/01-ballots.json @@ -0,0 +1,640 @@ +[ + { + "id": 1, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "d", + "name": "D" + } + ] + }, + { + "id": 2, + "weight": 1, + "preferences": [ + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + } + ] + }, + { + "id": 3, + "weight": 1, + "preferences": [ + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + } + ] + }, + { + "id": 4, + "weight": 1, + "preferences": [ + { + "id": "d", + "name": "D" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "a", + "name": "A" + } + ] + }, + { + "id": 5, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + } + ] + }, + { + "id": 6, + "weight": 1, + "preferences": [ + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + } + ] + }, + { + "id": 7, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + } + ] + }, + { + "id": 9, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "c", + "name": "C" + } + ] + }, + { + "id": 10, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "b", + "name": "B" + } + ] + }, + { + "id": 11, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "c", + "name": "C" + } + ] + }, + { + "id": 12, + "weight": 1, + "preferences": [ + { + "id": "c", + "name": "C" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "b", + "name": "B" + } + ] + }, + { + "id": 13, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "c", + "name": "C" + } + ] + }, + { + "id": 14, + "weight": 1, + "preferences": [ + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "c", + "name": "C" + } + ] + }, + { + "id": 15, + "weight": 1, + "preferences": [ + { + "id": "d", + "name": "D" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "a", + "name": "A" + } + ] + }, + { + "id": 16, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + } + ] + }, + { + "id": 17, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + } + ] + }, + { + "id": 18, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "b", + "name": "B" + } + ] + }, + { + "id": 19, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "d", + "name": "D" + } + ] + }, + { + "id": 20, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "c", + "name": "C" + } + ] + }, + { + "id": 21, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "d", + "name": "D" + } + ] + }, + { + "id": 22, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "d", + "name": "D" + } + ] + }, + { + "id": 23, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + } + ] + }, + { + "id": 24, + "weight": 1, + "preferences": [ + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + } + ] + }, + { + "id": 25, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + } + ] + }, + { + "id": 26, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + } + ] + }, + { + "id": 27, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + } + ] + }, + { + "id": 28, + "weight": 1, + "preferences": [ + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + } + ] + }, + { + "id": 29, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "d", + "name": "D" + } + ] + }, + { + "id": 30, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "d", + "name": "D" + } + ] + } +] \ No newline at end of file diff --git a/votee/src/test/resources/fixtures/01-candidates.json b/votee/src/test/resources/fixtures/01-candidates.json new file mode 100644 index 0000000..c9779de --- /dev/null +++ b/votee/src/test/resources/fixtures/01-candidates.json @@ -0,0 +1,18 @@ +[ + { + "id": "a", + "name": "A" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "d", + "name": "D" + } +] \ No newline at end of file diff --git a/votee/src/test/resources/fixtures/02-ballots.json b/votee/src/test/resources/fixtures/02-ballots.json new file mode 100644 index 0000000..c02bb30 --- /dev/null +++ b/votee/src/test/resources/fixtures/02-ballots.json @@ -0,0 +1,200 @@ +[ + { + "id": 1, + "weight": 1, + "preferences": [ + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + } + ] + }, + { + "id": 2, + "weight": 1, + "preferences": [ + { + "id": "d", + "name": "D" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "b", + "name": "B" + } + ] + }, + { + "id": 3, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + } + ] + }, + { + "id": 4, + "weight": 1, + "preferences": [ + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + } + ] + }, + { + "id": 5, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + } + ] + }, + { + "id": 6, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "c", + "name": "C" + } + ] + }, + { + "id": 7, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "d", + "name": "D" + } + ] + }, + { + "id": 9, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "d", + "name": "D" + } + ] + }, + { + "id": 10, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "c", + "name": "C" + } + ] + } +] \ No newline at end of file diff --git a/votee/src/test/resources/fixtures/03-ballots.json b/votee/src/test/resources/fixtures/03-ballots.json new file mode 100644 index 0000000..1f82d7d --- /dev/null +++ b/votee/src/test/resources/fixtures/03-ballots.json @@ -0,0 +1,200 @@ +[ + { + "id": 1, + "weight": 1, + "preferences": [ + { + "id": "b", + "name": "B" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "d", + "name": "D" + } + ] + }, + { + "id": 2, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "c", + "name": "C" + } + ] + }, + { + "id": 3, + "weight": 1, + "preferences": [ + { + "id": "c", + "name": "C" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "b", + "name": "B" + } + ] + }, + { + "id": 4, + "weight": 1, + "preferences": [ + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + } + ] + }, + { + "id": 5, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "b", + "name": "B" + } + ] + }, + { + "id": 6, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "c", + "name": "C" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "b", + "name": "B" + } + ] + }, + { + "id": 7, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "c", + "name": "C" + } + ] + }, + { + "id": 9, + "weight": 1, + "preferences": [ + { + "id": "a", + "name": "A" + }, + { + "id": "d", + "name": "D" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "c", + "name": "C" + } + ] + }, + { + "id": 10, + "weight": 1, + "preferences": [ + { + "id": "d", + "name": "D" + }, + { + "id": "a", + "name": "A" + }, + { + "id": "b", + "name": "B" + }, + { + "id": "c", + "name": "C" + } + ] + } +] \ No newline at end of file From 9d5ed154c3fb094ef592274c2b5eeb71edde3365 Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sun, 5 Jul 2026 02:48:45 +0200 Subject: [PATCH 05/16] Added approval algorithm --- .../votee/algorithms/Approval.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/algorithms/Approval.java diff --git a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Approval.java b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Approval.java new file mode 100644 index 0000000..fcd5850 --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Approval.java @@ -0,0 +1,37 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class Approval> extends AbstractPreferentialElection { + + public static > List> elect( + List ballots, List candidates, int vacancies, TieResolver tieResolver) { + return new Approval().run(ballots, candidates, vacancies, tieResolver); + } + + public static > List> elect( + List ballots, List candidates, int vacancies) { + return new Approval().run(ballots, candidates, vacancies, TieResolvers.doNothing()); + } + + @Override + public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { + Map scores = new LinkedHashMap<>(); + for (B ballot : ballots) { + for (C candidate : ballot.preferences()) { + scores.merge(candidate, ballot.weight(), Rational::add); + } + } + List> sorted = scores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + return resolveTies(sorted, tieResolver).stream() + .limit(vacancies) + .map(Winner::of) + .toList(); + } +} From 2b53df9b87739d7e1faa5835cfa52a297f7617f2 Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sun, 5 Jul 2026 02:50:26 +0200 Subject: [PATCH 06/16] Added Baldwin algorithm --- .../votee/algorithms/Baldwin.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/algorithms/Baldwin.java diff --git a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Baldwin.java b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Baldwin.java new file mode 100644 index 0000000..37e136b --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Baldwin.java @@ -0,0 +1,39 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public final class Baldwin> extends AbstractPreferentialElection { + + public static > List> elect( + List ballots, List candidates, int vacancies, TieResolver tieResolver) { + return new Baldwin().run(ballots, candidates, vacancies, tieResolver); + } + + public static > List> elect( + List ballots, List candidates, int vacancies) { + return new Baldwin().run(ballots, candidates, vacancies, TieResolvers.doNothing()); + } + + /** + * Repeatedly eliminates the lowest Borda scorer until one candidate remains. Ignores + * {@code vacancies}, matching the reference implementation, which only ever elects one winner. + */ + @Override + public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { + List remaining = new ArrayList<>(candidates); + while (remaining.size() > 1) { + List> ascending = BordaCount.bordaScores(ballots, remaining).entrySet().stream() + .sorted(Map.Entry.comparingByValue()) + .toList(); + C lowestScorer = resolveTies(ascending, tieResolver).get(0).getKey(); + remaining.remove(lowestScorer); + } + return BordaCount.bordaScores(ballots, remaining).entrySet().stream() + .map(Winner::of) + .toList(); + } +} From 5a0f6b4cb600a991bc4da4dfa1b6586ef27502e7 Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sun, 5 Jul 2026 02:52:38 +0200 Subject: [PATCH 07/16] Added Borda Count algorithm --- .../votee/algorithms/BordaCount.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/algorithms/BordaCount.java diff --git a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/BordaCount.java b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/BordaCount.java new file mode 100644 index 0000000..a79e98c --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/BordaCount.java @@ -0,0 +1,47 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class BordaCount> extends AbstractPreferentialElection { + + public static > List> elect( + List ballots, List candidates, int vacancies, TieResolver tieResolver) { + return new BordaCount().run(ballots, candidates, vacancies, tieResolver); + } + + public static > List> elect( + List ballots, List candidates, int vacancies) { + return new BordaCount().run(ballots, candidates, vacancies, TieResolvers.doNothing()); + } + + @Override + public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { + List> sorted = bordaScores(ballots, candidates).entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + return resolveTies(sorted, tieResolver).stream() + .limit(vacancies) + .map(Winner::of) + .toList(); + } + + /** + * Scores each candidate by rank position (candidates.size() - 1 - index) on every ballot, + * weighted by the ballot's weight. Package-private so Baldwin can reuse it per elimination round. + */ + static > Map bordaScores(List ballots, List candidates) { + Map scores = new LinkedHashMap<>(); + for (B ballot : ballots) { + List eligible = ballot.preferences().stream().filter(candidates::contains).toList(); + for (int i = 0; i < eligible.size(); i++) { + Rational points = Rational.whole(candidates.size() - 1L - i).multiply(ballot.weight()); + scores.merge(eligible.get(i), points, Rational::add); + } + } + return scores; + } +} From 649dde111ef68c139b13489f76feb36ab9e18495 Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sun, 5 Jul 2026 02:53:03 +0200 Subject: [PATCH 08/16] Added Contingent Method --- .../votee/algorithms/Contingent.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/algorithms/Contingent.java diff --git a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Contingent.java b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Contingent.java new file mode 100644 index 0000000..0f06ec3 --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Contingent.java @@ -0,0 +1,52 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class Contingent> extends AbstractPreferentialElection { + + public static > List> elect( + List ballots, List candidates, int vacancies, TieResolver tieResolver) { + return new Contingent().run(ballots, candidates, vacancies, tieResolver); + } + + public static > List> elect( + List ballots, List candidates, int vacancies) { + return new Contingent().run(ballots, candidates, vacancies, TieResolvers.doNothing()); + } + + /** Ignores {@code vacancies}, matching the reference implementation, which only ever elects one winner. */ + @Override + public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { + Map scores = new LinkedHashMap<>(countFirstVotes(ballots, candidates)); + List> sorted = resolveTies(descending(scores), tieResolver); + + // Matches the reference implementation exactly: the leader's raw first-preference score is + // compared against the flat 1/2 constant, not against half of ballots.size(). In practice this + // means the runoff branch below is only reached when ballot weights are unusually small. + if (sorted.get(0).getValue().compareTo(MAJORITY_THRESHOLD) > 0) { + return List.of(Winner.of(sorted.get(0))); + } + + List topTwo = sorted.stream().limit(2).map(Map.Entry::getKey).toList(); + for (B ballot : ballots) { + if (!topTwo.contains(ballot.preferences().getFirst())) { + ballot.preferences().stream() + .filter(topTwo::contains) + .findFirst() + .ifPresent(candidate -> scores.merge(candidate, ballot.weight(), Rational::add)); + } + } + + return List.of(Winner.of(resolveTies(descending(scores), tieResolver).get(0))); + } + + private static List> descending(Map scores) { + return scores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + } +} From ea49058157cc88e3e500ce5b9e7480bb57824981 Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sun, 5 Jul 2026 02:59:15 +0200 Subject: [PATCH 09/16] Added Coombs algorithm --- .../votee/algorithms/Coombs.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/algorithms/Coombs.java diff --git a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Coombs.java b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Coombs.java new file mode 100644 index 0000000..a34577e --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Coombs.java @@ -0,0 +1,48 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public final class Coombs> extends AbstractPreferentialElection { + + public static > List> elect( + List ballots, List candidates, int vacancies, TieResolver tieResolver) { + return new Coombs().run(ballots, candidates, vacancies, tieResolver); + } + + public static > List> elect( + List ballots, List candidates, int vacancies) { + return new Coombs().run(ballots, candidates, vacancies, TieResolvers.doNothing()); + } + + /** + * Repeatedly eliminates the candidate ranked last most often, until one candidate holds a + * strict majority of first-preference votes. Ignores {@code vacancies}, matching the reference + * implementation, which only ever elects one winner. Requires ballots to rank every candidate. + */ + @Override + public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { + List remaining = new ArrayList<>(candidates); + Rational threshold = MAJORITY_THRESHOLD.multiply(Rational.whole(ballots.size())); + + while (!remaining.isEmpty()) { + List> overThreshold = countFirstVotes(ballots, remaining).entrySet().stream() + .filter(e -> e.getValue().compareTo(threshold) > 0) + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + if (!overThreshold.isEmpty()) { + return resolveTies(overThreshold, tieResolver).stream().limit(1).map(Winner::of).toList(); + } + + List> mostDislikedFirst = countLastVotes(ballots, remaining).entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + C mostDisliked = resolveTies(mostDislikedFirst, tieResolver).get(0).getKey(); + remaining.remove(mostDisliked); + } + return List.of(); + } +} From e6340cbdeca3221379c53d7390c449297b03e8f4 Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sun, 5 Jul 2026 02:59:49 +0200 Subject: [PATCH 10/16] Added Exhausive Ballot algorithm --- .../votee/algorithms/ExhaustiveBallot.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/algorithms/ExhaustiveBallot.java diff --git a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/ExhaustiveBallot.java b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/ExhaustiveBallot.java new file mode 100644 index 0000000..e017dbc --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/ExhaustiveBallot.java @@ -0,0 +1,51 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public final class ExhaustiveBallot> extends AbstractPreferentialElection { + + public static > List> elect( + List ballots, List candidates, int vacancies, TieResolver tieResolver) { + return new ExhaustiveBallot().run(ballots, candidates, vacancies, tieResolver); + } + + public static > List> elect( + List ballots, List candidates, int vacancies) { + return new ExhaustiveBallot().run(ballots, candidates, vacancies, TieResolvers.doNothing()); + } + + /** + * Repeatedly excludes the lowest first-preference scorer from both the candidate and ballot + * lists, until only two candidates remain, then returns the higher scorer of the two. Ignores + * {@code vacancies}, matching the reference implementation, which only ever elects one winner. + * Unlike the reference implementation (which decides both elimination and the final winner via a + * plain sort, ignoring its own tieResolver parameter entirely), ties here are resolved through the + * given {@code tieResolver} at both decision points. + */ + @Override + public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { + List remainingCandidates = new ArrayList<>(candidates); + List remainingBallots = new ArrayList<>(ballots); + Map scores = countFirstVotes(remainingBallots, remainingCandidates); + + while (scores.size() > 2) { + C loser = resolveTies(ascending(scores), tieResolver).get(0).getKey(); + remainingCandidates.remove(loser); + remainingBallots = remainingBallots.stream().map(ballot -> ballot.exclude(List.of(loser))).toList(); + scores = countFirstVotes(remainingBallots, remainingCandidates); + } + + List> finalScores = resolveTies(ascending(scores), tieResolver); + return List.of(Winner.of(finalScores.get(finalScores.size() - 1))); + } + + private static List> ascending(Map scores) { + return scores.entrySet().stream() + .sorted(Map.Entry.comparingByValue()) + .toList(); + } +} From 8a35cdb2df936611a58ebc9d0f073dbd5d6cb534 Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sun, 5 Jul 2026 03:00:25 +0200 Subject: [PATCH 11/16] Added Super Majority algorithm --- .../votee/algorithms/SuperMajority.java | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/algorithms/SuperMajority.java diff --git a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/SuperMajority.java b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/SuperMajority.java new file mode 100644 index 0000000..af2d331 --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/SuperMajority.java @@ -0,0 +1,46 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.*; + +import java.util.List; +import java.util.Map; + +public final class SuperMajority> extends AbstractPreferentialElection { + + private final Rational majorityPercentage; + + public SuperMajority(Rational majorityPercentage) { + if (majorityPercentage.compareTo(MAJORITY_THRESHOLD) < 0 || majorityPercentage.compareTo(Rational.ONE) > 0) { + throw new IllegalArgumentException("majorityPercentage must be between 1/2 and 1"); + } + this.majorityPercentage = majorityPercentage; + } + + public static > List> elect( + List ballots, List candidates, int vacancies, Rational majorityPercentage, TieResolver tieResolver) { + return new SuperMajority(majorityPercentage).run(ballots, candidates, vacancies, tieResolver); + } + + public static > List> elect( + List ballots, List candidates, int vacancies, Rational majorityPercentage) { + return new SuperMajority(majorityPercentage).run(ballots, candidates, vacancies, TieResolvers.doNothing()); + } + + public static > List> elect( + List ballots, List candidates, int vacancies) { + return new SuperMajority(MAJORITY_THRESHOLD).run(ballots, candidates, vacancies, TieResolvers.doNothing()); + } + + @Override + public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { + Rational threshold = Rational.whole(ballots.size()).multiply(majorityPercentage); + List> sorted = countFirstVotes(ballots, candidates).entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + return resolveTies(sorted, tieResolver).stream() + .filter(e -> e.getValue().compareTo(threshold) > 0) + .limit(vacancies) + .map(Winner::of) + .toList(); + } +} From d5398935aa0622b9722d0caa747e3ed7b5065fba Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sun, 5 Jul 2026 03:00:47 +0200 Subject: [PATCH 12/16] Added Veto algorithm --- .../ludovictemgoua/votee/algorithms/Veto.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/algorithms/Veto.java diff --git a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Veto.java b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Veto.java new file mode 100644 index 0000000..77e7593 --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Veto.java @@ -0,0 +1,43 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class Veto> extends AbstractPreferentialElection { + + public static > List> elect( + List ballots, List candidates, int vacancies, TieResolver tieResolver) { + return new Veto().run(ballots, candidates, vacancies, tieResolver); + } + + public static > List> elect( + List ballots, List candidates, int vacancies) { + return new Veto().run(ballots, candidates, vacancies, TieResolvers.doNothing()); + } + + @Override + public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { + Map scores = new LinkedHashMap<>(); + for (B ballot : ballots) { + List preferences = ballot.preferences(); + for (int i = 0; i < preferences.size(); i++) { + // Every preference scores a point except a ballot's last choice, its veto - unless + // the ballot only lists one candidate, in which case that candidate isn't vetoed. + boolean isVetoed = i == preferences.size() - 1 && preferences.size() > 1; + if (!isVetoed) { + scores.merge(preferences.get(i), Rational.ONE, Rational::add); + } + } + } + List> sorted = scores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + return resolveTies(sorted, tieResolver).stream() + .limit(vacancies) + .map(Winner::of) + .toList(); + } +} From c05d2ff622c38df7352b21e2ac3e68f2e9f2f00c Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sun, 5 Jul 2026 03:13:46 +0200 Subject: [PATCH 13/16] Added tests --- .../votee/algorithms/ApprovalTest.java | 54 +++++++++++++ .../votee/algorithms/BaldwinTest.java | 58 ++++++++++++++ .../votee/algorithms/BordaCountTest.java | 60 +++++++++++++++ .../votee/algorithms/ContingentTest.java | 48 ++++++++++++ .../votee/algorithms/CoombsTest.java | 45 +++++++++++ .../algorithms/ExhaustiveBallotTest.java | 52 +++++++++++++ .../votee/algorithms/SuperMajorityTest.java | 75 ++++++++++++++++++ .../votee/algorithms/VetoTest.java | 77 +++++++++++++++++++ 8 files changed, 469 insertions(+) create mode 100644 votee/src/test/java/com/ludovictemgoua/votee/algorithms/ApprovalTest.java create mode 100644 votee/src/test/java/com/ludovictemgoua/votee/algorithms/BaldwinTest.java create mode 100644 votee/src/test/java/com/ludovictemgoua/votee/algorithms/BordaCountTest.java create mode 100644 votee/src/test/java/com/ludovictemgoua/votee/algorithms/ContingentTest.java create mode 100644 votee/src/test/java/com/ludovictemgoua/votee/algorithms/CoombsTest.java create mode 100644 votee/src/test/java/com/ludovictemgoua/votee/algorithms/ExhaustiveBallotTest.java create mode 100644 votee/src/test/java/com/ludovictemgoua/votee/algorithms/SuperMajorityTest.java create mode 100644 votee/src/test/java/com/ludovictemgoua/votee/algorithms/VetoTest.java diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ApprovalTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ApprovalTest.java new file mode 100644 index 0000000..b9ee54b --- /dev/null +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ApprovalTest.java @@ -0,0 +1,54 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.PreferentialBallot; +import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.Rational; +import com.ludovictemgoua.votee.model.Winner; +import com.ludovictemgoua.votee.support.FixtureLoader; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ApprovalTest { + + private final PreferentialCandidate a = new PreferentialCandidate("a", "A"); + private final PreferentialCandidate b = new PreferentialCandidate("b", "B"); + private final PreferentialCandidate c = new PreferentialCandidate("c", "C"); + + /** + * Every ballot in this fixture ranks all 4 candidates in full, so approval degenerates into a + * perfect 4-way tie (each candidate approved on all 9 ballots). votee-scala's own ApprovalSpec + * asserts a single winner ("d") out of that tie, but which candidate comes first is an artifact + * of each language's internal map iteration order, not of the algorithm - Scala's mutable.HashMap + * bucket order versus Java's LinkedHashMap insertion order have no reason to agree. Asserting the + * (correct, tied) score is the meaningful check here; asserting one specific winner would just be + * pinning down an implementation detail. See the LLD's "Determinism and Tie-Break Ordering" section. + */ + @Test + void everyCandidateEndsUpTiedWhenAllBallotsRankAllCandidates() { + List candidates = FixtureLoader.candidates("01-candidates.json"); + List> ballots = FixtureLoader.ballots("03-ballots.json"); + + List> winners = Approval.elect(ballots, candidates, candidates.size()); + + assertThat(winners).extracting(Winner::score).containsOnly(Rational.whole(9)); + assertThat(winners).extracting(winner -> winner.candidate().id()) + .containsExactlyInAnyOrder("a", "b", "c", "d"); + } + + @Test + void everyListedPreferenceCountsAsAFullApprovalVote() { + List candidates = List.of(a, b, c); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a, b)), + PreferentialBallot.of(2, List.of(a)), + PreferentialBallot.of(3, List.of(a, c)) + ); + + List> winners = Approval.elect(ballots, candidates, 1); + + assertThat(winners).containsExactly(new Winner<>(a, Rational.whole(3))); + } +} diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/BaldwinTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/BaldwinTest.java new file mode 100644 index 0000000..7ce1d73 --- /dev/null +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/BaldwinTest.java @@ -0,0 +1,58 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.PreferentialBallot; +import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.Rational; +import com.ludovictemgoua.votee.model.Winner; +import com.ludovictemgoua.votee.support.FixtureLoader; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class BaldwinTest { + + private final PreferentialCandidate a = new PreferentialCandidate("a", "A"); + private final PreferentialCandidate b = new PreferentialCandidate("b", "B"); + private final PreferentialCandidate c = new PreferentialCandidate("c", "C"); + + @Test + void matchesTheScalaReferenceOnTheFixtureData() { + List candidates = FixtureLoader.candidates("01-candidates.json"); + List> ballots = FixtureLoader.ballots("03-ballots.json"); + + List> winners = Baldwin.elect(ballots, candidates, 1); + + assertThat(winners).extracting(winner -> winner.candidate().id()).containsExactly("a"); + } + + @Test + void aSingleRemainingCandidateWinsWithoutAnEliminationRound() { + List candidates = List.of(a); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a)) + ); + + List> winners = Baldwin.elect(ballots, candidates, 1); + + assertThat(winners).containsExactly(new Winner<>(a, Rational.ZERO)); + } + + @Test + void eliminatesTheLowestBordaScorerEachRoundUntilOneCandidateRemains() { + List candidates = List.of(a, b, c); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a, b, c)), + PreferentialBallot.of(2, List.of(a, b, c)), + PreferentialBallot.of(3, List.of(b, c, a)), + PreferentialBallot.of(4, List.of(c, a, b)) + ); + + // Round 1 Borda scores (a=5, b=4, c=3) eliminate c. + // Round 2 Borda scores among [a, b] (a=3, b=1) eliminate b, leaving a. + List> winners = Baldwin.elect(ballots, candidates, 1); + + assertThat(winners).containsExactly(new Winner<>(a, Rational.ZERO)); + } +} diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/BordaCountTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/BordaCountTest.java new file mode 100644 index 0000000..f112868 --- /dev/null +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/BordaCountTest.java @@ -0,0 +1,60 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.PreferentialBallot; +import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.Rational; +import com.ludovictemgoua.votee.model.Winner; +import com.ludovictemgoua.votee.support.FixtureLoader; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class BordaCountTest { + + private final PreferentialCandidate a = new PreferentialCandidate("a", "A"); + private final PreferentialCandidate b = new PreferentialCandidate("b", "B"); + private final PreferentialCandidate c = new PreferentialCandidate("c", "C"); + + @Test + void matchesTheScalaReferenceOnTheFixtureData() { + List candidates = FixtureLoader.candidates("01-candidates.json"); + List> ballots = FixtureLoader.ballots("03-ballots.json"); + + List> winners = BordaCount.elect(ballots, candidates, 1); + + assertThat(winners).extracting(winner -> winner.candidate().id()).containsExactly("a"); + } + + @Test + void scoresByRankPositionAmongAllCandidates() { + List candidates = List.of(a, b, c); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a, b, c)) + ); + + List> winners = BordaCount.elect(ballots, candidates, 3); + + assertThat(winners).containsExactly( + new Winner<>(a, Rational.whole(2)), + new Winner<>(b, Rational.whole(1)), + new Winner<>(c, Rational.whole(0)) + ); + } + + @Test + void ranksOnlyWithinTheGivenCandidateListEvenWhenABallotPrefersAnExcludedCandidate() { + List eligibleCandidates = List.of(a, c); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a, b, c)) + ); + + List> winners = BordaCount.elect(ballots, eligibleCandidates, 2); + + assertThat(winners).containsExactly( + new Winner<>(a, Rational.whole(1)), + new Winner<>(c, Rational.whole(0)) + ); + } +} diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ContingentTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ContingentTest.java new file mode 100644 index 0000000..bfc3275 --- /dev/null +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ContingentTest.java @@ -0,0 +1,48 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.PreferentialBallot; +import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.Rational; +import com.ludovictemgoua.votee.model.Winner; +import com.ludovictemgoua.votee.support.FixtureLoader; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Only the immediate-majority path is covered here. The runoff/redistribution branch is gated by + * comparing the leader's raw score to a flat 1/2 (see Contingent's own inline comment) rather than + * to half of ballots.size(), which is under separate review - see the PDD/LLD discussion - so it is + * deliberately left untested until that comparison is settled. + */ +class ContingentTest { + + private final PreferentialCandidate a = new PreferentialCandidate("a", "A"); + private final PreferentialCandidate b = new PreferentialCandidate("b", "B"); + + @Test + void matchesTheScalaReferenceOnTheFixtureData() { + List candidates = FixtureLoader.candidates("01-candidates.json"); + List> ballots = FixtureLoader.ballots("03-ballots.json"); + + List> winners = Contingent.elect(ballots, candidates, 1); + + assertThat(winners).extracting(winner -> winner.candidate().id()).containsExactly("a"); + } + + @Test + void picksTheImmediateFirstPreferenceLeaderWhenItsScoreExceedsOneHalf() { + List candidates = List.of(a, b); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a, b)), + PreferentialBallot.of(2, List.of(a, b)), + PreferentialBallot.of(3, List.of(b, a)) + ); + + List> winners = Contingent.elect(ballots, candidates, 1); + + assertThat(winners).containsExactly(new Winner<>(a, Rational.whole(2))); + } +} diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/CoombsTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/CoombsTest.java new file mode 100644 index 0000000..6a8d826 --- /dev/null +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/CoombsTest.java @@ -0,0 +1,45 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.PreferentialBallot; +import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.Rational; +import com.ludovictemgoua.votee.model.Winner; +import com.ludovictemgoua.votee.support.FixtureLoader; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CoombsTest { + + private final PreferentialCandidate a = new PreferentialCandidate("a", "A"); + private final PreferentialCandidate b = new PreferentialCandidate("b", "B"); + private final PreferentialCandidate c = new PreferentialCandidate("c", "C"); + + @Test + void matchesTheScalaReferenceOnTheFixtureData() { + List candidates = FixtureLoader.candidates("01-candidates.json"); + List> ballots = FixtureLoader.ballots("03-ballots.json"); + + List> winners = Coombs.elect(ballots, candidates, 1); + + assertThat(winners).extracting(winner -> winner.candidate().id()).containsExactly("a"); + } + + @Test + void eliminatesTheMostLastRankedCandidateUntilAMajorityEmerges() { + List candidates = List.of(a, b, c); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a, b, c)), + PreferentialBallot.of(2, List.of(b, c, a)), + PreferentialBallot.of(3, List.of(c, a, b)) + ); + + // Round 1 first-preferences (a=1, b=1, c=1) have no majority; c is ranked last twice (most + // disliked) and is eliminated. Round 2 among [a, b]: a=2, b=1 - a clears the 1.5 majority. + List> winners = Coombs.elect(ballots, candidates, 1); + + assertThat(winners).containsExactly(new Winner<>(a, Rational.whole(2))); + } +} diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ExhaustiveBallotTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ExhaustiveBallotTest.java new file mode 100644 index 0000000..035b75a --- /dev/null +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ExhaustiveBallotTest.java @@ -0,0 +1,52 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.PreferentialBallot; +import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.Rational; +import com.ludovictemgoua.votee.model.TieResolvers; +import com.ludovictemgoua.votee.model.Winner; +import com.ludovictemgoua.votee.support.FixtureLoader; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExhaustiveBallotTest { + + private final PreferentialCandidate a = new PreferentialCandidate("a", "A"); + private final PreferentialCandidate b = new PreferentialCandidate("b", "B"); + private final PreferentialCandidate c = new PreferentialCandidate("c", "C"); + + @Test + void matchesTheScalaReferenceOnTheFixtureData() { + List candidates = FixtureLoader.candidates("01-candidates.json"); + List> ballots = FixtureLoader.ballots("01-ballots.json"); + + List> winners = ExhaustiveBallot.elect(ballots, candidates, 1); + + assertThat(winners).extracting(winner -> winner.candidate().id()).containsExactly("b"); + } + + @Test + void whichTiedCandidateIsEliminatedFirstIsDecidedByTheGivenTieResolver() { + List candidates = List.of(a, b, c); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a, b, c)), + PreferentialBallot.of(2, List.of(b, c, a)), + PreferentialBallot.of(3, List.of(c, a, b)) + ); + + // Round 1 first-preferences are a three-way tie (a=1, b=1, c=1). doNothing keeps insertion + // order (a, b, c) and eliminates a first, leaving b (2 votes) ahead of c (1) after ballot 3's + // preference shifts. reverse flips the tied group and eliminates c first instead, leaving a + // (2 votes) ahead of b (1) - a different final winner from the same tied starting scores. + List> withDoNothing = + ExhaustiveBallot.elect(ballots, candidates, 1, TieResolvers.doNothing()); + List> withReverse = + ExhaustiveBallot.elect(ballots, candidates, 1, TieResolvers.reverse()); + + assertThat(withDoNothing).containsExactly(new Winner<>(b, Rational.whole(2))); + assertThat(withReverse).containsExactly(new Winner<>(a, Rational.whole(2))); + } +} diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/SuperMajorityTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/SuperMajorityTest.java new file mode 100644 index 0000000..630929d --- /dev/null +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/SuperMajorityTest.java @@ -0,0 +1,75 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.PreferentialBallot; +import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.Rational; +import com.ludovictemgoua.votee.model.Winner; +import com.ludovictemgoua.votee.support.FixtureLoader; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SuperMajorityTest { + + private final PreferentialCandidate a = new PreferentialCandidate("a", "A"); + private final PreferentialCandidate b = new PreferentialCandidate("b", "B"); + + @Test + void matchesTheScalaReferenceWhenNoCandidateClearsA60PercentThreshold() { + List candidates = FixtureLoader.candidates("01-candidates.json"); + List> ballots = FixtureLoader.ballots("03-ballots.json"); + + List> winners = + SuperMajority.elect(ballots, candidates, 1, Rational.of(6, 10)); + + assertThat(winners).isEmpty(); + } + + @Test + void returnsNoWinnerWhenTheLeaderIsExactlyAtTheThreshold() { + List candidates = List.of(a, b); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a, b)), + PreferentialBallot.of(2, List.of(a, b)), + PreferentialBallot.of(3, List.of(a, b)), + PreferentialBallot.of(4, List.of(b, a)) + ); + + List> winners = + SuperMajority.elect(ballots, candidates, 1, Rational.of(3, 4)); + + assertThat(winners).isEmpty(); + } + + @Test + void picksTheCandidateThatClearsTheGivenThreshold() { + List candidates = List.of(a, b); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a, b)), + PreferentialBallot.of(2, List.of(a, b)), + PreferentialBallot.of(3, List.of(a, b)), + PreferentialBallot.of(4, List.of(a, b)), + PreferentialBallot.of(5, List.of(b, a)) + ); + + List> winners = + SuperMajority.elect(ballots, candidates, 1, Rational.of(3, 4)); + + assertThat(winners).containsExactly(new Winner<>(a, Rational.whole(4))); + } + + @Test + void rejectsAThresholdBelowOneHalf() { + assertThatThrownBy(() -> new SuperMajority>(Rational.of(1, 3))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsAThresholdAboveOne() { + assertThatThrownBy(() -> new SuperMajority>(Rational.of(3, 2))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/VetoTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/VetoTest.java new file mode 100644 index 0000000..d858869 --- /dev/null +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/VetoTest.java @@ -0,0 +1,77 @@ +package com.ludovictemgoua.votee.algorithms; + +import com.ludovictemgoua.votee.model.PreferentialBallot; +import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.Rational; +import com.ludovictemgoua.votee.model.Winner; +import com.ludovictemgoua.votee.support.FixtureLoader; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class VetoTest { + + private final PreferentialCandidate a = new PreferentialCandidate("a", "A"); + private final PreferentialCandidate b = new PreferentialCandidate("b", "B"); + private final PreferentialCandidate c = new PreferentialCandidate("c", "C"); + + /** + * Hand-tallying this fixture's last-place counts (d:1, a:1, b:3, c:4 out of 9 ballots) gives Veto + * scores a=8, b=6, c=5, d=8 - a and d are genuinely tied for first. votee-scala's own VetoSpec + * asserts a single winner ("d"), but which of the tied pair comes first is an artifact of each + * language's internal map iteration order (Scala's mutable.HashMap bucket order vs. Java's + * LinkedHashMap insertion order), not something the algorithm itself determines. See the LLD's + * "Determinism and Tie-Break Ordering" section. + */ + @Test + void matchesTheScalaReferenceScoreOnTheFixtureDataEvenThoughTheTiedWinnerDiffers() { + List candidates = FixtureLoader.candidates("01-candidates.json"); + List> ballots = FixtureLoader.ballots("03-ballots.json"); + + List> winners = Veto.elect(ballots, candidates, 1); + + assertThat(winners).hasSize(1); + assertThat(winners.get(0).score()).isEqualTo(Rational.whole(8)); + assertThat(winners.get(0).candidate().id()).isIn("a", "d"); + } + + @Test + void everyPreferenceExceptTheLastOneScoresAPoint() { + List candidates = List.of(a, b, c); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a, b, c)), + PreferentialBallot.of(2, List.of(a, b, c)), + PreferentialBallot.of(3, List.of(c, b, a)) + ); + + List> winners = Veto.elect(ballots, candidates, 1); + + assertThat(winners).containsExactly(new Winner<>(b, Rational.whole(3))); + } + + @Test + void aSinglePreferenceBallotDoesNotVetoItsOnlyCandidate() { + List candidates = List.of(a); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a)) + ); + + List> winners = Veto.elect(ballots, candidates, 1); + + assertThat(winners).containsExactly(new Winner<>(a, Rational.ONE)); + } + + @Test + void vetoScoresAreFlatPointsNotWeightedByBallotWeight() { + List candidates = List.of(a, b); + List> ballots = List.of( + new PreferentialBallot<>(1, Rational.whole(10), List.of(a, b)) + ); + + List> winners = Veto.elect(ballots, candidates, 1); + + assertThat(winners).containsExactly(new Winner<>(a, Rational.ONE)); + } +} From 555c26386327be237b03bf937fff3c0e8187352b Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sun, 5 Jul 2026 03:20:04 +0200 Subject: [PATCH 14/16] Added github ci file for votee --- .github/workflows/votee-ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/votee-ci.yml diff --git a/.github/workflows/votee-ci.yml b/.github/workflows/votee-ci.yml new file mode 100644 index 0000000..7546f86 --- /dev/null +++ b/.github/workflows/votee-ci.yml @@ -0,0 +1,27 @@ +name: votee CI + +on: + push: + paths: + - 'votee/**' + - '.github/workflows/votee-ci.yml' + pull_request: + paths: + - 'votee/**' + - '.github/workflows/votee-ci.yml' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + + - name: Run tests + working-directory: votee + run: mvn -B test From 3c8b72fcf7723da77477c7a38a4b61f9fbcdf52b Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sun, 5 Jul 2026 12:06:53 +0200 Subject: [PATCH 15/16] Added License and updated readme for votee --- votee/LICENSE | 201 +++++++++++++++++++++++++++++++++++++++++++++++ votee/README.md | 202 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 372 insertions(+), 31 deletions(-) create mode 100644 votee/LICENSE diff --git a/votee/LICENSE b/votee/LICENSE new file mode 100644 index 0000000..ed2aee4 --- /dev/null +++ b/votee/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [2026] [Ludovic Temgou Abanda N.] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/votee/README.md b/votee/README.md index 1a1a8e9..6a43cb7 100644 --- a/votee/README.md +++ b/votee/README.md @@ -2,45 +2,97 @@ A Java library of pluggable vote-counting algorithms for elections - a Java port of [votee-scala](../votee-scala), an existing Scala 3 library of mine implementing the same domain. +[![votee CI](https://github.com/icemc/java-backend-playground/actions/workflows/votee-ci.yml/badge.svg)](https://github.com/icemc/java-backend-playground/actions/workflows/votee-ci.yml) ![Java](https://img.shields.io/badge/Java-21-orange) ![Build](https://img.shields.io/badge/build-Maven-blue) -![Status](https://img.shields.io/badge/status-in--development-yellow) +![Version](https://img.shields.io/badge/version-0.1.0--SNAPSHOT-lightgrey) +![License](https://img.shields.io/badge/license-Apache--2.0-green) + +## Table of contents + +- [What this is](#what-this-is) +- [Algorithms](#algorithms) +- [Requirements](#requirements) +- [Installation](#installation) +- [Quick start](#quick-start) +- [Core concepts](#core-concepts) +- [Extending the library](#extending-the-library) +- [Known deviations from the Scala reference](#known-deviations-from-the-scala-reference) +- [Building from source](#building-from-source) +- [Testing](#testing) +- [Continuous integration](#continuous-integration) +- [Versioning and publishing](#versioning-and-publishing) +- [Design documents](#design-documents) +- [Reference implementation](#reference-implementation) +- [License](#license) ## What this is -Given a set of candidates and ballots, `votee` runs a chosen election algorithm (Majority, Approval, Borda Count, and so on) and returns the winner(s). Vote weights and scores are tracked as exact rationals rather than floating-point numbers, so tallies never drift due to rounding. Consumers can use the built-in `PreferentialCandidate`/`PreferentialBallot` types, or implement the `Candidate`/`Ballot` contracts themselves. +Given a set of candidates and ballots, `votee` runs a chosen election algorithm and returns the winner(s). Vote weights and scores are tracked as exact rationals rather than floating-point numbers, so tallies never drift due to rounding. Consumers can use the built-in `PreferentialCandidate`/`PreferentialBallot` types, or implement the `Candidate`/`Ballot` contracts themselves. -The full rationale behind every design decision in this port (why Java's generics need a different shape than Scala's, why `Rational` is hand-written instead of a dependency, why algorithms are iterative instead of recursive, and so on) is written up in: +All nine vote-counting algorithms implemented by the Scala reference are implemented here too, each checked for parity against the same JSON test fixtures the Scala test suite uses. -- [`docs/product-design.md`](docs/product-design.md) - what is being built and why -- [`docs/low-level-design.md`](docs/low-level-design.md) - concrete class shapes, per-algorithm design, test plan, and build/publishing configuration +## Algorithms -## Status +| Algorithm | Entry point | Vacancies honored | Notes | +|---|---|---|---| +| [Majority](https://en.wikipedia.org/wiki/Majority_rule) | `Majority.elect(...)` | Yes | Winner needs strictly more than half the first-preference votes | +| [Super Majority](https://en.wikipedia.org/wiki/Supermajority) | `SuperMajority.elect(...)` | Yes | Like Majority, against a configurable threshold in `[1/2, 1]` | +| [Approval](https://en.wikipedia.org/wiki/Approval_voting) | `Approval.elect(...)` | Yes | Every listed preference on a ballot counts as a full vote | +| Veto | `Veto.elect(...)` | Yes | Every preference on a ballot scores a point except the voter's last choice | +| [Borda Count](https://en.wikipedia.org/wiki/Borda_count) | `BordaCount.elect(...)` | Yes | Candidates score points by rank position, weighted by ballot weight | +| [Baldwin](https://en.wikipedia.org/wiki/Nanson%27s_method#Baldwin_method) | `Baldwin.elect(...)` | No - always 1 winner | Repeated Borda-Count elimination of the lowest scorer each round | +| [Contingent Vote](https://en.wikipedia.org/wiki/Contingent_vote) | `Contingent.elect(...)` | No - always 1 winner | Top-two runoff with redistributed ballots | +| [Coombs' Method](https://en.wikipedia.org/wiki/Coombs%27_method) | `Coombs.elect(...)` | No - always 1 winner | Repeated elimination of the most-last-ranked candidate | +| [Exhaustive Ballot](https://en.wikipedia.org/wiki/Exhaustive_ballot) | `ExhaustiveBallot.elect(...)` | No - always 1 winner | Repeated elimination of the lowest first-preference scorer | -Domain model (`Candidate`, `Ballot`, `Election`, `TieResolver`, `Winner`, `Rational`) is implemented. Of the nine algorithms in the reference implementation: +Every `elect(...)` method has an overload taking an explicit `TieResolver` and one that defaults to `TieResolvers.doNothing()`; see [Core concepts](#core-concepts). -- [x] Majority -- [ ] Super Majority -- [ ] Approval -- [ ] Veto -- [ ] Borda Count -- [ ] Baldwin -- [ ] Contingent Vote -- [ ] Coombs' Method -- [ ] Exhaustive Ballot +## Requirements -This list tracks the same nine algorithms `votee-scala` implements; see that project's own README for the longer list of voting methods neither library has implemented yet. +- JDK 21 or later +- Maven 3.9+ -## Getting started +## Installation -Requires JDK 21+ and Maven. +`votee` is published to a private GitHub Packages Maven registry (see [Versioning and publishing](#versioning-and-publishing)). To consume it from another Maven project: -``` -mvn test # run the test suite -mvn package # build the jar -``` +1. Add the repository to your `pom.xml`: + + ```xml + + + github + https://maven.pkg.github.com/icemc/votee + + + ``` + +2. Add the dependency: -## Usage + ```xml + + com.ludovictemgoua + votee + 0.1.0-SNAPSHOT + + ``` + +3. GitHub Packages requires authentication even for reads. Create a personal access token with `read:packages` scope, then add a matching server entry to your `~/.m2/settings.xml`: + + ```xml + + + github + YOUR_GITHUB_USERNAME + ${env.GITHUB_TOKEN} + + + ``` + + and set the `GITHUB_TOKEN` environment variable before running Maven. + +## Quick start ```java List candidates = List.of( @@ -58,20 +110,108 @@ List> ballots = List.of( List> winners = Majority.elect(ballots, candidates, 1); ``` -`Majority.elect(...)` has an overload accepting an explicit `TieResolver` (see `TieResolvers` for the built-in `doNothing`/`random`/`reverse` strategies) for callers who need to control how tied scores are broken; the two-argument overload above defaults to `TieResolvers.doNothing()`. +Any of the nine algorithm classes in the table above can be substituted for `Majority` with the same call shape. To control how tied scores are broken, pass a `TieResolver` explicitly: + +```java +List> winners = + BordaCount.elect(ballots, candidates, 1, TieResolvers.reverse()); +``` + +## Core concepts + +| Type | Role | +|---|---| +| `Candidate` | Contract for anything that can appear on a ballot (just an `id()`). | +| `PreferentialCandidate` | Built-in `Candidate`: `id`, `name`, and an optional `party` (a plain nullable field, not `Optional`, following standard Java field guidance). | +| `Ballot` | Contract for a voter's submitted preferences: an `id`, a `weight`, an ordered `preferences()` list, and `exclude`/`include` for filtering candidates. The `SELF` type parameter is a Curiously Recurring Generic Pattern so `exclude`/`include` return the concrete ballot type, not the interface. | +| `PreferentialBallot` | Built-in `Ballot`. Defensively copies its `preferences` list on construction, so it's genuinely immutable even if the caller mutates the list they passed in. | +| `Rational` | Exact fraction type (`BigInteger` numerator/denominator, reduced to lowest terms on construction). Used for every vote weight and score so tallies never drift the way floating-point sums can. | +| `TieResolver` | Strategy for ordering candidates tied on score. Built-in strategies live in `TieResolvers`: `doNothing()` (deterministic, order-preserving), `random()` (shuffle), `reverse()`. | +| `Election` / `AbstractPreferentialElection` | The algorithm contract, and a base class providing shared helpers (`countFirstVotes`, `countLastVotes`, `resolveTies`) that every algorithm builds on. | +| `Winner` | A candidate paired with their final score. | + +## Extending the library + +Bring your own candidate or ballot type by implementing the contracts directly, instead of subclassing the built-in defaults: + +```java +public record Voter(String id, String district) implements Candidate {} + +public record RankedBallot(int id, Rational weight, List preferences) + implements Ballot { + + @Override + public RankedBallot exclude(Collection voters) { /* ... */ } + + @Override + public RankedBallot include(Collection voters) { /* ... */ } +} +``` + +Every algorithm is generic over `>`, so `Majority.elect(ballots, voters, seats)` works without any change to the algorithm classes themselves. + +## Known deviations from the Scala reference + +Ported behavior generally matches `votee-scala` exactly, but a few places deliberately diverge. Each is called out with a code comment at its call site too: + +- **Ballot generics** use the Curiously Recurring Generic Pattern instead of Scala's higher-kinded self-type, since Java can't express `Ballot[+C <: Candidate, +T[+CC >: C <: Candidate] <: Ballot[CC, T]]` directly. +- **`Contingent`'s majority check compares the leader's raw score to a flat `1/2`**, not to half of `ballots.size()` (unlike `Majority`/`Coombs`, which do scale it) - this matches the Scala reference exactly, but means the runoff/redistribution branch is effectively unreachable with normal integer ballot weights. Under active review; not yet changed in either implementation. +- **`ExhaustiveBallot` resolves ties via the given `TieResolver`** at both the elimination and final-winner steps. The Scala reference accepts a `tieResolver` parameter but never actually uses it, deciding both steps by incidental sort order instead. This port intentionally does **not** replicate that specific behavior. +- **`Approval`/`Veto` on the ported test fixtures can legitimately tie** (every fixture ballot ranks all candidates, so `Approval` degenerates into a full N-way tie). Which candidate a tie resolves to first differs between Scala's hash-bucket map iteration and Java's insertion-ordered one; the test suite asserts scores, not winner identity, in that case. See the LLD's "Determinism and Tie-Break Ordering" section. +- **`Baldwin`, `Contingent`, `Coombs`, and `ExhaustiveBallot` ignore the `vacancies` parameter**, matching the Scala reference - each of these is structurally a single-winner algorithm. +- Score accumulators use `LinkedHashMap` (ballot-processing order) rather than relying on hash-bucket order, for reproducible results across runs. + +## Building from source + +``` +mvn compile # compile main sources +mvn test # run the test suite +mvn package # build the jar (main + sources) +``` + +Project layout: + +``` +votee/ + pom.xml + LICENSE + docs/ PDD and LLD + src/main/java/.../model/ domain contracts and value types + src/main/java/.../algorithms/ one class per voting algorithm + src/test/java/.../model/ unit tests for the domain types + src/test/java/.../algorithms/ one test class per algorithm + src/test/java/.../support/ FixtureLoader (test-scope-only JSON loading) + src/test/resources/fixtures/ JSON fixtures ported verbatim from votee-scala +``` ## Testing -Tests live under `src/test/java`, split into: +46 tests across two areas: + +- `model/` - unit tests for the domain types: `Rational` arithmetic and reduction, `PreferentialBallot`'s `exclude`/`include`/immutability, the three `TieResolvers` strategies. +- `algorithms/` - one test class per algorithm. Each has a fixture-driven case verified against the same JSON test data and expected winner as the corresponding `votee-scala` spec, plus hand-verified inline edge cases (majority/supermajority threshold boundaries, multi-round elimination, tie-resolver-sensitive outcomes, and so on). + +`support/FixtureLoader` loads the JSON fixtures ported verbatim from `votee-scala/src/main/resources` into `src/test/resources/fixtures`, converting the plain JSON number for `weight` into a `Rational` via a small Jackson module. Jackson is a test-scope-only dependency - it never appears on the library's runtime classpath. + +## Continuous integration + +[`votee-ci.yml`](../.github/workflows/votee-ci.yml) runs `mvn test` on every push and pull request that touches this module (path-filtered to `votee/**`, so unrelated changes elsewhere in the monorepo don't trigger it). + +## Versioning and publishing -- `model/` - unit tests for the domain types (`Rational` arithmetic and reduction, `PreferentialBallot`'s `exclude`/`include`/immutability, the three `TieResolvers`) -- `algorithms/` - one test class per algorithm. `MajorityTest` covers a fixture-driven case (verified against the same JSON test data and expected winner as `votee-scala`'s own `MajoritySpec`) plus inline edge cases (an exact-half tie produces no winner; a clear majority wins) -- `support/FixtureLoader` - loads the JSON fixtures ported verbatim from `votee-scala/src/main/resources` into `src/test/resources/fixtures`, converting the plain JSON number for `weight` into a `Rational`. Kept test-scope-only (Jackson is a test dependency, not a runtime one) so the library itself stays dependency-free. +Coordinates: `com.ludovictemgoua:votee`, currently at `0.1.0-SNAPSHOT`. Versioning follows Early SemVer (SemVer 2.0.0's own "major version zero" clause): breaking changes bump the minor version, backward-compatible changes bump the patch version, until the API is declared stable at `1.0.0`. -## Publishing +Target registry is a private GitHub Packages Maven repository; the `pom.xml` `distributionManagement` block is already pointed at it. Maven Central remains a possible future upgrade, since the `com.ludovictemgoua` groupId already satisfies Central's domain-ownership requirement. -Coordinates: `com.ludovictemgoua:votee`, currently at `0.1.0-SNAPSHOT` (Early SemVer - see the PDD). Target registry is a private GitHub Packages Maven repository; the `pom.xml` `distributionManagement` block is already pointed at it. Maven Central remains a possible future upgrade, since the `com.ludovictemgoua` groupId already satisfies Central's domain-ownership requirement. +## Design documents + +- [`docs/product-design.md`](docs/product-design.md) - what is being built and why +- [`docs/low-level-design.md`](docs/low-level-design.md) - concrete class shapes, per-algorithm design, test plan, and build/publishing configuration ## Reference implementation [`votee-scala`](../votee-scala) (`io.hiis.votee`) is the original Scala 3 library this port is based on, and is what every fixture-based test in this module is checked against for parity. + +## License + +Apache License 2.0 - see [`LICENSE`](LICENSE), matching the reference implementation's license. From b1d99134b0fb397a2abc1ee46ca9d9c6683dd66c Mon Sep 17 00:00:00 2001 From: Ludovic Temgoua Abanda Date: Sun, 5 Jul 2026 12:45:47 +0200 Subject: [PATCH 16/16] Completed all review items and made Winner an interface --- votee/README.md | 10 +++++- votee/docs/low-level-design.md | 16 +++++++-- .../votee/algorithms/Approval.java | 6 ++-- .../votee/algorithms/Contingent.java | 11 ++++-- .../ludovictemgoua/votee/algorithms/Veto.java | 12 +++---- .../ludovictemgoua/votee/model/Election.java | 2 +- .../votee/model/PreferentialWinner.java | 5 +++ .../votee/model/TieResolvers.java | 1 - .../ludovictemgoua/votee/model/Winner.java | 18 +++++++--- .../votee/algorithms/ApprovalTest.java | 19 ++++++++++- .../votee/algorithms/BaldwinTest.java | 5 +-- .../votee/algorithms/BordaCountTest.java | 11 +++--- .../votee/algorithms/ContingentTest.java | 34 ++++++++++++++++++- .../votee/algorithms/CoombsTest.java | 3 +- .../algorithms/ExhaustiveBallotTest.java | 5 +-- .../votee/algorithms/MajorityTest.java | 3 +- .../votee/algorithms/SuperMajorityTest.java | 3 +- .../votee/algorithms/VetoTest.java | 19 +++++++++-- 18 files changed, 145 insertions(+), 38 deletions(-) create mode 100644 votee/src/main/java/com/ludovictemgoua/votee/model/PreferentialWinner.java diff --git a/votee/README.md b/votee/README.md index 6a43cb7..0eec6f4 100644 --- a/votee/README.md +++ b/votee/README.md @@ -128,7 +128,7 @@ List> winners = | `Rational` | Exact fraction type (`BigInteger` numerator/denominator, reduced to lowest terms on construction). Used for every vote weight and score so tallies never drift the way floating-point sums can. | | `TieResolver` | Strategy for ordering candidates tied on score. Built-in strategies live in `TieResolvers`: `doNothing()` (deterministic, order-preserving), `random()` (shuffle), `reverse()`. | | `Election` / `AbstractPreferentialElection` | The algorithm contract, and a base class providing shared helpers (`countFirstVotes`, `countLastVotes`, `resolveTies`) that every algorithm builds on. | -| `Winner` | A candidate paired with their final score. | +| `Winner` | Contract for a candidate paired with their final score. Built-in default is `PreferentialWinner`; implement `Winner` directly for a richer result type (rank, margin, district, and so on). | ## Extending the library @@ -150,6 +150,12 @@ public record RankedBallot(int id, Rational weight, List preferences) Every algorithm is generic over `>`, so `Majority.elect(ballots, voters, seats)` works without any change to the algorithm classes themselves. +`Winner` is a contract for the same reason: the built-in algorithms always hand back `PreferentialWinner` instances (a plain candidate-and-score pair), but nothing forces a caller to work with that shape downstream. Wrap or adapt the result into your own richer type - carrying a rank, a margin, a district, or whatever else your domain needs - by implementing `Winner` directly instead of being limited to the two fields the default record has: + +```java +public record RankedWinner(C candidate, Rational score, int rank) implements Winner {} +``` + ## Known deviations from the Scala reference Ported behavior generally matches `votee-scala` exactly, but a few places deliberately diverge. Each is called out with a code comment at its call site too: @@ -157,7 +163,9 @@ Ported behavior generally matches `votee-scala` exactly, but a few places delibe - **Ballot generics** use the Curiously Recurring Generic Pattern instead of Scala's higher-kinded self-type, since Java can't express `Ballot[+C <: Candidate, +T[+CC >: C <: Candidate] <: Ballot[CC, T]]` directly. - **`Contingent`'s majority check compares the leader's raw score to a flat `1/2`**, not to half of `ballots.size()` (unlike `Majority`/`Coombs`, which do scale it) - this matches the Scala reference exactly, but means the runoff/redistribution branch is effectively unreachable with normal integer ballot weights. Under active review; not yet changed in either implementation. - **`ExhaustiveBallot` resolves ties via the given `TieResolver`** at both the elimination and final-winner steps. The Scala reference accepts a `tieResolver` parameter but never actually uses it, deciding both steps by incidental sort order instead. This port intentionally does **not** replicate that specific behavior. +- **`Approval`/`Veto` only score preferences for candidates in the given eligible list.** The Scala reference doesn't filter by the `candidates` parameter at all, so a ballot preference for a candidate outside that list could otherwise still accumulate votes (and, for `Veto`, could throw off which preference counts as the ballot's "last" choice). This port intentionally does **not** replicate that; a candidate not in the eligible list never scores, and `Veto`'s last-choice check is computed among eligible preferences only. - **`Approval`/`Veto` on the ported test fixtures can legitimately tie** (every fixture ballot ranks all candidates, so `Approval` degenerates into a full N-way tie). Which candidate a tie resolves to first differs between Scala's hash-bucket map iteration and Java's insertion-ordered one; the test suite asserts scores, not winner identity, in that case. See the LLD's "Determinism and Tie-Break Ordering" section. +- **`Contingent` guards against empty input** (no candidates, no ballots, or a ballot with no eligible preference), returning no winner or safely skipping the ballot instead of throwing. The Scala reference has no such guard and would throw on the equivalent input. - **`Baldwin`, `Contingent`, `Coombs`, and `ExhaustiveBallot` ignore the `vacancies` parameter**, matching the Scala reference - each of these is structurally a single-winner algorithm. - Score accumulators use `LinkedHashMap` (ballot-processing order) rather than relying on hash-bucket order, for reproducible results across runs. diff --git a/votee/docs/low-level-design.md b/votee/docs/low-level-design.md index 0ae065a..9acdcf0 100644 --- a/votee/docs/low-level-design.md +++ b/votee/docs/low-level-design.md @@ -237,14 +237,24 @@ Design note: Scala expresses the three built-in resolvers as members of `Electio ### 4.5 Winner +`Winner` is an interface, not a record directly, following the same contract-plus-default-implementation shape as `Candidate`/`PreferentialCandidate` and `Ballot`/`PreferentialBallot`. This was a deliberate revision after initial implementation: a record would have forced every consumer into exactly the (candidate, score) pair, with no way to carry extra fields (rank, margin, district, and so on) without wrapping. As an interface, a consumer can implement `Winner` directly for a richer result type instead. + ```java -public record Winner(C candidate, Rational score) { - public static Winner of(Map.Entry entry) { - return new Winner<>(entry.getKey(), entry.getValue()); +public interface Winner { + C candidate(); + Rational score(); + + static Winner of(Map.Entry entry) { + return new PreferentialWinner<>(entry.getKey(), entry.getValue()); } } + +public record PreferentialWinner(C candidate, Rational score) implements Winner { +} ``` +The built-in algorithms always construct `PreferentialWinner` internally via `Winner.of(...)`; they don't expose a way to plug in a custom `Winner` implementation as the algorithm's own output type. The extensibility here is for consumers who want to adapt or wrap an algorithm's result into their own richer type downstream, not for parameterizing the algorithms themselves over `W`. + ### 4.6 Election and AbstractPreferentialElection ```java diff --git a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Approval.java b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Approval.java index fcd5850..6f2f448 100644 --- a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Approval.java +++ b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Approval.java @@ -22,9 +22,9 @@ public static > List> elec public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { Map scores = new LinkedHashMap<>(); for (B ballot : ballots) { - for (C candidate : ballot.preferences()) { - scores.merge(candidate, ballot.weight(), Rational::add); - } + ballot.preferences().stream() + .filter(candidates::contains) + .forEach(candidate -> scores.merge(candidate, ballot.weight(), Rational::add)); } List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) diff --git a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Contingent.java b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Contingent.java index 0f06ec3..d3380ec 100644 --- a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Contingent.java +++ b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Contingent.java @@ -24,6 +24,12 @@ public List> run(List ballots, List candidates, int vacancies, T Map scores = new LinkedHashMap<>(countFirstVotes(ballots, candidates)); List> sorted = resolveTies(descending(scores), tieResolver); + // No candidate received a single eligible first-preference vote (e.g. empty candidates, + // empty ballots, or no ballot has an eligible preference) - there is no leader to elect. + if (sorted.isEmpty()) { + return List.of(); + } + // Matches the reference implementation exactly: the leader's raw first-preference score is // compared against the flat 1/2 constant, not against half of ballots.size(). In practice this // means the runoff branch below is only reached when ballot weights are unusually small. @@ -33,8 +39,9 @@ public List> run(List ballots, List candidates, int vacancies, T List topTwo = sorted.stream().limit(2).map(Map.Entry::getKey).toList(); for (B ballot : ballots) { - if (!topTwo.contains(ballot.preferences().getFirst())) { - ballot.preferences().stream() + List preferences = ballot.preferences(); + if (!preferences.isEmpty() && !topTwo.contains(preferences.getFirst())) { + preferences.stream() .filter(topTwo::contains) .findFirst() .ifPresent(candidate -> scores.merge(candidate, ballot.weight(), Rational::add)); diff --git a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Veto.java b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Veto.java index 77e7593..3a71c75 100644 --- a/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Veto.java +++ b/votee/src/main/java/com/ludovictemgoua/votee/algorithms/Veto.java @@ -22,13 +22,13 @@ public static > List> elec public List> run(List ballots, List candidates, int vacancies, TieResolver tieResolver) { Map scores = new LinkedHashMap<>(); for (B ballot : ballots) { - List preferences = ballot.preferences(); - for (int i = 0; i < preferences.size(); i++) { - // Every preference scores a point except a ballot's last choice, its veto - unless - // the ballot only lists one candidate, in which case that candidate isn't vetoed. - boolean isVetoed = i == preferences.size() - 1 && preferences.size() > 1; + List eligible = ballot.preferences().stream().filter(candidates::contains).toList(); + for (int i = 0; i < eligible.size(); i++) { + // Every eligible preference scores a point except the ballot's last eligible choice, + // its veto - unless the ballot only has one eligible candidate, which isn't vetoed. + boolean isVetoed = i == eligible.size() - 1 && eligible.size() > 1; if (!isVetoed) { - scores.merge(preferences.get(i), Rational.ONE, Rational::add); + scores.merge(eligible.get(i), Rational.ONE, Rational::add); } } } diff --git a/votee/src/main/java/com/ludovictemgoua/votee/model/Election.java b/votee/src/main/java/com/ludovictemgoua/votee/model/Election.java index 2ab0a15..71c5e06 100644 --- a/votee/src/main/java/com/ludovictemgoua/votee/model/Election.java +++ b/votee/src/main/java/com/ludovictemgoua/votee/model/Election.java @@ -2,7 +2,7 @@ import java.util.List; -public interface Election, W extends Winner> { +public interface Election, W> { List run(List ballots, List candidates, int vacancies, TieResolver tieResolver); default List run(List ballots, List candidates, int vacancies) { diff --git a/votee/src/main/java/com/ludovictemgoua/votee/model/PreferentialWinner.java b/votee/src/main/java/com/ludovictemgoua/votee/model/PreferentialWinner.java new file mode 100644 index 0000000..a764b24 --- /dev/null +++ b/votee/src/main/java/com/ludovictemgoua/votee/model/PreferentialWinner.java @@ -0,0 +1,5 @@ +package com.ludovictemgoua.votee.model; + +/** The library's default {@link Winner} implementation: just a candidate and their final score. */ +public record PreferentialWinner(C candidate, Rational score) implements Winner { +} diff --git a/votee/src/main/java/com/ludovictemgoua/votee/model/TieResolvers.java b/votee/src/main/java/com/ludovictemgoua/votee/model/TieResolvers.java index 5322266..ee34db5 100644 --- a/votee/src/main/java/com/ludovictemgoua/votee/model/TieResolvers.java +++ b/votee/src/main/java/com/ludovictemgoua/votee/model/TieResolvers.java @@ -1,6 +1,5 @@ package com.ludovictemgoua.votee.model; -import java.sql.Array; import java.util.ArrayList; import java.util.Collections; import java.util.List; diff --git a/votee/src/main/java/com/ludovictemgoua/votee/model/Winner.java b/votee/src/main/java/com/ludovictemgoua/votee/model/Winner.java index c2b0621..86dab1d 100644 --- a/votee/src/main/java/com/ludovictemgoua/votee/model/Winner.java +++ b/votee/src/main/java/com/ludovictemgoua/votee/model/Winner.java @@ -2,8 +2,18 @@ import java.util.Map; -public record Winner(C candidate, Rational score) { - public static Winner of(Map.Entry entry) { - return new Winner<>(entry.getKey(), entry.getValue()); +/** + * Contract for an election winner: a candidate paired with their final score. Implemented as an + * interface (rather than the {@code PreferentialWinner} record directly) so consumers who need + * extra fields (rank, margin, district, and so on) can implement their own {@code Winner}, instead + * of being constrained to the built-in record. + */ +public interface Winner { + C candidate(); + + Rational score(); + + static Winner of(Map.Entry entry) { + return new PreferentialWinner<>(entry.getKey(), entry.getValue()); } -} \ No newline at end of file +} diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ApprovalTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ApprovalTest.java index b9ee54b..5b7176d 100644 --- a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ApprovalTest.java +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ApprovalTest.java @@ -2,6 +2,7 @@ import com.ludovictemgoua.votee.model.PreferentialBallot; import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.PreferentialWinner; import com.ludovictemgoua.votee.model.Rational; import com.ludovictemgoua.votee.model.Winner; import com.ludovictemgoua.votee.support.FixtureLoader; @@ -49,6 +50,22 @@ void everyListedPreferenceCountsAsAFullApprovalVote() { List> winners = Approval.elect(ballots, candidates, 1); - assertThat(winners).containsExactly(new Winner<>(a, Rational.whole(3))); + assertThat(winners).containsExactly(new PreferentialWinner<>(a, Rational.whole(3))); + } + + @Test + void aPreferenceForACandidateNotInTheEligibleListNeverCounts() { + List candidates = List.of(a, b); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a, c)), + PreferentialBallot.of(2, List.of(a, b)) + ); + + List> winners = Approval.elect(ballots, candidates, candidates.size()); + + assertThat(winners).containsExactly( + new PreferentialWinner<>(a, Rational.whole(2)), + new PreferentialWinner<>(b, Rational.whole(1)) + ); } } diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/BaldwinTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/BaldwinTest.java index 7ce1d73..be0afaa 100644 --- a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/BaldwinTest.java +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/BaldwinTest.java @@ -2,6 +2,7 @@ import com.ludovictemgoua.votee.model.PreferentialBallot; import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.PreferentialWinner; import com.ludovictemgoua.votee.model.Rational; import com.ludovictemgoua.votee.model.Winner; import com.ludovictemgoua.votee.support.FixtureLoader; @@ -36,7 +37,7 @@ void aSingleRemainingCandidateWinsWithoutAnEliminationRound() { List> winners = Baldwin.elect(ballots, candidates, 1); - assertThat(winners).containsExactly(new Winner<>(a, Rational.ZERO)); + assertThat(winners).containsExactly(new PreferentialWinner<>(a, Rational.ZERO)); } @Test @@ -53,6 +54,6 @@ void eliminatesTheLowestBordaScorerEachRoundUntilOneCandidateRemains() { // Round 2 Borda scores among [a, b] (a=3, b=1) eliminate b, leaving a. List> winners = Baldwin.elect(ballots, candidates, 1); - assertThat(winners).containsExactly(new Winner<>(a, Rational.ZERO)); + assertThat(winners).containsExactly(new PreferentialWinner<>(a, Rational.ZERO)); } } diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/BordaCountTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/BordaCountTest.java index f112868..4ef0839 100644 --- a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/BordaCountTest.java +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/BordaCountTest.java @@ -2,6 +2,7 @@ import com.ludovictemgoua.votee.model.PreferentialBallot; import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.PreferentialWinner; import com.ludovictemgoua.votee.model.Rational; import com.ludovictemgoua.votee.model.Winner; import com.ludovictemgoua.votee.support.FixtureLoader; @@ -37,9 +38,9 @@ void scoresByRankPositionAmongAllCandidates() { List> winners = BordaCount.elect(ballots, candidates, 3); assertThat(winners).containsExactly( - new Winner<>(a, Rational.whole(2)), - new Winner<>(b, Rational.whole(1)), - new Winner<>(c, Rational.whole(0)) + new PreferentialWinner<>(a, Rational.whole(2)), + new PreferentialWinner<>(b, Rational.whole(1)), + new PreferentialWinner<>(c, Rational.whole(0)) ); } @@ -53,8 +54,8 @@ void ranksOnlyWithinTheGivenCandidateListEvenWhenABallotPrefersAnExcludedCandida List> winners = BordaCount.elect(ballots, eligibleCandidates, 2); assertThat(winners).containsExactly( - new Winner<>(a, Rational.whole(1)), - new Winner<>(c, Rational.whole(0)) + new PreferentialWinner<>(a, Rational.whole(1)), + new PreferentialWinner<>(c, Rational.whole(0)) ); } } diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ContingentTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ContingentTest.java index bfc3275..7ac2f33 100644 --- a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ContingentTest.java +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ContingentTest.java @@ -2,6 +2,7 @@ import com.ludovictemgoua.votee.model.PreferentialBallot; import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.PreferentialWinner; import com.ludovictemgoua.votee.model.Rational; import com.ludovictemgoua.votee.model.Winner; import com.ludovictemgoua.votee.support.FixtureLoader; @@ -43,6 +44,37 @@ void picksTheImmediateFirstPreferenceLeaderWhenItsScoreExceedsOneHalf() { List> winners = Contingent.elect(ballots, candidates, 1); - assertThat(winners).containsExactly(new Winner<>(a, Rational.whole(2))); + assertThat(winners).containsExactly(new PreferentialWinner<>(a, Rational.whole(2))); + } + + @Test + void returnsNoWinnerWhenNoCandidateReceivesAnEligibleFirstPreferenceVote() { + List candidates = List.of(); + List> ballots = List.of(); + + List> winners = Contingent.elect(ballots, candidates, 1); + + assertThat(winners).isEmpty(); + } + + /** + * Needs fractional weights: with integer weights any nonzero score already clears the flat 1/2 + * threshold (see Contingent's own inline comment), short-circuiting before the runoff loop below + * ever runs. Keeping every ballot's weight at 1/4 keeps the leader's raw score at or under 1/2, so + * this actually reaches the loop - and the empty-preference ballot inside it - unlike a normal + * integer-weight scenario would. + */ + @Test + void skipsAnEmptyPreferenceBallotDuringTheRunoffInsteadOfThrowing() { + List candidates = List.of(a, b); + List> ballots = List.of( + new PreferentialBallot<>(1, Rational.of(1, 4), List.of(a)), + new PreferentialBallot<>(2, Rational.of(1, 4), List.of(b)), + new PreferentialBallot<>(3, Rational.of(1, 4), List.of()) + ); + + List> winners = Contingent.elect(ballots, candidates, 1); + + assertThat(winners).containsExactly(new PreferentialWinner<>(a, Rational.of(1, 4))); } } diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/CoombsTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/CoombsTest.java index 6a8d826..fff236d 100644 --- a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/CoombsTest.java +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/CoombsTest.java @@ -2,6 +2,7 @@ import com.ludovictemgoua.votee.model.PreferentialBallot; import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.PreferentialWinner; import com.ludovictemgoua.votee.model.Rational; import com.ludovictemgoua.votee.model.Winner; import com.ludovictemgoua.votee.support.FixtureLoader; @@ -40,6 +41,6 @@ void eliminatesTheMostLastRankedCandidateUntilAMajorityEmerges() { // disliked) and is eliminated. Round 2 among [a, b]: a=2, b=1 - a clears the 1.5 majority. List> winners = Coombs.elect(ballots, candidates, 1); - assertThat(winners).containsExactly(new Winner<>(a, Rational.whole(2))); + assertThat(winners).containsExactly(new PreferentialWinner<>(a, Rational.whole(2))); } } diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ExhaustiveBallotTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ExhaustiveBallotTest.java index 035b75a..9813b30 100644 --- a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ExhaustiveBallotTest.java +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/ExhaustiveBallotTest.java @@ -2,6 +2,7 @@ import com.ludovictemgoua.votee.model.PreferentialBallot; import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.PreferentialWinner; import com.ludovictemgoua.votee.model.Rational; import com.ludovictemgoua.votee.model.TieResolvers; import com.ludovictemgoua.votee.model.Winner; @@ -46,7 +47,7 @@ void whichTiedCandidateIsEliminatedFirstIsDecidedByTheGivenTieResolver() { List> withReverse = ExhaustiveBallot.elect(ballots, candidates, 1, TieResolvers.reverse()); - assertThat(withDoNothing).containsExactly(new Winner<>(b, Rational.whole(2))); - assertThat(withReverse).containsExactly(new Winner<>(a, Rational.whole(2))); + assertThat(withDoNothing).containsExactly(new PreferentialWinner<>(b, Rational.whole(2))); + assertThat(withReverse).containsExactly(new PreferentialWinner<>(a, Rational.whole(2))); } } diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/MajorityTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/MajorityTest.java index fb0e550..40204a3 100644 --- a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/MajorityTest.java +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/MajorityTest.java @@ -2,6 +2,7 @@ import com.ludovictemgoua.votee.model.PreferentialBallot; import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.PreferentialWinner; import com.ludovictemgoua.votee.model.Rational; import com.ludovictemgoua.votee.model.TieResolvers; import com.ludovictemgoua.votee.model.Winner; @@ -56,7 +57,7 @@ void picksTheCandidateWithStrictlyMoreThanHalfTheFirstPreferenceVotes() { List> withExplicitResolver = Majority.elect(ballots, candidates, 1, TieResolvers.doNothing()); - assertThat(withDefaultResolver).containsExactly(new Winner<>(a, Rational.whole(3))); + assertThat(withDefaultResolver).containsExactly(new PreferentialWinner<>(a, Rational.whole(3))); assertThat(withExplicitResolver).isEqualTo(withDefaultResolver); } } diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/SuperMajorityTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/SuperMajorityTest.java index 630929d..b854b74 100644 --- a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/SuperMajorityTest.java +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/SuperMajorityTest.java @@ -2,6 +2,7 @@ import com.ludovictemgoua.votee.model.PreferentialBallot; import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.PreferentialWinner; import com.ludovictemgoua.votee.model.Rational; import com.ludovictemgoua.votee.model.Winner; import com.ludovictemgoua.votee.support.FixtureLoader; @@ -58,7 +59,7 @@ void picksTheCandidateThatClearsTheGivenThreshold() { List> winners = SuperMajority.elect(ballots, candidates, 1, Rational.of(3, 4)); - assertThat(winners).containsExactly(new Winner<>(a, Rational.whole(4))); + assertThat(winners).containsExactly(new PreferentialWinner<>(a, Rational.whole(4))); } @Test diff --git a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/VetoTest.java b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/VetoTest.java index d858869..d847546 100644 --- a/votee/src/test/java/com/ludovictemgoua/votee/algorithms/VetoTest.java +++ b/votee/src/test/java/com/ludovictemgoua/votee/algorithms/VetoTest.java @@ -2,6 +2,7 @@ import com.ludovictemgoua.votee.model.PreferentialBallot; import com.ludovictemgoua.votee.model.PreferentialCandidate; +import com.ludovictemgoua.votee.model.PreferentialWinner; import com.ludovictemgoua.votee.model.Rational; import com.ludovictemgoua.votee.model.Winner; import com.ludovictemgoua.votee.support.FixtureLoader; @@ -48,7 +49,7 @@ void everyPreferenceExceptTheLastOneScoresAPoint() { List> winners = Veto.elect(ballots, candidates, 1); - assertThat(winners).containsExactly(new Winner<>(b, Rational.whole(3))); + assertThat(winners).containsExactly(new PreferentialWinner<>(b, Rational.whole(3))); } @Test @@ -60,7 +61,7 @@ void aSinglePreferenceBallotDoesNotVetoItsOnlyCandidate() { List> winners = Veto.elect(ballots, candidates, 1); - assertThat(winners).containsExactly(new Winner<>(a, Rational.ONE)); + assertThat(winners).containsExactly(new PreferentialWinner<>(a, Rational.ONE)); } @Test @@ -72,6 +73,18 @@ void vetoScoresAreFlatPointsNotWeightedByBallotWeight() { List> winners = Veto.elect(ballots, candidates, 1); - assertThat(winners).containsExactly(new Winner<>(a, Rational.ONE)); + assertThat(winners).containsExactly(new PreferentialWinner<>(a, Rational.ONE)); + } + + @Test + void aCandidateNotInTheEligibleListNeverCountsAndTheVetoTargetsTheLastEligibleChoiceInstead() { + List candidates = List.of(a, b); + List> ballots = List.of( + PreferentialBallot.of(1, List.of(a, b, c)) + ); + + List> winners = Veto.elect(ballots, candidates, candidates.size()); + + assertThat(winners).containsExactly(new PreferentialWinner<>(a, Rational.ONE)); } }