Skip to content

Commit

Permalink
refactor(scan): centralize various ballot mark utilities (#3173)
Browse files Browse the repository at this point in the history
* feat(utils): add `marksToVotesDict`

This will be the shared implementation for converting marks to votes. Currently we have several different implementations of this.

* refactor(scan): centralize conversion of marks to votes

* refactor(scan): centralize `getMarkStatus`

* test(scan): update snapshots

* refactor(utils): allow omitting `marginal` threshold

Takes advantage of the type system to say that if you omit `marginal` then the result cannot be `MarkStatus.Marginal`.
  • Loading branch information
eventualbuddha committed Mar 21, 2023
1 parent dd54ef0 commit 932351e
Show file tree
Hide file tree
Showing 20 changed files with 365 additions and 221 deletions.
4 changes: 2 additions & 2 deletions apps/central-scan/backend/src/interpreter.test.ts
Expand Up @@ -962,7 +962,7 @@ test('interprets marks in ballots', async () => {
Object {
"id": "write-in-0",
"isWriteIn": true,
"name": "Write-In",
"name": "Write-In #1",
},
],
}
Expand Down Expand Up @@ -2028,7 +2028,7 @@ test('returns metadata if the QR code is readable but the HMPB ballot is not', a
Object {
"id": "write-in-2",
"isWriteIn": true,
"name": "Write-In",
"name": "Write-In #3",
},
],
},
Expand Down
31 changes: 1 addition & 30 deletions apps/central-scan/backend/src/types.ts
@@ -1,11 +1,4 @@
import {
BallotLocale,
BallotMark,
BallotTargetMark,
MarkStatus,
MarkThresholds,
PageInterpretation,
} from '@votingworks/types';
import { BallotLocale, PageInterpretation } from '@votingworks/types';
import { BallotStyleData } from '@votingworks/utils';

export interface PageInterpretationWithAdjudication<
Expand All @@ -25,25 +18,3 @@ export interface BallotConfig extends BallotStyleData {
locales: BallotLocale;
isLiveMode: boolean;
}

export function getMarkStatus(
mark: BallotTargetMark,
markThresholds: MarkThresholds
): MarkStatus {
if (mark.score >= markThresholds.definite) {
return MarkStatus.Marked;
}

if (mark.score >= markThresholds.marginal) {
return MarkStatus.Marginal;
}

return MarkStatus.Unmarked;
}

export function isMarginalMark(
mark: BallotMark,
markThresholds: MarkThresholds
): boolean {
return getMarkStatus(mark, markThresholds) === MarkStatus.Marginal;
}
6 changes: 3 additions & 3 deletions apps/central-scan/backend/src/util/option_mark_status.ts
Expand Up @@ -6,7 +6,7 @@ import {
MarkThresholds,
} from '@votingworks/types';
import { throwIllegalValue } from '@votingworks/basics';
import { getMarkStatus } from '../types';
import { getMarkStatus } from '@votingworks/utils';

