Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Have dominion autoload use both names and codes (alt) #908

Open
wants to merge 9 commits into
base: develop
Choose a base branch
from
29 changes: 20 additions & 9 deletions src/main/java/network/brightspots/rcv/BaseCvrReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javafx.util.Pair;
import network.brightspots.rcv.RawContestConfig.Candidate;
import network.brightspots.rcv.RawContestConfig.CvrSource;

abstract class BaseCvrReader {
Expand Down Expand Up @@ -63,19 +66,19 @@ public void runAdditionalValidations(List<CastVoteRecord> castVoteRecords)
}

// Some CVRs have a list of candidates in the file. Read that list and return it.
// This will be used in tandem with gatherUnknownCandidates, which only looks for candidates
// This will be used in tandem with gatherUnknownCandidateCounts, which only looks for candidates
// that have at least one vote.
public List<String> readCandidateListFromCvr(List<CastVoteRecord> castVoteRecords)
public List<String> readCandidateListFromCvr()
throws IOException {
return new ArrayList<>();
}

// Gather candidate names from the CVR that are not in the config.
Map<String, Integer> gatherUnknownCandidates(
public Map<Candidate, Integer> gatherUnknownCandidateCounts(
List<CastVoteRecord> castVoteRecords, boolean includeCandidatesWithZeroVotes) {
// First pass: gather all unrecognized candidates and their counts
// All CVR Readers have this implemented
Map<String, Integer> unrecognizedCandidateCounts = new HashMap<>();
Map<String, Integer> unrecognizedNameCounts = new HashMap<>();
for (CastVoteRecord cvr : castVoteRecords) {
for (Pair<Integer, CandidatesAtRanking> ranking : cvr.candidateRankings) {
for (String candidateName : ranking.getValue()) {
Expand All @@ -85,7 +88,7 @@ Map<String, Integer> gatherUnknownCandidates(
continue;
}

unrecognizedCandidateCounts.merge(candidateName, 1, Integer::sum);
unrecognizedNameCounts.merge(candidateName, 1, Integer::sum);
}
}
}
Expand All @@ -97,7 +100,7 @@ Map<String, Integer> gatherUnknownCandidates(
// during auto-load candidates and just use readCandidateListFromCvr.
List<String> allCandidates = new ArrayList<>();
try {
allCandidates = readCandidateListFromCvr(castVoteRecords);
allCandidates = readCandidateListFromCvr();
} catch (IOException e) {
// If we can't read the candidate list, we can't check for unrecognized candidates.
Logger.warning("IOException reading candidate list from CVR: %s", e.getMessage());
Expand All @@ -109,14 +112,22 @@ Map<String, Integer> gatherUnknownCandidates(

// Combine the lists
for (String candidateName : allCandidates) {
if (!unrecognizedCandidateCounts.containsKey(candidateName)
if (!unrecognizedNameCounts.containsKey(candidateName)
&& config.getNameForCandidate(candidateName) == null) {
unrecognizedCandidateCounts.put(candidateName, 0);
unrecognizedNameCounts.put(candidateName, 0);
}
}
}

return unrecognizedCandidateCounts;
// Change the map to use Candidate objects instead of names
return unrecognizedNameCounts.entrySet().stream()
.collect(Collectors.toMap(entry -> new Candidate(entry.getKey()), Map.Entry::getValue));
}

Set<Candidate> gatherUnknownCandidates(List<CastVoteRecord> castVoteRecords)
throws CastVoteRecord.CvrParseException, IOException {
readCastVoteRecords(castVoteRecords);
return gatherUnknownCandidateCounts(castVoteRecords, true).keySet();
}

boolean usesLastAllowedRanking(List<Pair<Integer, String>> rankings, String contestId) {
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/network/brightspots/rcv/CsvCvrReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public String readerName() {
}

@Override
public List<String> readCandidateListFromCvr(List<CastVoteRecord> castVoteRecords)
public List<String> readCandidateListFromCvr()
throws IOException {
try (FileInputStream inputStream = new FileInputStream(Path.of(cvrPath).toFile())) {
return getCandidateNamesAndInitializeParser(getCsvParser(inputStream));
Expand Down
77 changes: 53 additions & 24 deletions src/main/java/network/brightspots/rcv/DominionCvrReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class DominionCvrReader extends BaseCvrReader {
private Map<Integer, String> precinctPortions;
// map of contest ID to Contest data
private Map<String, Contest> contests;
private List<Candidate> candidates;
private Map<String, Candidate> candidateCodesToCandidates;

DominionCvrReader(ContestConfig config, RawContestConfig.CvrSource source) {
super(config, source);
Expand Down Expand Up @@ -100,9 +100,9 @@ private static Map<Integer, String> getPrecinctData(String precinctPath) {
return precinctsById;
}

// returns list of Candidate objects parsed from CandidateManifest.json
private static List<Candidate> getCandidates(String candidatePath) {
ArrayList<Candidate> candidates = new ArrayList<>();
// returns a map of Codes to Candidate objects parsed from CandidateManifest.json
yezr marked this conversation as resolved.
Show resolved Hide resolved
private Map<String, Candidate> getCandidates(String candidatePath) {
Map<String, Candidate> candidateCodesToCandidates = new HashMap<>();
try {
HashMap json = JsonParser.readFromFile(candidatePath, HashMap.class);
ArrayList candidateList = (ArrayList) json.get("List");
Expand All @@ -112,14 +112,17 @@ private static List<Candidate> getCandidates(String candidatePath) {
Integer id = (Integer) candidateMap.get("Id");
String code = id.toString();
String contestId = candidateMap.get("ContestId").toString();
if (!source.getContestId().equals(contestId)) {
continue;
}
Candidate newCandidate = new Candidate(name, code, contestId);
candidates.add(newCandidate);
candidateCodesToCandidates.put(code, newCandidate);
}
} catch (Exception exception) {
Logger.severe("Error parsing candidate manifest:\n%s", exception);
candidates = null;
candidateCodesToCandidates = null;
}
return candidates;
return candidateCodesToCandidates;
}

@Override
Expand All @@ -131,6 +134,16 @@ public String readerName() {
// them to the input list
@Override
void readCastVoteRecords(List<CastVoteRecord> castVoteRecords) throws CvrParseException {
loadManifests();

gatherCvrsForContest(castVoteRecords, source.getContestId());
if (castVoteRecords.isEmpty()) {
Logger.severe("No cast vote record data found!");
throw new CvrParseException();
}
}

private void loadManifests() throws CvrParseException {
// read metadata files for precincts, precinct portions, contest, and candidates

// Precinct data does not exist for earlier versions of Dominion (only precinct portion)
Expand All @@ -157,17 +170,11 @@ void readCastVoteRecords(List<CastVoteRecord> castVoteRecords) throws CvrParseEx
throw new CvrParseException();
}
Path candidatePath = Paths.get(cvrPath, CANDIDATE_MANIFEST);
this.candidates = getCandidates(candidatePath.toString());
if (this.candidates == null) {
this.candidateCodesToCandidates = getCandidates(candidatePath.toString());
if (this.candidateCodesToCandidates == null) {
Logger.severe("No candidate data found!");
throw new CvrParseException();
}
// parse the cvr file(s)
gatherCvrsForContest(castVoteRecords, source.getContestId());
if (castVoteRecords.isEmpty()) {
Logger.severe("No cast vote record data found!");
throw new CvrParseException();
}
}

@Override
Expand All @@ -188,22 +195,22 @@ private void validateNamesAreInContest(List<CastVoteRecord> castVoteRecords)
throws CastVoteRecord.CvrParseException {
// build a lookup map to optimize CVR parsing
Map<String, Set<String>> contestIdToCandidateNames = new HashMap<>();
for (Candidate candidate : this.candidates) {
Set<String> candidates;
for (Candidate candidate : this.candidateCodesToCandidates.values()) {
Set<String> candidateNames;
if (contestIdToCandidateNames.containsKey(candidate.getContestId())) {
candidates = contestIdToCandidateNames.get(candidate.getContestId());
candidateNames = contestIdToCandidateNames.get(candidate.getContestId());
} else {
candidates = new HashSet<>();
candidateNames = new HashSet<>();
}
candidates.add(config.getNameForCandidate(candidate.getCode()));
contestIdToCandidateNames.put(candidate.getContestId(), candidates);
candidateNames.add(config.getNameForCandidate(candidate.getCode()));
contestIdToCandidateNames.put(candidate.getContestId(), candidateNames);
}

// Check each candidate exists in the contest
for (CastVoteRecord cvr : castVoteRecords) {
String contestId = cvr.getContestId();
Set<String> candidates = contestIdToCandidateNames.get(contestId);
if (candidates == null) {
Set<String> candidateNames = contestIdToCandidateNames.get(contestId);
if (candidateNames == null) {
Logger.severe("Contest ID '%s' had no candidates!", contestId);
throw new CastVoteRecord.CvrParseException();
}
Expand All @@ -216,7 +223,7 @@ private void validateNamesAreInContest(List<CastVoteRecord> castVoteRecords)
if (candidateId.equals(Tabulator.UNDECLARED_WRITE_IN_OUTPUT_LABEL)) {
continue;
}
if (!candidates.contains(candidateName)) {
if (!candidateNames.contains(candidateName)) {
Logger.severe(
"Candidate ID '%s' is not valid for contest '%s'!", candidateName, contestId);
throw new CastVoteRecord.CvrParseException();
Expand All @@ -226,6 +233,28 @@ private void validateNamesAreInContest(List<CastVoteRecord> castVoteRecords)
}
}

// The Candidate Autoload looks only at the CVR file(s) and not the manifest files, so
// it doesn't know about the mapping between a code and the candidate's name. This function
// addresses that discrepancy, while also being much faster than actually reading each ballot.
@Override
public Set<RawContestConfig.Candidate> gatherUnknownCandidates(
List<CastVoteRecord> castVoteRecords) {
try {
loadManifests();
} catch (CvrParseException exception) {
Logger.severe("Error loading manifest data:\n%s", exception);
return new HashSet<>();
}

Set<String> knownNames = config.getCandidateNames();

// Return the candidate codes that are not in the knownNames set
return candidateCodesToCandidates.entrySet().stream()
.filter(entry -> !knownNames.contains(entry.getValue().name))
.map(entry -> new RawContestConfig.Candidate(entry.getValue().name, entry.getKey()))
.collect(Collectors.toSet());
}

// parse the CVR file or files into a List of CastVoteRecords for tabulation
private void gatherCvrsForContest(List<CastVoteRecord> castVoteRecords, String contestIdToLoad) {
try {
Expand Down
14 changes: 6 additions & 8 deletions src/main/java/network/brightspots/rcv/GuiConfigController.java
Original file line number Diff line number Diff line change
Expand Up @@ -1720,16 +1720,15 @@ protected Void call() {
}
if (cvrsSpecified) {
// Gather unloaded names from each of the sources and place into the HashSet
Set<String> unloadedNames = new HashSet<>();
HashSet<Candidate> unloadedCandidates = new HashSet<>();
for (CvrSource source : sources) {
Provider provider = ContestConfig.getProvider(source);
try {
List<CastVoteRecord> castVoteRecords = new ArrayList<>();
BaseCvrReader reader = provider.constructReader(config, source);
reader.readCastVoteRecords(castVoteRecords);
Set<String> unknownCandidates = reader.gatherUnknownCandidates(
castVoteRecords, true).keySet();
unloadedNames.addAll(unknownCandidates);
Set<Candidate> unknownCandidates =
reader.gatherUnknownCandidates(castVoteRecords);
unloadedCandidates.addAll(unknownCandidates);
} catch (ContestConfig.UnrecognizedProviderException e) {
Logger.severe(
"Unrecognized provider \"%s\" in source file \"%s\": %s",
Expand All @@ -1743,15 +1742,14 @@ protected Void call() {

// Validate each name and add to the table of candidates
int successCount = 0;
for (String name : unloadedNames) {
Candidate candidate = new Candidate(name, null, false);
for (Candidate candidate : unloadedCandidates) {
Set<ValidationError> validationErrors =
ContestConfig.performBasicCandidateValidation(candidate);
if (validationErrors.isEmpty()) {
tableViewCandidates.getItems().add(candidate);
successCount++;
} else {
Logger.severe("Failed to load candidate \"%s\"!", name);
Logger.severe("Failed to load candidate \"%s\"!", candidate.getName());
}
}

Expand Down
8 changes: 8 additions & 0 deletions src/main/java/network/brightspots/rcv/RawContestConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,14 @@ public static class Candidate {

Candidate() {}

Candidate(String name) {
this(name, null, false);
}

Candidate(String name, String newlineSeparatedAliases) {
this(name, newlineSeparatedAliases, false);
}

Candidate(String name, String newlineSeparatedAliases, boolean excluded) {
this.name.setValue(name);
this.excluded.setValue(excluded);
Expand Down
9 changes: 5 additions & 4 deletions src/main/java/network/brightspots/rcv/TabulatorSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import network.brightspots.rcv.ContestConfig.UnrecognizedProviderException;
import network.brightspots.rcv.FileUtils.UnableToCreateDirectoryException;
import network.brightspots.rcv.OutputWriter.RoundSnapshotDataMissingException;
import network.brightspots.rcv.RawContestConfig.Candidate;
import network.brightspots.rcv.Tabulator.TabulationAbortedException;

@SuppressWarnings("RedundantSuppression")
Expand Down Expand Up @@ -357,8 +358,8 @@ private LoadedCvrData parseCastVoteRecords(
source, reader, sourceIndex, startIndex, castVoteRecords.size() - 1));

// Check for unrecognized candidates
Map<String, Integer> unrecognizedCandidateCounts =
reader.gatherUnknownCandidates(castVoteRecords, false);
Map<Candidate, Integer> unrecognizedCandidateCounts =
reader.gatherUnknownCandidateCounts(castVoteRecords, false);

if (!unrecognizedCandidateCounts.isEmpty()) {
throw new UnrecognizedCandidatesException(unrecognizedCandidateCounts);
Expand Down Expand Up @@ -440,9 +441,9 @@ private LoadedCvrData parseCastVoteRecords(
static class UnrecognizedCandidatesException extends Exception {

// count of how many times each unrecognized candidate was encountered during CVR parsing
final Map<String, Integer> candidateCounts;
final Map<Candidate, Integer> candidateCounts;

UnrecognizedCandidatesException(Map<String, Integer> candidateCounts) {
UnrecognizedCandidatesException(Map<Candidate, Integer> candidateCounts) {
this.candidateCounts = candidateCounts;
}
}
Expand Down
Loading