Skip to content

Commit

Permalink
Server endpoint for surfacing discrepancies (#2026)
Browse files Browse the repository at this point in the history
* Server endpoint for surfacing discrepacies

* PR feedback: update return model

* Tests for batch comparison discrepancies

* PR feedback: simplify dict init
  • Loading branch information
nikhilb4a authored Oct 31, 2024
1 parent a16c8d8 commit ef9ae00
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 2 deletions.
85 changes: 84 additions & 1 deletion server/api/jurisdictions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from collections import Counter
from collections import Counter, defaultdict
import logging
from typing import Dict, List, Optional, Mapping, cast as typing_cast
import enum
Expand Down Expand Up @@ -727,3 +727,86 @@ def get_discrepancy_counts_by_jurisdiction(election: Election):
for jurisdiction in election.jurisdictions
}
)


DiscrepanciesByJurisdiction = Dict[str, Dict[str, Dict[str, Dict[str, Dict[str, int]]]]]
# DiscrepanciesByJurisdiction = {
# jurisdictionID: {
# batchName: {
# contestID: {
# reportedVotes: {choiceID: int},
# auditedVotes: {choiceID: int},
# discrepancies: {choiceID: int},
# }
# }


@api.route("/election/<election_id>/discrepancy", methods=["GET"])
@restrict_access([UserType.AUDIT_ADMIN])
def get_discrepancies_by_jurisdiction(election: Election):
current_round = get_current_round(election)
if not current_round:
raise Conflict("Audit not started")

discrepancies_by_jurisdiction: DiscrepanciesByJurisdiction = {}

if election.audit_type == AuditType.BATCH_COMPARISON:
discrepancies_by_jurisdiction = (
get_batch_comparison_audit_discrepancies_by_jurisdiction(
election, current_round.id
)
)
else:
raise Conflict("Discrepancies are only implemented for batch comparison audits")

return jsonify(discrepancies_by_jurisdiction)


def get_batch_comparison_audit_discrepancies_by_jurisdiction(
election: Election, round_id: str
) -> DiscrepanciesByJurisdiction:
discrepancies_by_jurisdiction: DiscrepanciesByJurisdiction = defaultdict(
lambda: defaultdict(dict)
)

# TODO: Add support for combined batches
batch_keys_in_round = set(
SampledBatchDraw.query.filter_by(round_id=round_id)
.join(Batch)
.join(Jurisdiction)
.with_entities(Jurisdiction.name, Batch.name)
.all()
)
jurisdiction_name_to_id = dict(
Jurisdiction.query.filter_by(election_id=election.id).with_entities(
Jurisdiction.name, Jurisdiction.id
)
)

for contest in list(election.contests):
audited_batch_results = sampled_batch_results(
contest, include_non_rla_batches=True
)
reported_batch_results = batch_tallies(contest)

for batch_key, audited_batch_result in audited_batch_results.items():
if batch_key not in batch_keys_in_round:
continue

audited_contest_result = audited_batch_result[contest.id]
reported_contest_result = reported_batch_results[batch_key][contest.id]
vote_deltas = batch_vote_deltas(
reported_contest_result, audited_contest_result
)
if not vote_deltas:
continue

jurisdiction_name, batch_name = batch_key
jurisdiction_id = jurisdiction_name_to_id[jurisdiction_name]
discrepancies_by_jurisdiction[jurisdiction_id][batch_name][contest.id] = {
"reportedVotes": reported_contest_result,
"auditedVotes": audited_contest_result,
"discrepancies": vote_deltas,
}

return discrepancies_by_jurisdiction
35 changes: 35 additions & 0 deletions server/tests/api/test_jurisdictions.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,3 +429,38 @@ def test_discrepancy_counts_before_audit_launch(
}
]
}


def test_discrepancy_before_audit_launch(
client: FlaskClient,
election_id: str,
jurisdiction_ids: List[str], # pylint: disable=unused-argument
):
rv = client.get(f"/api/election/{election_id}/discrepancy")
assert rv.status_code == 409
assert json.loads(rv.data) == {
"errors": [
{
"errorType": "Conflict",
"message": "Audit not started",
}
]
}


