Skip to content

Commit

Permalink
Tiny STV parsing tests all passing.
Browse files Browse the repository at this point in the history
  • Loading branch information
vteague committed Jul 30, 2024
1 parent fab2f1c commit 6b91896
Show file tree
Hide file tree
Showing 5 changed files with 232 additions and 49 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@
*
* @author Daniel M. Zimmerman <[email protected]>
* @version 1.0.0
* Additions by Vanessa Teague for IRV and STV parsing.
* This parser can cope with 3 different kinds of contests, defined by their headers.
* - plurality single- and multi- winner contests, which are parsed directly and saved,
* - IRV (ranked single-winner) contests, which are parsed with some extra complexity for dealing with
* their special candidate headers (names and ranks), and saved,
* - STV (ranked multi-winner) contests, which by request are accepted but then dropped completely.
* These are ephemerally parsed into the Contest data structures, which are then used to parse each
* CVR, but no CountyContestResult is added to my_results, no Contest is persisted, and none of the
* choices are added into the CVRContestInfo structures attached to the CVR. This processing is
* intended to replicate the situation in which the STV contest had simply been omitted from the
* CSV file. However, parsing errors (invalid headers, missing headers, etc) will still cause an
* error message.
*/
@SuppressWarnings({"PMD.GodClass", "PMD.CyclomaticComplexity", "PMD.ExcessiveImports",
"PMD.ModifiedCyclomaticComplexity", "PMD.StdCyclomaticComplexity"})
Expand Down Expand Up @@ -165,6 +177,11 @@ public class DominionCVRExportParser {
*/
private static final String IRV_WINNERS_ALLOWED = "(Number of positions=";

/**
* Indicator (-1) to show that there are no votes allowed. This is used to identify STV contests.
*/
private static final int STV_NO_VOTES = -1;

/**
* The parser to be used.
*/
Expand Down Expand Up @@ -334,19 +351,26 @@ private void updateContestStructures(final CSVRecord the_line,
final int pluralityVotesAllowed = extractPositiveInteger(c, PLURALITY_VOTE_FOR, ")");
final int irvVotesAllowed = extractPositiveInteger(c, IRV_VOTE_FOR, ")");
final int irvWinners = extractPositiveInteger(c, IRV_WINNERS_ALLOWED, ",");

// If winners and allowed votes are as expected for plurality, this is a plurality contest.
if(pluralityVotesAllowed > 0 && irvVotesAllowed == -1 && irvWinners == -1) {
String contestName = c.substring(0, c.indexOf(PLURALITY_VOTE_FOR)).strip();
final boolean isPlurality = pluralityVotesAllowed > 0 && irvVotesAllowed == -1 && irvWinners == -1;
final boolean isIRV = pluralityVotesAllowed == -1 && irvVotesAllowed > 0 && irvWinners == 1;
// If it looks like IRV but has more than one winner, it's an STV contest.
final boolean isSTV = pluralityVotesAllowed == -1 && irvVotesAllowed > 0 && irvWinners > 1;

// Counterintuitively, we also code the STV contest as a 'plurality' contest, which works
// fine with candidate names of the form Alice(1), Alice(2), etc.
// STV_NO_VOTES = -1 in the_votes_allowed to encode that it's an STV contest.
if(isPlurality || isSTV) {
String contestName
= c.substring(0, c.indexOf(isPlurality ? PLURALITY_VOTE_FOR : IRV_VOTE_FOR)).strip();
the_names.add(contestName);
the_contest_types.put(contestName, ContestType.PLURALITY);
the_choice_counts.put(contestName, count);
the_votes_allowed.put(contestName, pluralityVotesAllowed);
the_votes_allowed.put(contestName, isPlurality ? pluralityVotesAllowed : STV_NO_VOTES);

// If winners and allowed votes are as expected for IRV, this is an IRV contest.
} else if(pluralityVotesAllowed == -1 && irvVotesAllowed > 0 && irvWinners == 1
// We expect the count to be the real number of choices times the number of ranks.
&& count % irvVotesAllowed == 0) {
// We expect the count to be the real number of choices times the number of ranks.
} else if(isIRV && count % irvVotesAllowed == 0) {
String contestName = c.substring(0, c.indexOf(IRV_WINNERS_ALLOWED)).strip();
the_names.add(contestName);
the_contest_types.put(contestName, ContestType.IRV);
Expand All @@ -355,9 +379,6 @@ private void updateContestStructures(final CSVRecord the_line,
the_choice_counts.put(contestName, count / irvVotesAllowed);
the_votes_allowed.put(contestName, irvVotesAllowed);

// TODO Clarify whether we should accept, though not audit, an STV contest, as below,
// See issue https://github.com/DemocracyDevelopers/colorado-rla/issues/107
// } else if (pluralityVotesAllowed == -1 && irvVotesAllowed > 0 && irvWinners > 1) {
} else {
// The header didn't have the keywords we expected.
final String msg = "Could not parse header: ";
Expand Down Expand Up @@ -450,6 +471,7 @@ private Result addContests(final CSVRecord choiceLine,
choice = new IRVPreference(choiceLine.get(index)).candidateName;
} else {
// Plurality - just use the candidate/choice name as is.
// Also for STV - it will just keep the names followed by rank.
choice = choiceLine.get(index).strip();
}
final String explanation = explanationLine.get(index).trim();
Expand All @@ -467,15 +489,16 @@ private Result addContests(final CSVRecord choiceLine,

// Winners allowed is always 1 for IRV, but is assumed to be equal to votesAllowed for
// plurality, because the Dominion format doesn't give us that separately.
// For STV, it doesn't matter because the contest will be dropped.
final int winnersAllowed = isIRV ? 1 : votesAllowed.get(contestName);

final Contest c = new Contest(contestName, my_county, contestTypes.get(contestName).toString(),
choices, votesAllowed.get(contestName), winnersAllowed, contest_count);

LOGGER.debug(String.format("[addContests: county=%s, contest=%s", my_county.name(), c));

// If we've just finished a plurality contest header, index is already at the right place
// for the next contest.
// If we've just finished a plurality or STV contest header, index is already at the right
// place for the next contest.
// If we've just finished the 1st-preference IRV choices, e.g. "Alice(1), Bob(1), Chuan(1)",
// index will now be pointed at the beginning of the IRV 2nd preferences, e.g.
// "Alice(2), Bob(2), Chuan(2)", so we have to advance it to the next contest. There is a
Expand All @@ -484,10 +507,13 @@ private Result addContests(final CSVRecord choiceLine,
index = index + (isIRV ? choices.size() * (votesAllowed.get(contestName) - 1) : 0);
contest_count = contest_count + 1;
try {
Persistence.saveOrUpdate(c);
final CountyContestResult r = CountyContestResultQueries.matching(my_county, c);
// Don't make a CountyContestResult for, and don't persist, an STV contest.
if(votesAllowed.get(contestName) != STV_NO_VOTES) {
Persistence.saveOrUpdate(c);
final CountyContestResult r = CountyContestResultQueries.matching(my_county, c);
my_results.add(r);
}
my_contests.add(c);
my_results.add(r);
} catch (PersistenceException pe) {
result.success = false;
result.errorMessage = StringUtils.abbreviate(DBExceptionUtil.getConstraintFailureReason(pe), 250);
Expand All @@ -505,34 +531,6 @@ private Result addContests(final CSVRecord choiceLine,
return result ;
}

/**
* Checks whether a contest choice is "Write-in" or various other spellings. Note this is not the
* same as _being_ a write-in candidate - we are looking for the fictitious candidate who
* indicates the beginning of the write-ins. If it is a plurality contest, we look for expected
* write-in strings such as "Write-in", "WRITEIN", "write_in", etc.
* If it is an IRV contest, we parse it as candidate_name(rank) and check whether candidate_name
* matches an expected write-in string.
* @param choice the candidate name (possibly with a rank in parentheses, if IRV).
* @param contestType PLURALITY or IRV.
* @return true if it matches some variation on "Write-in", allowing '-', '_', empty space or
* whitespace between (any capitalization of) "Write" and any capitalization of "in".
* @throws IRVParsingException if the contestType is IRV but the choice cannot be parsed as
* name(rank).
*/
private boolean nameIsWriteIn(String choice, ContestType contestType) throws IRVParsingException {
// The upper case matches "WRITE" [something] "IN", where the something can be 0 or more of
// '-', '_', space or tab.
final String writeInRegexp = "WRITE[-_ \t]*IN";

// If it's IRV, ignore the rank in parentheses and just check the name.
if(contestType == ContestType.IRV) {
return (new IRVPreference(choice).candidateName.toUpperCase()).matches(writeInRegexp);
} else {
// If it's plurality, just match the name directly.
return choice.toUpperCase().matches(writeInRegexp);
}
}

/**
* Checks to see if the set of parsed CVRs needs flushing, and does so
* if necessary.
Expand Down Expand Up @@ -682,7 +680,9 @@ private CastVoteRecord extractCVR(final CSVRecord the_line) {
irvInterpretation.logMessage(CVR_NUMBER_HEADER, IMPRINTED_ID_HEADER)));
}
contest_info.add(new CVRContestInfo(co, null, null, orderedChoices));
} else {

} else if(co.votesAllowed() != STV_NO_VOTES) {
// Don't store an STV contest (indicated by STV_NO_VOTES in votesAllowed).
// Store plurality vote.
contest_info.add(new CVRContestInfo(co, null, null, votes));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
import java.util.stream.Collectors;

import org.testng.annotations.*;
import us.freeandfair.corla.query.ContestQueries;
import us.freeandfair.corla.query.ExportQueries;

import javax.transaction.Transactional;
Expand All @@ -70,6 +71,7 @@
* in RankedBallotInterpretationReportTests, but is included here so the Boulder data only has to
* be loaded once.)
* - an examples to test the broader class of Write In strings.
* - some basic small test cases with STV votes, to ensure that STV contests are properly dropped.
*/
public class DominionCVRExportParserTests extends TestClassWithDatabase {

Expand All @@ -93,6 +95,14 @@ public class DominionCVRExportParserTests extends TestClassWithDatabase {
*/
private static final Properties blank = new Properties();

/**
* Some expected votes.
*/
private final List<String> ABC = List.of("Alice","Bob","Chuan");
private final List<String> ACB = List.of("Alice","Chuan","Bob");
private final List<String> BAC = List.of("Bob","Alice","Chuan");
private final List<String> CAB = List.of("Chuan","Alice","Bob");

@BeforeClass
public static void beforeAll() {
postgres.start();
Expand All @@ -119,11 +129,6 @@ public void parseThreeCandidatesTenVotesSucceeds() throws IOException {
final Path path = Paths.get(TINY_CSV_PATH + "ThreeCandidatesTenVotes.csv");
final Reader reader = Files.newBufferedReader(path);

final List<String> ABC = List.of("Alice","Bob","Chuan");
final List<String> ACB = List.of("Alice","Chuan","Bob");
final List<String> BAC = List.of("Bob","Alice","Chuan");
final List<String> CAB = List.of("Chuan","Alice","Bob");

final DominionCVRExportParser parser = new DominionCVRExportParser(reader,
fromString("Saguache"), blank, true);
assertTrue(parser.parse().success);
Expand Down Expand Up @@ -611,4 +616,140 @@ public void parseWriteIns() throws IOException {
assertTrue(contest.choices().get(2).qualifiedWriteIn());
}
}

/**
* Simple test of successful parsing of a tiny IRV test example, with IRV, STV, and plurality,
* in that order.
* Tests that all the metadata and some of the votes are correct.
* In particular, the STV contest should be completely dropped.
* @throws IOException never.
*/
@Test
@Transactional
public void parseThreeCandidatesTenVotesPlusSTVPlusPluralitySucceedsAndDropsSTV() throws IOException {
testUtils.log(LOGGER, "parseThreeCandidatesTenVotesPlusSTVPlusPluralitySucceedsAndDropsSTV");
final Path path = Paths.get(TINY_CSV_PATH + "ThreeCandidatesTenVotesPlusSTVPlusPlurality.csv");

doIRVAndSTVAndPluralityTest(path, "Cheyenne");
}

/**
* Second test for successful parsing of mixed IRV, plurality, STV contests. The STV contest is
* at the end.
* @throws IOException never.
*/
@Test
@Transactional
public void parseThreeCandidatesTenVotesPlusPluralityPlusSTVSucceedsAndDropsSTV() throws IOException {
testUtils.log(LOGGER, "parseThreeCandidatesTenVotesPlusPluralityPlusSTVSucceedsAndDropsSTV");
final Path path = Paths.get(TINY_CSV_PATH + "ThreeCandidatesTenVotesPlusPluralityPlusSTV.csv");

doIRVAndSTVAndPluralityTest(path, "Yuma");
}

/**
* Third test for successful parsing of mixed IRV, plurality, STV contests. The STV contest is
* at the beginning.
* @throws IOException never.
*/
@Test
@Transactional
public void parseSTVPlusThreeCandidatesTenVotesPlusPluralitySucceedsAndDropsSTV() throws IOException {
testUtils.log(LOGGER, "parseSTVPlusThreeCandidatesTenVotesPlusPluralitySucceedsAndDropsSTV");
final Path path = Paths.get(TINY_CSV_PATH + "STVPlusThreeCandidatesTenVotesPlusPlurality.csv");

doIRVAndSTVAndPluralityTest(path, "La Plata");
}

/**
* The actual work function for the three files with mixed IRV, STV and plurality. Although the
* position of the STV contest varies in these three files, the other data is the same, so the
* parsed result should be the same.
* @param path The path to the CSV file.
* @param countyName The name of the county.
* @throws IOException
*/
void doIRVAndSTVAndPluralityTest(Path path, String countyName) throws IOException {
final Reader reader = Files.newBufferedReader(path);

County cheyenne = fromString(countyName);
final DominionCVRExportParser parser = new DominionCVRExportParser(reader, cheyenne, blank, true);
assertTrue(parser.parse().success);

// There should be three contests, an IRV one and two plurality ones, because the STV one was
// dropped.
final List<Contest> contests = forCounties(Set.of(fromString(countyName)));
assertEquals(contests.size(), 3);
final Contest IRVContest = contests.get(0);
final Contest pluralityContest = contests.get(1);
final Contest pluralityContest2 = contests.get(2);

// Check basic data
assertEquals(IRVContest.name(), "TinyExample1");
assertEquals(IRVContest.description(), ContestType.IRV.toString());
assertEquals(IRVContest.choices().stream().map(Choice::name).collect(Collectors.toList()), ABC);

// Votes allowed should be 3 (because there are 3 ranks), whereas winners=1 always for IRV.
assertEquals(IRVContest.votesAllowed().intValue(), 3);
assertEquals(IRVContest.winnersAllowed().intValue(), 1);

// There are 10 votes:
// 3 Alice, Bob, Chuan
// 3 Alice, Chuan, Bob
// 1 Bob, Alice, Chuan
// 3 Chuan, Alice, Bob
final List<List<String>> expectedChoices = List.of(
ABC,ABC,ABC,
ACB,ACB,ACB,
BAC,
CAB, CAB, CAB);

final List<CVRContestInfo> cvrs = getMatching(fromString(countyName).id(),
CastVoteRecord.RecordType.UPLOADED).map(cvr -> cvr.contestInfoForContest(IRVContest)).toList();
assertEquals(10, cvrs.size());
for(int i=0 ; i < expectedChoices.size() ; i++) {
assertEquals(cvrs.get(i).choices(), expectedChoices.get(i));
}

// Check basic data for the plurality Example1 contest
assertEquals(pluralityContest.name(), "PluralityExample1");
assertEquals(pluralityContest.description(), ContestType.PLURALITY.toString());
assertEquals(pluralityContest.choices().stream().map(Choice::name)
.collect(Collectors.toList()), List.of("Diego", "Eli", "Farhad"));

// Votes allowed should be 1. For plurality, votes allowed = winners allowed.
assertEquals(pluralityContest.votesAllowed().intValue(), 1);
assertEquals(pluralityContest.winnersAllowed().intValue(), 1);

// There are 10 votes:
// 1 Diego,
// 3 Farhad, Farhad, Farhad,
// 3 Diego, Diego, Diego,
// 3 Eli, Eli, Eli
final List<List<String>> expectedPluralityChoices = List.of(
List.of("Diego"),
List.of("Farhad"), List.of("Farhad"), List.of("Farhad"),
List.of("Diego"), List.of("Diego"), List.of("Diego"),
List.of("Eli"), List.of("Eli"), List.of("Eli"));

final List<CVRContestInfo> pluralityCvrs = getMatching(fromString(countyName).id(),
CastVoteRecord.RecordType.UPLOADED).map(cvr -> cvr.contestInfoForContest(pluralityContest)).toList();
assertEquals(10, pluralityCvrs.size());
for(int i=0 ; i < expectedPluralityChoices.size() ; i++) {
assertEquals(cvrs.get(i).choices(), expectedChoices.get(i));
}

// Check basic data for the plurality Example2 contest
assertEquals(pluralityContest2.name(), "PluralityExample2");
assertEquals(pluralityContest2.description(), ContestType.PLURALITY.toString());
assertEquals(pluralityContest2.choices().stream().map(Choice::name).collect(Collectors.toList()),
List.of("Gertrude", "Ho", "Imogen"));

// Votes allowed should be 2
assertEquals(pluralityContest2.votesAllowed().intValue(), 2);
assertEquals(pluralityContest2.winnersAllowed().intValue(), 2);

// Just for good measure, check that there's no contest called "STVExample1" in the database.
assertTrue(ContestQueries.forCounty(cheyenne).stream().noneMatch(c -> c.name().equals("STVExample1")));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Tiny IRV Example Contest 1,5.10.11.24,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
,,,,,,,"STVExample1 (Number of positions=2, Number of ranks=3)","STVExample1 (Number of positions=2, Number of ranks=3)","STVExample1 (Number of positions=2, Number of ranks=3)","STVExample1 (Number of positions=2, Number of ranks=3)","STVExample1 (Number of positions=2, Number of ranks=3)","STVExample1 (Number of positions=2, Number of ranks=3)","STVExample1 (Number of positions=2, Number of ranks=3)","STVExample1 (Number of positions=2, Number of ranks=3)","STVExample1 (Number of positions=2, Number of ranks=3)","TinyExample1 (Number of positions=1, Number of ranks=3)","TinyExample1 (Number of positions=1, Number of ranks=3)","TinyExample1 (Number of positions=1, Number of ranks=3)","TinyExample1 (Number of positions=1, Number of ranks=3)","TinyExample1 (Number of positions=1, Number of ranks=3)","TinyExample1 (Number of positions=1, Number of ranks=3)","TinyExample1 (Number of positions=1, Number of ranks=3)","TinyExample1 (Number of positions=1, Number of ranks=3)","TinyExample1 (Number of positions=1, Number of ranks=3)",PluralityExample1 (Vote For=1),PluralityExample1 (Vote For=1),PluralityExample1 (Vote For=1),PluralityExample2 (Vote For=2),PluralityExample2 (Vote For=2),PluralityExample2 (Vote For=2)
,,,,,,,Jaya(1),Kun(1),Lou(1),Jaya(2),Kun(2),Lou(2),Jaya(3),Kun(3),Lou(3),Alice(1),Bob(1),Chuan(1),Alice(2),Bob(2),Chuan(2),Alice(3),Bob(3),Chuan(3),Diego,Eli,Farhad,Gertrude,Ho,Imogen
CvrNumber,TabulatorNum,BatchId,RecordId,ImprintedId,PrecinctPortion,BallotType,,,,,,,,,,,,,,,,,,,,,,,,
1,1,1,1,1-1-1,Precinct 1,Ballot 1 - Type 1,1,0,0,0,1,0,0,0,1,1,0,0,0,1,0,0,0,1,1,0,0,1,0,1
2,1,1,2,1-1-2,Precinct 1,Ballot 1 - Type 1,1,0,0,0,1,0,0,0,1,1,0,0,0,1,0,0,0,1,0,0,1,0,0,1
3,1,1,3,1-1-3,Precinct 1,Ballot 1 - Type 1,1,0,0,0,1,0,0,0,1,1,0,0,0,1,0,0,0,1,0,0,1,1,1,0
4,1,1,4,1-1-4,Precinct 1,Ballot 1 - Type 1,1,0,0,0,0,1,0,1,0,1,0,0,0,0,1,0,1,0,0,0,1,1,1,0
5,1,1,5,1-1-5,Precinct 1,Ballot 1 - Type 1,1,0,0,0,0,1,0,1,0,1,0,0,0,0,1,0,1,0,1,0,0,1,1,0
6,1,1,6,1-1-6,Precinct 1,Ballot 1 - Type 1,1,0,0,0,0,1,0,1,0,1,0,0,0,0,1,0,1,0,1,0,0,1,1,0
7,1,1,7,1-1-7,Precinct 1,Ballot 1 - Type 1,0,1,0,1,0,0,0,0,1,0,1,0,1,0,0,0,0,1,1,0,0,1,1,0
8,1,1,8,1-1-8,Precinct 1,Ballot 1 - Type 1,0,0,1,1,0,0,0,1,0,0,0,1,1,0,0,0,1,0,0,1,0,1,1,0
9,1,1,9,1-1-9,Precinct 1,Ballot 1 - Type 1,0,0,1,1,0,0,0,1,0,0,0,1,1,0,0,0,1,0,0,1,0,1,1,0
10,1,1,10,1-1-10,Precinct 1,Ballot 1 - Type 1,0,0,1,1,0,0,0,1,0,0,0,1,1,0,0,0,1,0,0,1,0,1,1,0
Loading

0 comments on commit 6b91896

Please sign in to comment.