Skip to content

Commit

Permalink
v0.1.2
Browse files Browse the repository at this point in the history
  • Loading branch information
HEdingfield authored Aug 20, 2019
2 parents 8874bbe + 5213dc0 commit 887aca2
Show file tree
Hide file tree
Showing 57 changed files with 349 additions and 1,040 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,21 @@ The Tabulator produces the following as output:

`$ chmod 777 gradlew`

#### 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](src/main/resources/network/brightspots/rcv/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.
Expand Down Expand Up @@ -94,6 +103,8 @@ Look in the console window to see where the output spreadsheet was written, e.g.

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 file name, the date and time of printing, who is doing the printing, and any other desired information.

## Acknowledgements

#### Bright Spots Developers
Expand Down
99 changes: 27 additions & 72 deletions src/main/java/network/brightspots/rcv/CastVoteRecord.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@
*/

/*
* Purpose:
* Internal representation of a single cast vote record including rankings ID and state (exhausted
* or not). Conceptually this is a ballot.
* Internal representation of a single cast vote record, including to whom it counts over the course
* of a tabulation (can be multiple candidates for a multi-winner election).
*/

package network.brightspots.rcv;
Expand All @@ -40,18 +39,18 @@
class CastVoteRecord {

// computed unique ID for this CVR (source file + line number)
private final String computedID;
private final String computedId;
// supplied unique ID for this CVR
private final String suppliedID;
private final String suppliedId;
// which precinct this ballot came from
private final String precinct;
// container for ALL CVR data parsed from the source CVR file
private final List<String> fullCVRData;
private final List<String> fullCvrData;
// records winners to whom some fraction of this vote has been allocated
private final Map<String, BigDecimal> winnerToFractionalValue = new HashMap<>();
// map of round to all candidates selected for that round
// a set is used to handle overvotes
SortedMap<Integer, Set<String>> rankToCandidateIDs;
SortedMap<Integer, Set<String>> rankToCandidateIds;
// whether this CVR is exhausted or not
private boolean isExhausted;
// tells us which candidate is currently receiving this CVR's vote (or fractional vote)
Expand All @@ -63,46 +62,34 @@ class CastVoteRecord {
// add a new entry.
private final Map<Integer, List<Pair<String, BigDecimal>>> cdfSnapshotData = new HashMap<>();

// function: CastVoteRecord
// purpose: create a new CVR object
// param: computedID is our computed unique ID for this CVR
// param: suppliedID is the (ostensibly unique) ID from the input data
// param: rankings list of rank->candidateID selections parsed for this CVR
// param: fullCVRData list of strings containing ALL data parsed for this CVR
CastVoteRecord(
String computedID,
String suppliedID,
String computedId,
String suppliedId,
String precinct,
List<String> fullCVRData,
List<String> fullCvrData,
List<Pair<Integer, String>> rankings) {
this.computedID = computedID;
this.suppliedID = suppliedID;
this.computedId = computedId;
this.suppliedId = suppliedId;
this.precinct = precinct;
this.fullCVRData = fullCVRData;
this.fullCvrData = fullCvrData;
sortRankings(rankings);
}

String getID() {
return suppliedID != null ? suppliedID : computedID;
String getId() {
return suppliedId != null ? suppliedId : computedId;
}

// function: logRoundOutcome
// purpose: logs the outcome for this CVR for this round for auditing purposes
// param: outcomeType indicates what happened
// param: detail reflects who received the vote OR why it was exhausted/ignored
// param: fractionalTransferValue if someone received the vote (not exhausted/ignored)
// logs the outcome for this CVR for this round for auditing purposes
void logRoundOutcome(
int round, VoteOutcomeType outcomeType, String detail, BigDecimal fractionalTransferValue) {

StringBuilder logStringBuilder = new StringBuilder();
// add round and ID
logStringBuilder.append("[Round] ").append(round).append(" [CVR] ");
if (!isNullOrBlank(suppliedID)) {
logStringBuilder.append(suppliedID);
if (!isNullOrBlank(suppliedId)) {
logStringBuilder.append(suppliedId);
} else {
logStringBuilder.append(computedID);
logStringBuilder.append(computedId);
}
// add outcome type
if (outcomeType == VoteOutcomeType.IGNORED) {
logStringBuilder.append(" [was ignored] ");
} else if (outcomeType == VoteOutcomeType.EXHAUSTED) {
Expand All @@ -114,30 +101,27 @@ void logRoundOutcome(
logStringBuilder.append(" [transferred to] ");
}
}
// add detail: either candidate ID or more explanation for other outcomes
logStringBuilder.append(detail);

// add fractional transfer value of the vote if it is fractional
// add vote value if not 1
if (fractionalTransferValue != null && !fractionalTransferValue.equals(BigDecimal.ONE)) {
logStringBuilder.append(" [value] ").append(fractionalTransferValue.toString());
}

// add complete data for round 1 only
if (round == 1) {
logStringBuilder.append(" [Raw Data] ");
logStringBuilder.append(fullCVRData);
logStringBuilder.append(fullCvrData);
}

// output with level FINE routes to audit log
Logger.log(Level.FINE, logStringBuilder.toString());
}

Map<Integer, List<Pair<String, BigDecimal>>> getCdfSnapshotData() {
return cdfSnapshotData;
}

// purpose: store info that we'll need in order to generate the CVR JSON snapshots in the Common
// Data Format at the end of the tabulation (if this option is enabled)
// store info needed to generate the CVR JSON snapshots in Common Data Format
void logCdfSnapshotData(int round) {
List<Pair<String, BigDecimal>> data = new LinkedList<>();
for (Entry<String, BigDecimal> entry : winnerToFractionalValue.entrySet()) {
Expand All @@ -150,39 +134,28 @@ void logCdfSnapshotData(int round) {
cdfSnapshotData.put(round, data);
}

// function: exhaust
// purpose: transition the CVR into exhausted state
void exhaust() {
assert !isExhausted;
isExhausted = true;
}

// function: isExhausted
// purpose: getter for exhausted state
// returns: true if CVR is exhausted otherwise false
boolean isExhausted() {
return isExhausted;
}

// function: getFractionalTransferValue
// purpose: getter for fractionalTransferValue
// the FTV for this cast vote record (by default the FTV is exactly one vote, but it
// could be less in a multi-winner contest if this CVR already helped elect a winner)
// returns: value of field
// fractional transfer value is one by default but can be less if this
// CVR already helped elect winner(s) (multi-winner contest only)
BigDecimal getFractionalTransferValue() {
// remainingValue starts at one, and we subtract all the parts that are already allocated
BigDecimal remainingValue = BigDecimal.ONE;
for (BigDecimal allocatedValue : winnerToFractionalValue.values()) {
remainingValue = remainingValue.subtract(allocatedValue);
}
return remainingValue;
}

// function: recordCurrentRecipientAsWinner
// purpose: calculate and store new vote value for current (newly elected) recipient
// calculate and store new vote value for current (newly elected) recipient
// param: surplusFraction fraction of this vote's current value which is now surplus and will
// be transferred
// param: config used for vote math
void recordCurrentRecipientAsWinner(BigDecimal surplusFraction, ContestConfig config) {
// Calculate transfer amount rounding DOWN to ensure we leave more of the vote with
// the winner. This avoids transferring more than intended which could leave the winner with
Expand All @@ -193,46 +166,28 @@ void recordCurrentRecipientAsWinner(BigDecimal surplusFraction, ContestConfig co
winnerToFractionalValue.put(getCurrentRecipientOfVote(), newAllocatedValue);
}

// function: getCurrentRecipientOfVote
// purpose: getter for currentRecipientOfVote
// returns: value of field
String getCurrentRecipientOfVote() {
return currentRecipientOfVote;
}

// function: setCurrentRecipientOfVote
// purpose: setter for currentRecipientOfVote
// param: new value of field
void setCurrentRecipientOfVote(String currentRecipientOfVote) {
this.currentRecipientOfVote = currentRecipientOfVote;
}

// function: getPrecinct
// purpose: getter for precinct
// returns: value of field
String getPrecinct() {
return precinct;
}

// function: getWinnerToFractionalValue
// purpose: getter for winnerToFractionalValue
// returns: value of field
Map<String, BigDecimal> getWinnerToFractionalValue() {
return winnerToFractionalValue;
}

// function: sortRankings
// purpose: create a map of ranking to candidates selected at that rank
// param: rankings list of rankings (rank, candidateID pairs) to be sorted
// create a sorted map of ranking to candidates selected at that rank
private void sortRankings(List<Pair<Integer, String>> rankings) {
rankToCandidateIDs = new TreeMap<>();
// index for iterating over all rankings
rankToCandidateIds = new TreeMap<>();
for (Pair<Integer, String> ranking : rankings) {
// set of candidates given this rank
Set<String> candidatesAtRank =
rankToCandidateIDs.computeIfAbsent(ranking.getKey(), k -> new HashSet<>());
// create the new optionsAtRank and add to the sorted CVR
// add this option into the map
rankToCandidateIds.computeIfAbsent(ranking.getKey(), k -> new HashSet<>());
candidatesAtRank.add(ranking.getValue());
}
}
Expand Down
69 changes: 26 additions & 43 deletions src/main/java/network/brightspots/rcv/CommonDataFormatReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,25 +32,18 @@

class CommonDataFormatReader {

// path of the source file
private final String filePath;
private final ContestConfig config;

// function: CommonDataFormatReader
// purpose: class constructor
// param: filePath source file to read
CommonDataFormatReader(String filePath, ContestConfig config) {
this.filePath = filePath;
this.config = config;
}

// function: getCandidates
// purpose: returns map from candidate ID to name parsed from CDF election json
// returns map from candidate ID to name parsed from CDF election json
Map<String, String> getCandidates() {
// container for results
Map<String, String> candidates = new HashMap<>();
try {
// try to read in the file
HashMap json = JsonParser.readFromFile(filePath, HashMap.class);
// top-level election is a list of election objects:
ArrayList electionArray = (ArrayList) json.get("Election");
Expand All @@ -66,8 +59,8 @@ Map<String, String> getCandidates() {
ArrayList contestSelectionArray = (ArrayList) contest.get("ContestSelection");
for (Object contestSelectionObject : contestSelectionArray) {
HashMap contestSelection = (HashMap) contestSelectionObject;
// selectionID is the candidate ID
String selectionID = (String) contestSelection.get("@id");
// selectionId is the candidate ID
String selectionId = (String) contestSelection.get("@id");
String selectionName = null;
ArrayList codeArray = (ArrayList) contestSelection.get("Code");
if (codeArray != null) {
Expand All @@ -79,7 +72,7 @@ Map<String, String> getCandidates() {
}
}
}
candidates.put(selectionID, selectionName != null ? selectionName : selectionID);
candidates.put(selectionId, selectionName != null ? selectionName : selectionId);
}
}
}
Expand All @@ -89,23 +82,21 @@ Map<String, String> getCandidates() {
return candidates;
}

// function: parseRankingsFromSnapshot
// purpose: parse a list of contest selection rankings from a NIST "Snapshot" HashMap
// parse a list of contest selection rankings from a NIST "Snapshot" HashMap
private List<Pair<Integer, String>> parseRankingsFromSnapshot(HashMap snapshot) {
// list to contain rankings as they are parsed
List<Pair<Integer, String>> rankings = new ArrayList<>();
// at the top level is a list of contests each of which contains selections
ArrayList CVRContests = (ArrayList) snapshot.get("CVRContest");
for (Object contestObject : CVRContests) {
HashMap CVRContest = (HashMap) contestObject;
ArrayList CvrContests = (ArrayList) snapshot.get("CVRContest");
for (Object contestObject : CvrContests) {
HashMap CvrContest = (HashMap) contestObject;
// each contest contains contestSelections
ArrayList contestSelections = (ArrayList) CVRContest.get("CVRContestSelection");
ArrayList contestSelections = (ArrayList) CvrContest.get("CVRContestSelection");
for (Object contestSelectionObject : contestSelections) {
HashMap contestSelection = (HashMap) contestSelectionObject;
// selectionID is the candidate/contest ID for this selection position
String selectionID = (String) contestSelection.get("ContestSelectionId");
if (selectionID.equals(config.getOvervoteLabel())) {
selectionID = Tabulator.EXPLICIT_OVERVOTE_LABEL;
// selectionId is the candidate/contest ID for this selection position
String selectionId = (String) contestSelection.get("ContestSelectionId");
if (selectionId.equals(config.getOvervoteLabel())) {
selectionId = Tabulator.EXPLICIT_OVERVOTE_LABEL;
}
// extract all the positions (ranks) which this selection has been assigned
ArrayList selectionPositions = (ArrayList) contestSelection.get("SelectionPosition");
Expand All @@ -115,49 +106,41 @@ private List<Pair<Integer, String>> parseRankingsFromSnapshot(HashMap snapshot)
// and finally the rank
Integer rank = (Integer) selectionPosition.get("Rank");
assert rank != null && rank >= 1;
// create a new ranking object and save it
rankings.add(new Pair<>(rank, selectionID));
rankings.add(new Pair<>(rank, selectionId));
}
}
}
return rankings;
}

// function: parseCVRFile
// purpose: parse the given file into a List of CastVoteRecords for tabulation
// param: castVoteRecords existing list to append new CastVoteRecords to
void parseCVRFile(List<CastVoteRecord> castVoteRecords) {
// parse the given file into a List of CastVoteRecords for tabulation
void parseCvrFile(List<CastVoteRecord> castVoteRecords) {
// cvrIndex and fileName are used to generate IDs for cvrs
int cvrIndex = 0;
String fileName = new File(filePath).getName();

try {
HashMap json = JsonParser.readFromFile(filePath, HashMap.class);
// we expect a top-level "CVR" object containing a list of CVR objects
ArrayList CVRs = (ArrayList) json.get("CVR");
ArrayList cvrs = (ArrayList) json.get("CVR");
// for each CVR object extract the current snapshot
// it will be used to build the ballot data
for (Object CVR : CVRs) {
HashMap CVRObject = (HashMap) CVR;
String currentSnapshotID = (String) CVRObject.get("CurrentSnapshotId");
// get ballotID
String ballotID = (String) CVRObject.get("BallotPrePrintedId");
// compute internal ballot ID
String computedCastVoteRecordID = String.format("%s(%d)", fileName, ++cvrIndex);

ArrayList CVRSnapshots = (ArrayList) CVRObject.get("CVRSnapshot");
for (Object snapshotObject : CVRSnapshots) {
for (Object cvr : cvrs) {
HashMap cvrObject = (HashMap) cvr;
String currentSnapshotId = (String) cvrObject.get("CurrentSnapshotId");
String ballotId = (String) cvrObject.get("BallotPrePrintedId");
String computedCastVoteRecordId = String.format("%s(%d)", fileName, ++cvrIndex);
ArrayList cvrSnapshots = (ArrayList) cvrObject.get("CVRSnapshot");
for (Object snapshotObject : cvrSnapshots) {
HashMap snapshot = (HashMap) snapshotObject;
if (!snapshot.get("@id").equals(currentSnapshotID)) {
if (!snapshot.get("@id").equals(currentSnapshotId)) {
continue;
}

// we found the current CVR snapshot so get rankings and create a new cvr
List<Pair<Integer, String>> rankings = parseRankingsFromSnapshot(snapshot);

// create new cast vote record
CastVoteRecord newRecord =
new CastVoteRecord(computedCastVoteRecordID, ballotID, null, null, rankings);
new CastVoteRecord(computedCastVoteRecordId, ballotId, null, null, rankings);
castVoteRecords.add(newRecord);

// provide some user feedback on the CVR count
Expand Down
Loading

0 comments on commit 887aca2

Please sign in to comment.