Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/develop' into feature/issue-679_…
Browse files Browse the repository at this point in the history
…confirm-num-ballots
  • Loading branch information
artoonie committed Jun 10, 2024
2 parents ffdf589 + d287d28 commit 89c370e
Show file tree
Hide file tree
Showing 14 changed files with 194 additions and 92 deletions.
15 changes: 10 additions & 5 deletions src/main/java/network/brightspots/rcv/BaseCvrReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,23 @@ abstract void readCastVoteRecords(List<CastVoteRecord> castVoteRecords)
throws CastVoteRecord.CvrParseException, IOException;

// Individual contests may have a different value than what the config allows.
public boolean isRankingAllowed(int rank, String contestId) {
protected boolean isRankingAllowed(int rank, String contestId) {
return config.isRankingAllowed(rank);
}

// Any reader-specific validations can override this function.
public void runAdditionalValidations(List<CastVoteRecord> castVoteRecords)
throws CastVoteRecord.CvrParseException {
for (CastVoteRecord cvr : castVoteRecords) {
for (Pair<Integer, CandidatesAtRanking> ranking : cvr.candidateRankings) {
if (!this.config.isRankingAllowed(ranking.getKey())) {
throw new CastVoteRecord.CvrParseException();
}
if (cvr.candidateRankings.numRankings() == 0) {
continue;
}
int maxRanking = cvr.candidateRankings.maxRankingNumber();
if (!isRankingAllowed(maxRanking, cvr.getContestId())) {
Logger.severe(
"CVR \"%s\" has a ranking %d, but contest \"%s\" has max ranking %s!",
cvr.getId(), maxRanking, cvr.getContestId(), config.getMaxRankingsAllowedAsString());
throw new CastVoteRecord.CvrParseException();
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ CandidatesAtRanking get(int i) {
}

int maxRankingNumber() {
if (numRankings == 0) {
throw new IllegalArgumentException("Max ranking may only be called on non-empty rankings!");
}
return this.rankings.length;
}

Expand Down
6 changes: 4 additions & 2 deletions src/main/java/network/brightspots/rcv/DominionCvrReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,10 @@ public void runAdditionalValidations(List<CastVoteRecord> castVoteRecords)
}

@Override
public boolean isRankingAllowed(int rank, String contestId) {
return rank > 0 && rank <= contests.get(contestId).getMaxRanks();
protected boolean isRankingAllowed(int rank, String contestId) {
return rank > 0
&& rank <= contests.get(contestId).getMaxRanks()
&& config.isRankingAllowed(rank);
}

private void validateNamesAreInContest(List<CastVoteRecord> castVoteRecords)
Expand Down
29 changes: 21 additions & 8 deletions src/main/java/network/brightspots/rcv/GuiConfigController.java
Original file line number Diff line number Diff line change
Expand Up @@ -691,17 +691,17 @@ public void buttonCvrFilePathClicked() {
Provider provider = getProviderChoice(choiceCvrProvider);
switch (provider) {
case CDF -> selectedFiles =
chooseFile(provider, new ExtensionFilter("JSON and XML files", "*.json", "*.xml"));
chooseFile(provider, new ExtensionFilter("JSON or XML file(s)", "*.json", "*.xml"));
case CLEAR_BALLOT, CSV -> selectedFiles =
chooseFile(provider, new ExtensionFilter("CSV files", "*.csv"));
chooseFile(provider, new ExtensionFilter("CSV file(s)", "*.csv"));
case DOMINION, HART -> {
DirectoryChooser dc = new DirectoryChooser();
dc.setInitialDirectory(new File(FileUtils.getUserDirectory()));
dc.setTitle("Select " + provider + " Cast Vote Record Folder");
selectedDirectory = dc.showDialog(GuiContext.getInstance().getMainWindow());
}
case ESS -> selectedFiles =
chooseFile(provider, new ExtensionFilter("Excel files", "*.xls", "*.xlsx"));
chooseFile(provider, new ExtensionFilter("Excel file(s)", "*.xls", "*.xlsx"));
default -> {
// Do nothing for unhandled providers
}
Expand Down Expand Up @@ -1293,11 +1293,23 @@ public LocalDate fromString(String string) {
textFieldCvrOvervoteLabel.setText(ContestConfig.SUGGESTED_OVERVOTE_LABEL);
}
case PROVIDER_UNKNOWN -> {
// Do nothing
// do nothing
}
default -> throw new IllegalStateException(
"Unexpected value: " + getProviderChoice(choiceCvrProvider));
}
// Now set the "Select" button label
// These don't correspond exactly to the switch statement above, so
// do this in its own switch statement.
Provider choice = getProviderChoice(choiceCvrProvider);
switch (choice) {
case CDF -> buttonCvrFilePath.setText("Select JSON/XML file(s)");
case CLEAR_BALLOT, CSV -> buttonCvrFilePath.setText("Select CSV file(s)");
case DOMINION, HART -> buttonCvrFilePath.setText("Select a folder");
case ESS -> buttonCvrFilePath.setText("Select Excel file(s)");
case PROVIDER_UNKNOWN -> buttonCvrFilePath.setText("Select");
default -> throw new IllegalStateException("Unexpected value: " + choice);
}
});
EditableColumn[] cvrStringColumnsAndProperties = new EditableColumn[]{
new EditableColumnString(tableColumnCvrFilePath, "filePath"),
Expand Down Expand Up @@ -1887,11 +1899,12 @@ protected Task<LoadedCvrData> createTask() {
@Override
protected LoadedCvrData call() {
TabulatorSession session = new TabulatorSession(configPath);
LoadedCvrData cvrStatics = session.parseAndCountCastVoteRecords();
if (cvrStatics.successfullyReadAll) {
LoadedCvrData cvrStatics = null;
try {
cvrStatics = session.parseAndCountCastVoteRecords();
succeeded();
} else {
Logger.severe("Failed to read all CVRs!");
} catch (TabulatorSession.CastVoteRecordGenericParseException e) {
Logger.severe("Failed to read CVRs: %s", e.getMessage());
failed();
}
return cvrStatics;
Expand Down
5 changes: 0 additions & 5 deletions src/main/java/network/brightspots/rcv/ResultsWriter.java
Original file line number Diff line number Diff line change
Expand Up @@ -655,11 +655,6 @@ private void printRankings(
throws IOException {
// for each rank determine what candidate id, overvote, or undervote occurred and print it
for (int rank = 1; rank <= maxRanks; rank++) {
// If the configuration did not allow this ranking, we want to exclude it
// from the RCTab CVR -- even if it was present in the source CVRs.
if (!reader.isRankingAllowed(rank, source.getContestId())) {
break;
}
if (castVoteRecord.candidateRankings.hasRankingAt(rank)) {
CandidatesAtRanking candidates = castVoteRecord.candidateRankings.get(rank);
// We list all candidates at a given ranking on separate lines
Expand Down
21 changes: 13 additions & 8 deletions src/main/java/network/brightspots/rcv/StreamingCvrReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,16 @@ private void cvrCell(int col, String cellData) {
currentBatch = cellData;
} else if (idColumnIndex != null && col == idColumnIndex) {
currentSuppliedCvrId = cellData;
}
} else if (col >= firstVoteColumnIndex
&& (config.isMaxRankingsSetToMaximum()
|| col < firstVoteColumnIndex + config.getMaxRankingsAllowedWhenNotSetToMaximum())) {
// Unlike other CVRs, where having a ranking over the max number of rankings is an error,
// in these files it simply defines the "last" column used for rankings.
// If the max rankings is set to the maximum, we don't need to check the upper bound --
// we read all columns.
// Get the current ranking, and update the max ranking
int currentRank = col - firstVoteColumnIndex + 1;

// see if this column is in the ranking range
int currentRank = col - firstVoteColumnIndex + 1;
if (config.isRankingAllowed(currentRank)) {
// handle any empty cells which may exist between this cell and any previous one
handleEmptyCells(currentRank);
String cellString = cellData.trim();
Expand All @@ -253,15 +258,15 @@ private void cvrCell(int col, String cellData) {
if (!isNullOrBlank(overvoteDelimiter)) {
candidates = cellString.split(Pattern.quote(overvoteDelimiter));
} else {
candidates = new String[] {cellString};
candidates = new String[]{cellString};
}

for (String candidate : candidates) {
candidate = candidate.trim();
if (candidates.length > 1 && (candidate.equals("") || candidate.equals(skippedRankLabel))) {
if (candidates.length > 1 && (candidate.isBlank() || candidate.equals(skippedRankLabel))) {
Logger.severe(
"If a cell contains multiple candidates split by the overvote delimiter, it's not "
+ "valid for any of them to be blank or an explicit skipped ranking.");
"If a cell contains multiple candidates split by the overvote delimiter, "
+ "it's not valid for any of them to be blank or an explicit skipped ranking.");
encounteredDataErrors = true;
} else if (!candidate.equals(skippedRankLabel)) {
// map overvotes to our internal overvote string
Expand Down
103 changes: 52 additions & 51 deletions src/main/java/network/brightspots/rcv/TabulatorSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -106,24 +106,21 @@ boolean convertToCdf() {
try {
FileUtils.createOutputDirectory(config.getOutputDirectory());
LoadedCvrData castVoteRecords = parseCastVoteRecords(config);
if (!castVoteRecords.successfullyReadAll) {
Logger.severe("Aborting conversion due to cast vote record errors!");
} else {
Tabulator.SliceIdSet sliceIds =
new Tabulator(castVoteRecords.getCvrs(), config).getEnabledSliceIds();
ResultsWriter writer =
new ResultsWriter()
.setNumRounds(0)
.setSliceIds(sliceIds)
.setContestConfig(config)
.setTimestampString(timestampString);
writer.generateCdfJson(castVoteRecords.getCvrs());
conversionSuccess = true;
}
Tabulator.SliceIdSet sliceIds =
new Tabulator(castVoteRecords.getCvrs(), config).getEnabledSliceIds();
ResultsWriter writer =
new ResultsWriter()
.setNumRounds(0)
.setSliceIds(sliceIds)
.setContestConfig(config)
.setTimestampString(timestampString);
writer.generateCdfJson(castVoteRecords.getCvrs());
conversionSuccess = true;
} catch (IOException
| UnableToCreateDirectoryException
| TabulationAbortedException
| RoundSnapshotDataMissingException exception) {
| RoundSnapshotDataMissingException
| CastVoteRecordGenericParseException exception) {
Logger.severe("Failed to convert CVR(s) to CDF: %s", exception.getMessage());
}
}
Expand All @@ -140,7 +137,7 @@ boolean convertToCdf() {
return conversionSuccess;
}

LoadedCvrData parseAndCountCastVoteRecords() {
LoadedCvrData parseAndCountCastVoteRecords() throws CastVoteRecordGenericParseException {
ContestConfig config = ContestConfig.loadContestConfig(configPath);
return parseCastVoteRecords(config);
}
Expand Down Expand Up @@ -192,23 +189,17 @@ List<String> tabulate(String operatorName, LoadedCvrData expectedCvrData) {
Logger.info(
"Beginning tabulation for seat #%d...", config.getSequentialWinners().size() + 1);
// Read cast vote records and slice IDs from CVR files
LoadedCvrData castVoteRecords = parseCastVoteRecords(config);
if (config.getSequentialWinners().isEmpty()
&& !castVoteRecords.metadataMatches(expectedCvrData)) {
Logger.severe("CVR data has changed between loading the CVRs and reading them!");
exceptionsEncountered.add(TabulationAbortedException.class.toString());
break;
}
if (!castVoteRecords.successfullyReadAll) {
String errorMessage = "Aborting tabulation due to cast vote record errors!";
exceptionsEncountered.add(errorMessage);
Logger.severe(errorMessage);
break;
}
Set<String> newWinnerSet;
try {
LoadedCvrData castVoteRecords = parseCastVoteRecords(config);
if (config.getSequentialWinners().isEmpty()
&& !castVoteRecords.metadataMatches(expectedCvrData)) {
Logger.severe("CVR data has changed between loading the CVRs and reading them!");
exceptionsEncountered.add(TabulationAbortedException.class.toString());
break;
}
newWinnerSet = runTabulationForConfig(config, castVoteRecords.getCvrs());
} catch (TabulationAbortedException exception) {
} catch (TabulationAbortedException | CastVoteRecordGenericParseException exception) {
exceptionsEncountered.add(exception.getClass().toString());
Logger.severe(exception.getMessage());
break;
Expand All @@ -235,23 +226,22 @@ List<String> tabulate(String operatorName, LoadedCvrData expectedCvrData) {
tabulationSuccess = true;
} else {
// normal operation (not multi-pass IRV, a.k.a. sequential multi-seat)
// Read cast vote records and slice IDs from CVR files
LoadedCvrData castVoteRecords = parseCastVoteRecords(config);
if (!castVoteRecords.metadataMatches(expectedCvrData)) {
Logger.severe("CVR data has changed between loading the CVRs and reading them!");
exceptionsEncountered.add(TabulationAbortedException.class.toString());
} else if (!castVoteRecords.successfullyReadAll) {
String errorMessage = "Aborting tabulation due to cast vote record errors!";
exceptionsEncountered.add(errorMessage);
Logger.severe(errorMessage);
} else {
try {
// Read cast vote records and precinct IDs from CVR files
try {
LoadedCvrData castVoteRecords = parseCastVoteRecords(config);
if (!castVoteRecords.metadataMatches(expectedCvrData)) {
Logger.severe("CVR data has changed between loading the CVRs and reading them!");
exceptionsEncountered.add(TabulationAbortedException.class.toString());
} else {
runTabulationForConfig(config, castVoteRecords.getCvrs());
tabulationSuccess = true;
} catch (TabulationAbortedException exception) {
exceptionsEncountered.add(exception.getClass().toString());
Logger.severe(exception.getMessage());
}
} catch (CastVoteRecordGenericParseException exception) {
exceptionsEncountered.add(exception.getClass().toString());
Logger.severe("Aborting tabulation due to cast vote record errors!");
} catch (TabulationAbortedException exception) {
exceptionsEncountered.add(exception.getClass().toString());
Logger.severe(exception.getMessage());
}
}
Logger.info("Tabulation session completed.");
Expand All @@ -268,10 +258,10 @@ List<String> tabulate(String operatorName) {
}

Set<String> loadSliceNamesFromCvrs(ContestConfig.TabulateBySlice slice, ContestConfig config) {
List<CastVoteRecord> castVoteRecords = parseCastVoteRecords(config).getCvrs();
try {
List<CastVoteRecord> castVoteRecords = parseCastVoteRecords(config).getCvrs();
return new Tabulator(castVoteRecords, config).getEnabledSliceIds().get(slice);
} catch (TabulationAbortedException e) {
} catch (TabulationAbortedException | CastVoteRecordGenericParseException e) {
throw new RuntimeException(e);
}
}
Expand Down Expand Up @@ -312,7 +302,8 @@ private Set<String> runTabulationForConfig(
// parse CVR files referenced in the ContestConfig object into a list of CastVoteRecords
// param: config object containing CVR file paths to parse
// returns: list of parsed CVRs or null if an error was encountered
private LoadedCvrData parseCastVoteRecords(ContestConfig config) {
private LoadedCvrData parseCastVoteRecords(ContestConfig config)
throws CastVoteRecordGenericParseException {
Logger.info("Parsing cast vote records...");
List<CastVoteRecord> castVoteRecords = new ArrayList<>();
boolean encounteredSourceProblem = false;
Expand Down Expand Up @@ -403,6 +394,11 @@ private LoadedCvrData parseCastVoteRecords(ContestConfig config) {
} else {
Logger.info("Parsed %d cast vote records successfully.", castVoteRecords.size());
}

if (castVoteRecords == null) {
throw new CastVoteRecordGenericParseException();
}

return new LoadedCvrData(castVoteRecords);
}

Expand All @@ -416,6 +412,9 @@ static class UnrecognizedCandidatesException extends Exception {
}
}

static class CastVoteRecordGenericParseException extends Exception {
}

/**
* A summary of the cast vote records that have been read.
* Manages CVR in memory, so you can retain metadata about the loaded CVRs without
Expand All @@ -439,7 +438,9 @@ public LoadedCvrData(List<CastVoteRecord> cvrs) {
this.doesMatchAllMetadata = false;
}

/** This constructor will cause metadataMatches to always return true. */
/**
* This constructor will cause metadataMatches to always return true.
*/
private LoadedCvrData() {
this.cvrs = null;
this.successfullyReadAll = false;
Expand All @@ -457,8 +458,8 @@ private LoadedCvrData() {
*/
public boolean metadataMatches(LoadedCvrData other) {
return other.doesMatchAllMetadata
|| this.doesMatchAllMetadata
|| other.numCvrs() == this.numCvrs();
|| this.doesMatchAllMetadata
|| other.numCvrs() == this.numCvrs();
}

public int numCvrs() {
Expand All @@ -477,4 +478,4 @@ public List<CastVoteRecord> getCvrs() {
return cvrs;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,10 @@
<VBox spacing="4.0">
<HBox alignment="CENTER_LEFT" spacing="4.0">
<Label text="Path *"/>
<TextField prefHeight="25.0" prefWidth="290.0" fx:id="textFieldCvrFilePath"/>
<Button mnemonicParsing="false" onAction="#buttonCvrFilePathClicked"
text="Select" fx:id="buttonCvrFilePath"/>
<TextField prefHeight="25.0" prefWidth="179.0" fx:id="textFieldCvrFilePath"/>
<Button fx:id="buttonCvrFilePath" mnemonicParsing="false"
onAction="#buttonCvrFilePathClicked"
prefHeight="26.0" prefWidth="157.0" text="Select" />
<padding>
<Insets bottom="4.0" left="4.0" right="4.0" top="4.0"/>
</padding>
Expand Down
Loading

0 comments on commit 89c370e

Please sign in to comment.