diff --git a/src/main/java/network/brightspots/rcv/ContestConfig.java b/src/main/java/network/brightspots/rcv/ContestConfig.java index bb3deffc7..1c0149ff3 100644 --- a/src/main/java/network/brightspots/rcv/ContestConfig.java +++ b/src/main/java/network/brightspots/rcv/ContestConfig.java @@ -70,6 +70,7 @@ class ContestConfig { private static final int MAX_ROW_INDEX = 100000; private static final int MIN_MAX_RANKINGS_ALLOWED = 1; private static final int MIN_MAX_SKIPPED_RANKS_ALLOWED = 0; + private static final int MIN_NUMBER_OF_ROUNDS = 0; private static final int MIN_NUMBER_OF_WINNERS = 0; private static final int MIN_DECIMAL_PLACES_FOR_VOTE_ARITHMETIC = 1; private static final int MAX_DECIMAL_PLACES_FOR_VOTE_ARITHMETIC = 20; @@ -701,6 +702,15 @@ && getMaxRankingsAllowed() < MIN_MAX_RANKINGS_ALLOWED)) { ValidationError.RULES_MULTI_SEAT_BOTTOMS_UP_PERCENTAGE_THRESHOLD_INVALID); } + if (fieldOutOfRangeOrNotInteger( + getStopTabulationEarlyOnRoundRaw(), + "stopEarlyOnRound", + MIN_NUMBER_OF_ROUNDS, + Integer.MAX_VALUE, + false)) { + validationErrors.add(ValidationError.RULES_STOP_TABULATION_EARLY_ON_ROUND_INVALID); + } + WinnerElectionMode winnerMode = getWinnerElectionMode(); if (Utils.isInt(getNumberOfWinnersRaw())) { if (getNumberOfWinners() > 0) { @@ -789,6 +799,10 @@ private String getNumberOfWinnersRaw() { return rawConfig.rules.numberOfWinners; } + private String getStopTabulationEarlyOnRoundRaw() { + return rawConfig.rules.stopTabulationEarlyOnRound; + } + Integer getNumberOfWinners() { return Integer.parseInt(getNumberOfWinnersRaw()); } @@ -947,6 +961,12 @@ boolean isContinueUntilTwoCandidatesRemainEnabled() { return rawConfig.rules.continueUntilTwoCandidatesRemain; } + Integer getStopTabulationEarlyOnRound() { + return isNullOrBlank(getStopTabulationEarlyOnRoundRaw()) + ? Integer.MAX_VALUE + : Integer.parseInt(getStopTabulationEarlyOnRoundRaw()); + } + int getNumDeclaredCandidates() { int size = getCandidateCodeList().size(); if (undeclaredWriteInsEnabled()) { @@ -1125,6 +1145,7 @@ enum ValidationError { RULES_MIN_DECIMAL_PLACES_FOR_VOTE_ARITHMETIC_INVALID, RULES_MIN_VOTE_THRESHOLD_INVALID, RULES_MULTI_SEAT_BOTTOMS_UP_PERCENTAGE_THRESHOLD_INVALID, + RULES_STOP_TABULATION_EARLY_ON_ROUND_INVALID, RULES_NUMBER_OF_WINNERS_INVALID_FOR_WINNER_ELECTION_MODE, RULES_CONTINUE_UNTIL_TWO_CANDIDATES_REMAIN_TRUE_FOR_MULTI_SEAT, RULES_BATCH_ELIMINATION_TRUE_FOR_MULTI_SEAT, diff --git a/src/main/java/network/brightspots/rcv/ContestConfigMigration.java b/src/main/java/network/brightspots/rcv/ContestConfigMigration.java index b71db4c86..7f440d311 100644 --- a/src/main/java/network/brightspots/rcv/ContestConfigMigration.java +++ b/src/main/java/network/brightspots/rcv/ContestConfigMigration.java @@ -188,6 +188,11 @@ static void migrateConfigVersion(ContestConfig config) } } + // Migrations from 1.3.0 to 1.4.0 + if (rules.stopTabulationEarlyOnRound == null) { + rules.stopTabulationEarlyOnRound = ""; + } + Logger.info( "Migrated tabulator config version from %s to %s.", config.rawConfig.tabulatorVersion != null ? config.rawConfig.tabulatorVersion : "unknown", diff --git a/src/main/java/network/brightspots/rcv/GuiConfigController.java b/src/main/java/network/brightspots/rcv/GuiConfigController.java index 0b59e0b7f..a698ebb51 100644 --- a/src/main/java/network/brightspots/rcv/GuiConfigController.java +++ b/src/main/java/network/brightspots/rcv/GuiConfigController.java @@ -240,6 +240,8 @@ public class GuiConfigController implements Initializable { @FXML private CheckBox checkBoxContinueUntilTwoCandidatesRemain; @FXML + private TextField textFieldStopTabulationEarlyOnRound; + @FXML private CheckBox checkBoxExhaustOnDuplicateCandidate; @FXML private MenuBar menuBar; @@ -795,6 +797,8 @@ private void clearAndDisableWinningRuleFields() { checkBoxMaxRankingsAllowedMax.setDisable(true); textFieldMinimumVoteThreshold.clear(); textFieldMinimumVoteThreshold.setDisable(true); + textFieldStopTabulationEarlyOnRound.clear(); + textFieldStopTabulationEarlyOnRound.setDisable(true); checkBoxBatchElimination.setSelected(false); checkBoxBatchElimination.setDisable(true); checkBoxContinueUntilTwoCandidatesRemain.setSelected(false); @@ -1110,6 +1114,7 @@ public LocalDate fromString(String string) { setWinningRulesDefaultValues(); checkBoxMaxRankingsAllowedMax.setDisable(false); textFieldMinimumVoteThreshold.setDisable(false); + textFieldStopTabulationEarlyOnRound.setDisable(false); choiceTiebreakMode.setDisable(false); switch (getWinnerElectionModeChoice(choiceWinnerElectionMode)) { case STANDARD_SINGLE_WINNER -> { @@ -1250,6 +1255,7 @@ private void loadConfig(ContestConfig config) throws ConfigVersionIsNewerThanApp setThresholdCalculationMethodRadioButton(rules.nonIntegerWinningThreshold, rules.hareQuota); checkBoxBatchElimination.setSelected(rules.batchElimination); checkBoxContinueUntilTwoCandidatesRemain.setSelected(rules.continueUntilTwoCandidatesRemain); + textFieldStopTabulationEarlyOnRound.setText(rules.stopTabulationEarlyOnRound); checkBoxExhaustOnDuplicateCandidate.setSelected(rules.exhaustOnDuplicateCandidate); } @@ -1340,6 +1346,7 @@ private RawContestConfig createRawContestConfig() { rules.hareQuota = radioThresholdHareQuota.isSelected(); rules.batchElimination = checkBoxBatchElimination.isSelected(); rules.continueUntilTwoCandidatesRemain = checkBoxContinueUntilTwoCandidatesRemain.isSelected(); + rules.stopTabulationEarlyOnRound = getTextOrEmptyString(textFieldStopTabulationEarlyOnRound); rules.exhaustOnDuplicateCandidate = checkBoxExhaustOnDuplicateCandidate.isSelected(); rules.rulesDescription = getTextOrEmptyString(textFieldRulesDescription); config.rules = rules; diff --git a/src/main/java/network/brightspots/rcv/Main.java b/src/main/java/network/brightspots/rcv/Main.java index b9fd9174f..1f96808e8 100644 --- a/src/main/java/network/brightspots/rcv/Main.java +++ b/src/main/java/network/brightspots/rcv/Main.java @@ -27,7 +27,7 @@ public class Main extends GuiApplication { public static final String APP_NAME = "RCTab"; - public static final String APP_VERSION = "1.3.0"; + public static final String APP_VERSION = "1.4.0.alpha"; /** * Main entry point to RCTab. diff --git a/src/main/java/network/brightspots/rcv/RawContestConfig.java b/src/main/java/network/brightspots/rcv/RawContestConfig.java index f03908e33..68ce34fc1 100644 --- a/src/main/java/network/brightspots/rcv/RawContestConfig.java +++ b/src/main/java/network/brightspots/rcv/RawContestConfig.java @@ -269,6 +269,7 @@ public static class ContestRules { public boolean hareQuota; public boolean batchElimination; public boolean continueUntilTwoCandidatesRemain; + public String stopTabulationEarlyOnRound; public boolean exhaustOnDuplicateCandidate; public String rulesDescription; diff --git a/src/main/java/network/brightspots/rcv/Tabulator.java b/src/main/java/network/brightspots/rcv/Tabulator.java index abc1534cc..703a41713 100644 --- a/src/main/java/network/brightspots/rcv/Tabulator.java +++ b/src/main/java/network/brightspots/rcv/Tabulator.java @@ -412,7 +412,9 @@ private boolean shouldContinueTabulating() { int numEliminatedCandidates = candidateToRoundEliminated.size(); int numWinnersDeclared = winnerToRound.size(); // apply config setting if specified - if (config.isContinueUntilTwoCandidatesRemainEnabled()) { + if (currentRound >= config.getStopTabulationEarlyOnRound()) { + keepTabulating = false; + } else if (config.isContinueUntilTwoCandidatesRemainEnabled()) { // Keep going if there are more than two candidates alive. Also make sure we tabulate one last // round after we've made our final elimination. keepTabulating = numEliminatedCandidates + numWinnersDeclared + 1 < config.getNumCandidates() diff --git a/src/main/resources/network/brightspots/rcv/GuiConfigLayout.fxml b/src/main/resources/network/brightspots/rcv/GuiConfigLayout.fxml index a0f038829..5ef300b4a 100644 --- a/src/main/resources/network/brightspots/rcv/GuiConfigLayout.fxml +++ b/src/main/resources/network/brightspots/rcv/GuiConfigLayout.fxml @@ -430,6 +430,13 @@ + + diff --git a/src/main/resources/network/brightspots/rcv/hints_winning_rules.txt b/src/main/resources/network/brightspots/rcv/hints_winning_rules.txt index aa24e44a1..18e048dd6 100644 --- a/src/main/resources/network/brightspots/rcv/hints_winning_rules.txt +++ b/src/main/resources/network/brightspots/rcv/hints_winning_rules.txt @@ -22,6 +22,8 @@ Use Batch Elimination: Batch elimination, or simultaneous elimination of all can Continue until Two Candidates Remain: Single-winner ranked-choice voting elections can stop as soon as a candidate receives a majority of votes, even though 3 or more candidates may still be in the race. Selecting this option will run the round-by-round count until only two candidates remain, regardless of when a candidate wins a majority of votes. Available only when Winner Election Mode is "Single-winner majority determines winner" or "Multi-pass IRV." +Stop Tabulation Early on Round: If a winner is not found by the given round, tabulation stops early. + Tiebreak Mode (required): Ties in ranked-choice voting contests can occur when eliminating candidates or when electing candidates. Multi-winner contests can have ties between candidates who have both crossed the threshold of election; in that case ties are broken to determine whose surplus vote value transfers first. Tiebreak procedures are set in law, either in the ranked-choice voting law used in your jurisdiction or in the elections code more generally. Select the option from this list that complies with law and procedure in your jurisdiction. * Random: Randomly select a tied candidate to eliminate or, in multi-winner contests only, elect. Requires a random seed. diff --git a/src/test/java/network/brightspots/rcv/TabulatorTests.java b/src/test/java/network/brightspots/rcv/TabulatorTests.java index 1e7728d25..45bcc10aa 100644 --- a/src/test/java/network/brightspots/rcv/TabulatorTests.java +++ b/src/test/java/network/brightspots/rcv/TabulatorTests.java @@ -395,6 +395,12 @@ void testSkipToNext() { runTabulationTest("skip_to_next_test"); } + @Test + @DisplayName("test stopping tabulation early") + void testStopTabulationEarly() { + runTabulationTest("stop_tabulation_early_test"); + } + @Test @DisplayName("test Hare quota") void testHareQuota() { diff --git a/src/test/resources/network/brightspots/rcv/test_data/stop_tabulation_early_test/stop_tabulation_early_test_config.json b/src/test/resources/network/brightspots/rcv/test_data/stop_tabulation_early_test/stop_tabulation_early_test_config.json new file mode 100644 index 000000000..5957d00bf --- /dev/null +++ b/src/test/resources/network/brightspots/rcv/test_data/stop_tabulation_early_test/stop_tabulation_early_test_config.json @@ -0,0 +1,55 @@ +{ + "tabulatorVersion": "TEST", + "outputSettings": { + "contestName": "Stop Tabulation Early Test", + "outputDirectory": "output", + "contestDate": "2023-03-14", + "contestJurisdiction": "Funkytown, USA", + "contestOffice": "Sergeant-at-Arms", + "tabulateByPrecinct": false, + "generateCdfJson": false + }, + "cvrFileSources": [ + { + "filePath" : "stop_tabulation_early_test_cvr.xlsx", + "firstVoteColumnIndex" : "2", + "firstVoteRowIndex" : "2", + "idColumnIndex" : "1", + "precinctColumnIndex" : "", + "provider" : "ess", + "treatBlankAsUndeclaredWriteIn": false, + "overvoteLabel": "overvote", + "undervoteLabel": "undervote", + "undeclaredWriteInLabel": "" + } ], + "candidates" : [ { + "name" : "Mookie Blaylock", + "code" : "", + "excluded" : false + }, { + "name" : "Yinka Dare", + "code" : "", + "excluded" : false + }, { + "name" : "George Gervin", + "code" : "", + "excluded" : false + } ], + "rules" : { + "tiebreakMode": "random", + "overvoteRule": "alwaysSkipToNextRank", + "winnerElectionMode": "singleWinnerMajority", + "randomSeed": "1", + "numberOfWinners": "1", + "decimalPlacesForVoteArithmetic": "4", + "minimumVoteThreshold": "0", + "maxSkippedRanksAllowed": "0", + "maxRankingsAllowed": "3", + "nonIntegerWinningThreshold": false, + "hareQuota": false, + "batchElimination": true, + "exhaustOnDuplicateCandidate": false, + "rulesDescription": "Doyle Rules", + "stopTabulationEarlyOnRound": "2" + } +} diff --git a/src/test/resources/network/brightspots/rcv/test_data/stop_tabulation_early_test/stop_tabulation_early_test_cvr.xlsx b/src/test/resources/network/brightspots/rcv/test_data/stop_tabulation_early_test/stop_tabulation_early_test_cvr.xlsx new file mode 100644 index 000000000..e5e49afbe Binary files /dev/null and b/src/test/resources/network/brightspots/rcv/test_data/stop_tabulation_early_test/stop_tabulation_early_test_cvr.xlsx differ diff --git a/src/test/resources/network/brightspots/rcv/test_data/stop_tabulation_early_test/stop_tabulation_early_test_expected_summary.json b/src/test/resources/network/brightspots/rcv/test_data/stop_tabulation_early_test/stop_tabulation_early_test_expected_summary.json new file mode 100644 index 000000000..e96796e8a --- /dev/null +++ b/src/test/resources/network/brightspots/rcv/test_data/stop_tabulation_early_test/stop_tabulation_early_test_expected_summary.json @@ -0,0 +1,33 @@ +{ + "config" : { + "contest" : "Stop Tabulation Early Test", + "date" : "2023-03-14", + "jurisdiction" : "Funkytown, USA", + "office" : "Sergeant-at-Arms", + "threshold" : "4" + }, + "results" : [ { + "round" : 1, + "tally" : { + "George Gervin" : "3", + "Mookie Blaylock" : "3", + "Yinka Dare" : "3" + }, + "tallyResults" : [ { + "eliminated" : "Yinka Dare", + "transfers" : { + "exhausted" : "3" + } + } ] + }, { + "round" : 2, + "tally" : { + "George Gervin" : "3", + "Mookie Blaylock" : "3" + }, + "tallyResults" : [ { + "eliminated" : "George Gervin", + "transfers" : { } + } ] + } ] +}