def test_discrepancy_non_batch_comparison_enabled(
client: FlaskClient,
election_id: str,
jurisdiction_ids: List[str], # pylint: disable=unused-argument
round_1_id: str, # pylint: disable=unused-argument
):
rv = client.get(f"/api/election/{election_id}/discrepancy")
assert rv.status_code == 409
assert json.loads(rv.data) == {
"errors": [
{
"errorType": "Conflict",
"message": "Discrepancies are only implemented for batch comparison audits",
}
]
}
29 changes: 28 additions & 1 deletion server/tests/batch_comparison/test_batch_comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,28 @@ def test_batch_comparison_round_2(
# In J2, single sampled batch hasn't been audited yet. The frontend won't show this to the user.
assert discrepancy_counts[jurisdictions[1]["id"]] == 1

# Check discrepancies
rv = client.get(f"/api/election/{election_id}/discrepancy")
discrepancies = json.loads(rv.data)
assert (
discrepancies[jurisdictions[0]["id"]]["Batch 1"][contests[0]["id"]][
"reportedVotes"
][choice_ids[0]]
== 500
)
assert (
discrepancies[jurisdictions[0]["id"]]["Batch 1"][contests[0]["id"]][
"auditedVotes"
][choice_ids[0]]
== 400
)
assert (
discrepancies[jurisdictions[0]["id"]]["Batch 1"][contests[0]["id"]][
"discrepancies"
][choice_ids[0]]
== 100
)

# Now do the second jurisdiction
set_logged_in_user(
client, UserType.JURISDICTION_ADMIN, default_ja_email(election_id)
Expand Down Expand Up @@ -652,10 +674,15 @@ def test_batch_comparison_batches_sampled_multiple_times(
# Check discrepancy counts
rv = client.get(f"/api/election/{election_id}/discrepancy-counts")
discrepancy_counts = json.loads(rv.data)
print(discrepancy_counts)
assert discrepancy_counts[jurisdictions[0]["id"]] == 0
assert discrepancy_counts[jurisdictions[1]["id"]] == 0

# Check discrepancies
rv = client.get(f"/api/election/{election_id}/discrepancy")
discrepancies = json.loads(rv.data)
assert jurisdictions[0]["id"] not in discrepancies
assert jurisdictions[1]["id"] not in discrepancies

# End the round
rv = client.post(f"/api/election/{election_id}/round/current/finish")
assert_ok(rv)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,28 @@ def test_multi_contest_batch_comparison_end_to_end(
assert discrepancy_counts[jurisdictions[1]["id"]] == 0
assert discrepancy_counts[jurisdictions[2]["id"]] == 1

# Check discrepancies
rv = client.get(f"/api/election/{election_id}/discrepancy")
discrepancies = json.loads(rv.data)
assert (
discrepancies[jurisdictions[0]["id"]]["Batch 6"][contest_ids[0]][
"reportedVotes"
][contest_1_choice_ids[0]]
== 50
)
assert (
discrepancies[jurisdictions[0]["id"]]["Batch 6"][contest_ids[0]][
"auditedVotes"
][contest_1_choice_ids[0]]
== 49
)
assert (
discrepancies[jurisdictions[0]["id"]]["Batch 6"][contest_ids[0]][
"discrepancies"
][contest_1_choice_ids[0]]
== 1
)

#
# Finish audit
#
Expand Down Expand Up @@ -880,6 +902,31 @@ def test_multi_contest_batch_comparison_round_2(
assert discrepancy_counts[jurisdiction_ids[1]] == 0
assert discrepancy_counts[jurisdiction_ids[2]] == 0

# Check discrepancies
rv = client.get(f"/api/election/{election_id}/discrepancy")
discrepancies = json.loads(rv.data)

assert (
discrepancies[jurisdiction_ids[0]]["Batch 7"][contest_ids[0]]["reportedVotes"][
contest_1_choice_ids[0]
]
== 50
)
assert (
discrepancies[jurisdiction_ids[0]]["Batch 7"][contest_ids[0]]["auditedVotes"][
contest_1_choice_ids[0]
]
== 0
)
assert (
discrepancies[jurisdiction_ids[0]]["Batch 7"][contest_ids[0]]["discrepancies"][
contest_1_choice_ids[0]
]
== 50
)
assert jurisdiction_ids[1] not in discrepancies
assert jurisdiction_ids[2] not in discrepancies

#
# End round 1
#
Expand Down Expand Up @@ -1012,6 +1059,14 @@ def test_multi_contest_batch_comparison_round_2(
assert discrepancy_counts[jurisdiction_ids[1]] == 0
assert discrepancy_counts[jurisdiction_ids[2]] == 0

# Check discrepancies
rv = client.get(f"/api/election/{election_id}/discrepancy")
discrepancies = json.loads(rv.data)

assert jurisdiction_ids[0] not in discrepancies
assert jurisdiction_ids[1] not in discrepancies
assert jurisdiction_ids[2] not in discrepancies

#
# End round 2 / finish audit
#
Expand Down

0 comments on commit ef9ae00

Please sign in to comment.