Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
670d058
Merge edits written by CPSolver as referenced in previous issue comment
Jan 7, 2026
f1489aa
Fix merge mistake
Jan 7, 2026
b5a6d8b
Add test ballots referenced in issue 972 description
Jan 11, 2026
7b37a42
Add new overvote rule to GUI resource files
Jan 11, 2026
1994408
Add comment about function isExhausted ignoring temporarily inactive …
Jan 11, 2026
0e8df12
Add function isInactiveByOvervote because isExhausted does not consid…
Jan 15, 2026
e1eb90c
Restore code lines to previous state because overvote label does not …
Jan 15, 2026
cb10b6a
Lots of debugging of new tabulation code that implements new overvote…
Jan 15, 2026
d54e117
Add test for new overvote rule
Jan 15, 2026
80d3210
Convert test ballot data from A>B=C notation to rank number notation
Jan 16, 2026
fd89a05
Save gradle files modified by IDEA IDE because they work
Jan 17, 2026
b8be86f
Update README.md, copy content from RCTabPlus develop branch
cpsolver Jan 17, 2026
fef9198
Adjust file names in new test folder for new overvote rule
Jan 18, 2026
6474276
Update config documentation to include new overvote rule
Jan 19, 2026
3235b03
Fix new-code bug that failed test of overvote rule skip to next rank …
Jan 20, 2026
90e0951
Fix style error, indentation level
Jan 21, 2026
107cbac
Finalize new test, now passes this new test
Jan 23, 2026
e53957c
Add updated config file overlooked in previous commit
Jan 23, 2026
21efec3
Create test of new overvote rule for multi-winner election
Jan 23, 2026
31bae32
Finish creating test of new overvote rule for multi-winner contest
Jan 24, 2026
1f532f1
Add VoteOutcomeType.INACTIVE_OVERVOTED
Jan 24, 2026
548952f
In audit log, specify category as uncounted, not exhausted, when inac…
Jan 26, 2026
591684a
Refine two tests that test new overvote rule
Jan 26, 2026
674e621
Minor candidate name change in test data
Feb 2, 2026
3ea97a5
Refine audit info categorization to specify new category of inactive …
Feb 2, 2026
6501b80
Change categorization so older tests pass even though now use same au…
Feb 3, 2026
9ca5546
Update expected test results for new test
Feb 3, 2026
00b2d89
Change test ballot data to better test more kinds of transitions
Feb 7, 2026
daf90be
Refine wording in audit log for exhausted ballots
Feb 7, 2026
11200ea
Update test expected files to match new test ballot data
Feb 7, 2026
76b03e7
Update README to include link to summary graphic
Feb 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 7 additions & 136 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,144 +1,15 @@
# RCTab
# RCTabPlus

## Overview

RCTab is a free, open-source application designed to quickly and accurately tabulate a wide variety of ranked choice voting (RCV) elections, including both single-winner contests and various multi-winner formats (e.g. single transferable vote, a.k.a. STV). It allows users to:
- Create contest configuration files using a graphical user interface (GUI)
- Validate contest configuration files to ensure they are well-formed, and all values are within expected ranges
- Tabulate a contest
RCTabPlus is a fork of the RCTab software at https://github.com/BrightSpots/rcv

A contest configuration file specifies:
- Which tabulation rule variations to use
- A list of registered candidates
- Paths to one or more cast vote record (CVR) files
- Output formatting options (contest name, date, jurisdiction, etc.)
This Plus version adds a new Overvote Rule named "Count overvote when single continuing". This overvote-counting rule much better matches the clear intent of a voter in the United States where a ranked choice ballot typically has no more than 6 "rank" columns of ovals, even when there are as many as 20 or 30 candidates.

