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" : { }
+ } ]
+ } ]
+}