/**
* state of the mark for a given contest and option
Expand All @@ -29,13 +29,13 @@ export function optionMarkStatus({
switch (mark.type) {
case 'candidate':
if (mark.optionId === optionId) {
return getMarkStatus(mark, markThresholds);
return getMarkStatus(mark.score, markThresholds);
}
break;

case 'yesno':
if (mark.optionId === optionId) {
return getMarkStatus(mark, markThresholds);
return getMarkStatus(mark.score, markThresholds);
}
break;

Expand Down
17 changes: 0 additions & 17 deletions apps/scan/backend/src/types.ts
@@ -1,8 +1,6 @@
import {
AdjudicationReasonInfo,
BallotTargetMark,
ElectionDefinition,
MarkStatus,
MarkThresholds,
PageInterpretation,
PollsState,
Expand Down Expand Up @@ -31,21 +29,6 @@ export interface BallotPageQrcode {
position: 'top' | 'bottom';
}

export function getMarkStatus(
mark: BallotTargetMark,
markThresholds: MarkThresholds
): MarkStatus {
if (mark.score >= markThresholds.definite) {
return MarkStatus.Marked;
}

if (mark.score >= markThresholds.marginal) {
return MarkStatus.Marginal;
}

return MarkStatus.Unmarked;
}

export type PrecinctScannerState =
| 'connecting'
| 'disconnected'
Expand Down
6 changes: 3 additions & 3 deletions apps/scan/backend/src/util/option_mark_status.ts
Expand Up @@ -6,7 +6,7 @@ import {
MarkThresholds,
} from '@votingworks/types';
import { throwIllegalValue } from '@votingworks/basics';
import { getMarkStatus } from '../types';
import { getMarkStatus } from '@votingworks/utils';

/**
* state of the mark for a given contest and option
Expand All @@ -30,13 +30,13 @@ export function optionMarkStatus({
switch (mark.type) {
case 'candidate':
if (mark.optionId === optionId) {
return getMarkStatus(mark, markThresholds);
return getMarkStatus(mark.score, markThresholds);
}
break;

case 'yesno':
if (mark.optionId === optionId) {
return getMarkStatus(mark, markThresholds);
return getMarkStatus(mark.score, markThresholds);
}
break;

Expand Down
2 changes: 1 addition & 1 deletion apps/scan/backend/src/vx_interpreter.test.ts
Expand Up @@ -377,7 +377,7 @@ test('interprets marks in ballots', async () => {
Object {
"id": "write-in-0",
"isWriteIn": true,
"name": "Write-In",
"name": "Write-In #1",
},
],
}
Expand Down
Expand Up @@ -16,12 +16,14 @@ import {
getContests,
InterpretedBmdPage,
InterpretedHmpbPage,
MarkStatus,
safeParseInt,
SheetOf,
VotesDict,
YesNoContest,
YesNoVote,
} from '@votingworks/types';
import { getMarkStatus } from '@votingworks/utils';

import {
BallotPageLayoutsLookup,
Expand Down Expand Up @@ -372,7 +374,9 @@ function buildOriginalSnapshot({
(Math.floor(mark.score * 100) / 100).toString(),
],
HasIndication:
mark.score >= definiteMarkThreshold
getMarkStatus(mark.score, {
definite: definiteMarkThreshold,
}) === MarkStatus.Marked
? CVR.IndicationStatus.Yes
: CVR.IndicationStatus.No,
},
Expand Down
2 changes: 1 addition & 1 deletion libs/ballot-interpreter-nh/src/cli/interpret/index.ts
@@ -1,4 +1,4 @@
import { Result, ok, err, find, iter } from '@votingworks/basics';
import { err, find, iter, ok, Result } from '@votingworks/basics';
import { writeImageData } from '@votingworks/image-utils';
import {
MarkThresholds,
Expand Down
Expand Up @@ -7,7 +7,7 @@ import {
MarkStatus,
MarkThresholds,
} from '@votingworks/types';
import { ballotAdjudicationReasons } from '@votingworks/utils';
import { ballotAdjudicationReasons, getMarkStatus } from '@votingworks/utils';
import { InterpretedOvalMark } from '../types';

/**
Expand Down Expand Up @@ -56,12 +56,14 @@ export function convertMarksToAdjudicationInfo({
let fallbackStatus = MarkStatus.Unmarked;

for (const mark of marks) {
if (mark.score >= markThresholds.definite) {
return MarkStatus.Marked;
const markStatus = getMarkStatus(mark.score, markThresholds);

if (markStatus === MarkStatus.Marked) {
return markStatus;
}

if (mark.score >= markThresholds.marginal) {
fallbackStatus = MarkStatus.Marginal;
if (markStatus === MarkStatus.Marginal) {
fallbackStatus = markStatus;
}
}

Expand Down
129 changes: 54 additions & 75 deletions libs/ballot-interpreter-nh/src/interpret/convert_marks_to_votes.ts
@@ -1,91 +1,70 @@
import { assert, find, iter, throwIllegalValue } from '@votingworks/basics';
import {
Candidate,
ContestOptionId,
BallotTargetMark,
Contests,
MarkThresholds,
Vote,
VotesDict,
} from '@votingworks/types';
import { assert, find, throwIllegalValue } from '@votingworks/basics';
import makeDebug from 'debug';
import { convertMarksToVotesDict } from '@votingworks/utils';
import { InterpretedOvalMark } from '../types';

const log = makeDebug('ballot-interpreter-nh:interpret');
function convertNewHampshireMarkToSharedMark(
contests: Contests,
mark: InterpretedOvalMark
): BallotTargetMark {
const contest = find(contests, (c) => c.id === mark.gridPosition.contestId);
if (contest.type === 'candidate') {
return {
type: 'candidate',
contestId: contest.id,
optionId:
mark.gridPosition.type === 'option'
? mark.gridPosition.optionId
: `write-in-${mark.gridPosition.writeInIndex}`,
score: mark.score,
bounds: mark.bounds,
scoredOffset: mark.scoredOffset,
target: {
inner: mark.bounds,
bounds: mark.bounds,
},
};
}

if (contest.type === 'yesno') {
assert(mark.gridPosition.type === 'option');
assert(
mark.gridPosition.optionId === 'yes' ||
mark.gridPosition.optionId === 'no'
);
return {
type: 'yesno',
contestId: contest.id,
optionId: mark.gridPosition.optionId,
score: mark.score,
bounds: mark.bounds,
scoredOffset: mark.scoredOffset,
target: {
inner: mark.bounds,
bounds: mark.bounds,
},
};
}

throwIllegalValue(contest, 'type');
}

/**
* Convert a series of oval marks into a list of candidate votes.
*/
export function convertMarksToVotes(
contests: Contests,
markThresholds: MarkThresholds,
ovalMarks: readonly InterpretedOvalMark[]
ovalMarks: Iterable<InterpretedOvalMark>
): VotesDict {
const votes: VotesDict = {};

for (const mark of ovalMarks) {
const { gridPosition } = mark;
const { contestId } = gridPosition;
const contest = find(contests, (c) => c.id === contestId);

let vote: Vote;
let optionId: ContestOptionId;

if (contest.type === 'candidate') {
const candidate: Candidate =
gridPosition.type === 'option'
? find(contest.candidates, (c) => c.id === gridPosition.optionId)
: {
id: `write-in-${gridPosition.writeInIndex}`,
name: `Write-In #${gridPosition.writeInIndex + 1}`,
isWriteIn: true,
};
vote = [candidate];
optionId = candidate.id;
} else if (contest.type === 'yesno') {
assert(gridPosition.type === 'option');
vote = [gridPosition.optionId] as Vote;
optionId = gridPosition.optionId;
} else {
throwIllegalValue(contest, 'type');
}

if (mark.score < markThresholds.marginal) {
log(
`Mark for contest '%s' option '%s' will be ignored, score is too low: %d < %d (marginal threshold)`,
contestId,
optionId,
mark.score,
markThresholds.marginal
);
continue;
}

if (mark.score < markThresholds.definite) {
log(
`Mark for contest '%s' option '%s' is marginal, score is too low: %d < %d (definite threshold)`,
contestId,
optionId,
mark.score,
markThresholds.definite
);
continue;
}

log(
`Mark for contest '%s' option '%s' will be counted, score is high enough: %d (definite threshold) ≤ %d`,
contestId,
optionId,
markThresholds.definite,
mark.score
);

if (!votes[contestId]) {
votes[contestId] = vote;
} else {
const existing = votes[contestId] as Vote;
votes[contestId] = [...existing, ...vote] as Vote;
}
}

return votes;
return convertMarksToVotesDict(
contests,
markThresholds,
iter(ovalMarks).map((m) => convertNewHampshireMarkToSharedMark(contests, m))
);
}
6 changes: 3 additions & 3 deletions libs/ballot-interpreter-vx/src/cli/commands/interpret.test.ts
Expand Up @@ -429,7 +429,7 @@ test('run interpret', async () => {
╟───────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────╢
║ Judge, Texas Supreme Court, Place 6 │ Jane Bland │ ║
╟───────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────╢
║ Member, Texas House of Representatives, District 111 │ Write-In │ ║
║ Member, Texas House of Representatives, District 111 │ Write-In #1 │ ║
╟───────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────╢
║ Dallas County Tax Assessor-Collector │ John Ames │ ║
╟───────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────╢
Expand All @@ -441,7 +441,7 @@ test('run interpret', async () => {
╟───────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────╢
║ Proposition R: Countywide Recycling Program │ │ no ║
╟───────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────╢
║ City Council │ │ Randall Rupp, Donald Davis, Write-In
║ City Council │ │ Randall Rupp, Donald Davis, Write-In #2
╟───────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────╢
║ Mayor │ │ ║
╚═══════════════════════════════════════════════════════╧════════════════════════════════════════════════════════════════════════════════════════════════════╧════════════════════════════════════════════════════════════════════════════════════════════════════╝
Expand Down Expand Up @@ -764,7 +764,7 @@ test('run interpret with JSON output', async () => {
Object {
"id": "write-in-0",
"isWriteIn": true,
"name": "Write-In",
"name": "Write-In #1",
},
],
"texas-sc-judge-place-6": Array [
Expand Down

0 comments on commit 932351e

Please sign in to comment.