The Tabulator produces the following as output:
- A summary .csv file containing round-by-round vote totals for each candidate and the eventual winner(s)
- A summary .json file containing additional information which can be used by external tools for visualizing contest results
- A detailed audit .log file describing how every ballot was counted in each round over the course of the tabulation
The RCTab (non-plus) software only supports overvote-counting rules that make sense in Australia where a voter writes a number in a box next to each candidate's name. In that case a voter can easily write numbers that are as large as the number of candidates, and can easily avoid an overvote by not writing the same number twice.

## Installing and Launching the Tabulator
The overvote rule named "count overvote when single continuing" simply counts a ballot as inactive during any counting round in which more than one of the overvoted candidates is still continuing. When just one of the overvoted candidates is continuing, the ballot counts for that continuing candidate.

#### Method 1 (Easy): Pre-Compiled Version
The following graphic summarizes how this overvote rule works, and why it is needed:

1. Download the pre-compiled Tabulator for your OS from the GitHub [releases page](https://github.com/BrightSpots/rcv/releases).

**Note**: this download is a "jlink package", which means you don't even need to have Java installed on your machine to run it!

2. Unzip the file, navigate to the `bin` directory, and launch the RCV Tabulator GUI by running the `rcv` script if using MacOS or Linux, or `rcv.bat` if using Windows.

On Linux, you may install the .deb file, then run `/opt/rcv/bin/RCTab` to launch the tabulator GUI.

#### Method 2 (Less Easy): Compile and Run Using Gradle

1. Install [JDK 21](https://adoptium.net/temurin/releases/?version=21), and make sure your Java path is picking it up properly by
verifying that the following command returns the expected version:

`$ java -version`

If the expected version isn't returned, you'll need to follow the instructions [here](https://www.java.com/en/download/help/path.xml) on how to set your Java path.

If you are using Linux or MacOS and need to regularly switch between Java versions, consider installing [jEnv](https://www.jenv.be/). For a list of the Java versions installed on your machine, run `/usr/libexec/java_home -V` on MacOS or `update-alternatives --config java` on Linux.

2. Download the [zip of the source code from GitHub](https://github.com/BrightSpots/rcv/archive/master.zip) and unzip it, or install git and use the following command at the terminal / command prompt to clone a local copy on your machine:

`$ git clone https://github.com/BrightSpots/rcv.git`

3. Use the provided version of Gradle to build and run the code from the terminal / command prompt to start the RCV Tabulator GUI:

`$ cd rcv-master` (or, if you cloned the repo using git: `cd rcv`)

`$ ./gradlew run` (or, if you're on Windows: `gradlew run`)

If you get a "permission denied" error in Linux or MacOS, you need to mark the script as executable with:

`$ chmod 777 gradlew`

#### Method 3 (Least Easy): Building on an Air-Gapped Machine

1. Download Gradle from https://gradle.org/releases/ and place it in your path.
2. Download and extract the source code from
the [releases page](https://github.com/BrightSpots/rcv/releases).
3. Alongside the release you just downloaded, you will find corresponding cache files (cache.[OS].zip). Download this file too.
4. Stop the Gradle daemon with `gradle --stop`.
5. Delete the directory ~/.gradle/caches if it exists.
6. Extract the appropriate caches/[filename].zip to ~/.gradle/caches so that the "caches" directory is in ~/.gradle.
7. Alongside these extracted caches is a file named checksums.csv. In the extracted directory, you may manually verify each dependency using checksums.csv in accordance with your own policies.
8. Run `gradle assemble --offline` and ensure you get no errors.
9. Run `gradle run --offline` to launch RCTab, or `gradle jpackage --offline` to generate an executable file specific to the OS you are using (a .dmg, .exe, or .deb).

#### Encrypting the Tabulator Directory
For security purposes, we **strongly recommend** applying password encryption (e.g. 256-bit SHA) to the directory containing the Tabulator, config files, CVR files, and any other related files.

We recommend using open-source utilities such as [7-Zip](https://www.7-zip.org/) for Windows or EncFS, gocryptfs, etc. for Linux (see [this comparison](https://nuetzlich.net/gocryptfs/comparison/)).

Mac OS has built-in encryption capability that allows users to create encrypted disk images from folders using Disk Utility (see ["Create a secure disk image"](https://support.apple.com/guide/disk-utility/create-a-disk-image-dskutl11888/mac)).

## Configuring a Contest

The GUI can be used to easily create, save, and load contest configuration files (which are in .json format). These files can also be created manually using any basic text editor, but this method isn't recommended.

In either case, please reference the [config file documentation](config_file_documentation.txt) when configuring a contest.

**Warning**: Using shortcuts, aliases, or symbolic links to launch the Tabulator is not supported; doing so may result in unexpected behavior. Also, please avoid clicking in the command prompt / terminal window when starting the Tabulator GUI, as it may halt the startup process.

## Loading and Tabulating a Contest

The Tabulator includes several example contest configuration files and associated CVR files.

1. Click "File > Load..." in the menu and navigate to the `sample_input` folder (if you used Method 2 to install the Tabulator, navigate to the `test_data` folder).
2. Open one of the folders listed here and select the config file (it will have the `_config.json` suffix).
3. Click on the configuration tabs (Output, CVR Files, Candidates, Required Rules, Optional Rules) to see how this contest is configured.
4. Click "Tabulation > Validate" in the menu to check if this configuration is valid. You will see the results in the console at the bottom of the main window.
5. Click "Tabulation > Tabulate" in the menu to tabulate the election. You will see the results in the console, including the location of the output files.

## Command-Line Interface

Alternatively, you can run the Tabulator using the command-line interface by including the flag `--cli` and then supplying a path to an existing config file, e.g.:

`$ rcv --cli path/to/config`

Or, if you're compiling and running using Gradle:

`$ gradlew run --args="--cli path/to/config"`

You can also activate a special `convert-to-cdf` function via the command line to export the CVR as a NIST common data
format (CDF) .json instead of tabulating the results, e.g.:

`$ rcv --cli path/to/config --convert-to-cdf`

This option is available in the GUI by selecting the "Conversion > Convert CVRs in Current Config to CDF" menu option.

Or, again, if you're compiling and running using Gradle:

`$ gradlew run --args="--cli path/to/config --convert-to-cdf"`

Note: if you convert a source to CDF and that source uses an overvoteLabel or an undeclaredWriteInLabel, the label will
be represented differently in the generated CDF source file than it was in the original CVR source. When you create a
new config using this generated CDF source file and you need to set overvoteLabel, you should use "overvote". If you
need to set undeclaredWriteInLabel, you should use "Undeclared Write-ins".

## Viewing Tabulator Output

Tabulator output filenames automatically include the current date and time,
e.g. `2019-06-25_17-19-28_summary.csv`. This keeps them separate if you tabulate the same contest
multiple times.

Look in the console window to see where the output spreadsheet was written, e.g.

`2019-06-25 17:19:28 PDT INFO: Generating summary spreadsheet: /rcv/test_data/2018_maine_gov_primary_dem/output/2019-06-25_17-19-28_summary.csv...`

The summary spreadsheet (in .csv format), summary .json, and audit .log files are all readable using a basic text editor.

**Note**: If you intend to print any of the output files, we **strongly recommend** adding headers /
footers with page numbers, the filename, the date and time of printing, who is doing the printing,
and any other desired information.

## Acknowledgements

#### Bright Spots Developers

- Jonathan Moldover
- Louis Eisenberg
- Hylton Edingfield
https://votefair.org/count_overvote_when_single_continuing.png
3 changes: 2 additions & 1 deletion config_file_documentation.txt
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,11 @@ Config file must be valid JSON format. Examples can be found in the test_data fo

"overvoteRule" required
how the program should handle an overvote when it encounters one
value: "alwaysSkipToNextRank" | "exhaustImmediately" | "exhaustIfMultipleContinuing"
value: "alwaysSkipToNextRank" | "exhaustImmediately" | "exhaustIfMultipleContinuing" | "countWhenSingleContinuing"
"alwaysSkipToNextRank": when we encounter an overvote, ignore this rank and look at the next rank in the cast vote record
"exhaustImmediately": exhaust a ballot as soon as we encounter an overvote
"exhaustIfMultipleContinuing": if more than one candidate in an overvote are continuing, exhaust the ballot; if only one, assign the vote to them; if none, continue to the next rank (not valid with an ES&S source unless overvoteDelimiter is supplied)
"countWhenSingleContinuing": if more than one candidate in an overvote are continuing, count the ballot as inactive for this counting round; when the overvoted candidates include only one continuing candidate, count the ballot for for the single continuing candidate; when all the overvoted candidates are not continuing, ignore this rank and look at the next rank in the cast vote record

"winnerElectionMode" required
whether the program should apply a special process for selecting the winner(s)
Expand Down
Binary file modified gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
2 changes: 0 additions & 2 deletions gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
# Also manually update gradlew and gradlew.bat with appropriate version tag from:
# https://github.com/gradle/gradle
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
networkTimeout=10000
validateDistributionUrl=true
Expand Down
9 changes: 4 additions & 5 deletions gradlew

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions gradlew.bat

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion src/main/java/network/brightspots/rcv/CastVoteRecord.java
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ void logRoundOutcome(
if (outcomeType == VoteOutcomeType.IGNORED) {
logStringBuilder.append(" [was ignored] ");
} else if (outcomeType == VoteOutcomeType.EXHAUSTED) {
logStringBuilder.append(" [became inactive] ");
logStringBuilder.append(" [is exhausted] ");
} else if (outcomeType == VoteOutcomeType.INACTIVE_OVERVOTED) {
logStringBuilder.append(" [counted as inactive by overvote] ");
} else {
if (round == 1) {
logStringBuilder.append(" [counted for] ");
Expand Down Expand Up @@ -186,10 +188,18 @@ void exhaustBy(StatusForRound status) {
this.currentRoundStatus = status;
}

// check if exhausted, but this check ignores the possibility of a ballot being
// temporarily inactive during multiple continuing overvoted candidates
boolean isExhausted() {
return currentRoundStatus != StatusForRound.ACTIVE;
}

// check if ballot is inactive by overvote, which can be temporary
// when overvote rule is count when single continuing
boolean isInactiveByOvervote() {
return currentRoundStatus != StatusForRound.INVALIDATED_BY_OVERVOTE;
}

StatusForRound getBallotStatus() {
return currentRoundStatus;
}
Expand Down Expand Up @@ -303,6 +313,7 @@ enum VoteOutcomeType {
COUNTED,
IGNORED,
EXHAUSTED,
INACTIVE_OVERVOTED,
}

static class CvrParseException extends Exception {}
Expand Down
10 changes: 6 additions & 4 deletions src/main/java/network/brightspots/rcv/ContestConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,7 @@ && getOvervoteRule() != Tabulator.OvervoteRule.EXHAUST_IMMEDIATELY
&& getOvervoteRule() != Tabulator.OvervoteRule.ALWAYS_SKIP_TO_NEXT_RANK) {
validationErrors.add(ValidationError.CVR_OVERVOTE_LABEL_OVERVOTE_RULE_MISMATCH);
Logger.severe(
"When overvoteLabel is supplied, overvoteRule must be either \"%s\" or \"%s\"!",
"When overvoteLabel is supplied, overvoteRule must be \"%s\" or \"%s\"!",
Tabulator.OVERVOTE_RULE_ALWAYS_SKIP_TEXT,
Tabulator.OVERVOTE_RULE_EXHAUST_IMMEDIATELY_TEXT);
}
Expand Down Expand Up @@ -661,12 +661,14 @@ && isTabulateByEnabled(TabulateBySlice.BATCH)) {
cvrPath);
}
if (isNullOrBlank(source.getOvervoteDelimiter())
&& getOvervoteRule() == OvervoteRule.EXHAUST_IF_MULTIPLE_CONTINUING) {
&& (getOvervoteRule() == OvervoteRule.EXHAUST_IF_MULTIPLE_CONTINUING
|| getOvervoteRule() == OvervoteRule.COUNT_WHEN_SINGLE_CONTINUING)) {
validationErrors.add(ValidationError.CVR_OVERVOTE_DELIMITER_MISSING);
Logger.severe(
"overvoteDelimiter is required for an ES&S CVR source when overvoteRule "
+ "is set to \"%s\".",
Tabulator.OVERVOTE_RULE_EXHAUST_IF_MULTIPLE_TEXT);
+ "is set to \"%s\" or \"%s\".",
Tabulator.OVERVOTE_RULE_EXHAUST_IF_MULTIPLE_TEXT,
Tabulator.OVERVOTE_RULE_COUNT_WHEN_SINGLE_TEXT);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ public class GuiConfigController implements Initializable {
@FXML
private RadioButton radioOvervoteExhaustIfMultiple;
@FXML
private RadioButton radioOvervoteCountWhenSingle;
@FXML
private ChoiceBox<WinnerElectionMode> choiceWinnerElectionMode;
@FXML
private TextField textFieldRandomSeed;
Expand Down Expand Up @@ -358,6 +360,8 @@ private String getOvervoteRuleChoice() {
rule = OvervoteRule.EXHAUST_IMMEDIATELY;
} else if (radioOvervoteExhaustIfMultiple.isSelected()) {
rule = OvervoteRule.EXHAUST_IF_MULTIPLE_CONTINUING;
} else if (radioOvervoteCountWhenSingle.isSelected()) {
rule = OvervoteRule.COUNT_WHEN_SINGLE_CONTINUING;
}
return rule.getInternalLabel();
}
Expand Down Expand Up @@ -1050,6 +1054,7 @@ private void clearConfig() {
radioOvervoteAlwaysSkip.setSelected(false);
radioOvervoteExhaustImmediately.setSelected(false);
radioOvervoteExhaustIfMultiple.setSelected(false);
radioOvervoteCountWhenSingle.setSelected(false);
textFieldMaxSkippedRanksAllowed.clear();
textFieldMaxSkippedRanksAllowed.setDisable(false);
checkBoxMaxSkippedRanksAllowedUnlimited.setSelected(false);
Expand Down Expand Up @@ -1468,6 +1473,7 @@ public LocalDate fromString(String string) {
radioOvervoteAlwaysSkip.setText(Tabulator.OVERVOTE_RULE_ALWAYS_SKIP_TEXT);
radioOvervoteExhaustImmediately.setText(Tabulator.OVERVOTE_RULE_EXHAUST_IMMEDIATELY_TEXT);
radioOvervoteExhaustIfMultiple.setText(Tabulator.OVERVOTE_RULE_EXHAUST_IF_MULTIPLE_TEXT);
radioOvervoteCountWhenSingle.setText(Tabulator.OVERVOTE_RULE_COUNT_WHEN_SINGLE_TEXT);
checkBoxMaxSkippedRanksAllowedUnlimited.setOnAction(event -> {
textFieldMaxSkippedRanksAllowed.clear();
textFieldMaxSkippedRanksAllowed.setDisable(
Expand Down Expand Up @@ -1625,6 +1631,7 @@ private void setOvervoteRuleRadioButton(OvervoteRule overvoteRule) {
case ALWAYS_SKIP_TO_NEXT_RANK -> radioOvervoteAlwaysSkip.setSelected(true);
case EXHAUST_IMMEDIATELY -> radioOvervoteExhaustImmediately.setSelected(true);
case EXHAUST_IF_MULTIPLE_CONTINUING -> radioOvervoteExhaustIfMultiple.setSelected(true);
case COUNT_WHEN_SINGLE_CONTINUING -> radioOvervoteCountWhenSingle.setSelected(true);
case RULE_UNKNOWN -> {
// Do nothing for unknown overvote rules
}
Expand Down
Loading