Skip to content

Race-prefix overvote_rank/has_duplicate_rank in the ballot CSV export - #1576

Open
masiarek wants to merge 1 commit into
Equal-Vote:mainfrom
masiarek:fix/csv-race-columns
Open

masiarek wants to merge 1 commit into
Equal-Vote:mainfrom
masiarek:fix/csv-race-columns

Conversation

@masiarek

Copy link
Copy Markdown
Collaborator

Description

In the ballot-data CSV export (BallotDataExport.tsx), the per-candidate columns are race-prefixed (Race!!Candidate), but the ranked-ballot bookkeeping columns overvote_rank and has_duplicate_rank were not. With two or more IRV/STV races on one ballot, the header emitted the same unprefixed label and key once per ranked race, and the row builder wrote every race's values into the same two keys — so the last ranked race's values were repeated under every race's columns. An auditor reading a multi-race export got wrong per-race data with no warning.

Concretely, for a two-race election (Mayor = IRV, Council = STV) where ballot b1 has an overvote at rank 2 in Mayor and ballot b2 has a duplicate rank in Council:

Before (Mayor's columns silently show Council's values; b1's real overvote is erased, b2 is falsely flagged with a duplicate in Mayor):

ballot_id,precinct,Mayor!!Ada,Mayor!!Ben,overvote_rank,has_duplicate_rank,Council!!Cara,Council!!Dan,overvote_rank,has_duplicate_rank
b1,P1,1,2,,FALSE,1,2,,FALSE
b2,P1,2,1,,TRUE,1,1,,TRUE

After:

ballot_id,precinct,Mayor!!Ada,Mayor!!Ben,Mayor!!overvote_rank,Mayor!!has_duplicate_rank,Council!!Cara,Council!!Dan,Council!!overvote_rank,Council!!has_duplicate_rank
b1,P1,1,2,2,FALSE,1,2,,FALSE
b2,P1,2,1,,FALSE,1,1,,TRUE

The fix, and the compatibility choice

The two columns are now scoped per race exactly the way the candidate columns already are:

  • Row keys always carry the race id (${race.race_id}-overvote_rank), mirroring the candidate keys (${race.race_id}-${candidate_id}) — keys are internal, so this has no effect on the file itself.
  • Header labels take the ${race.title}!! prefix under the same condition the candidate labels use: election.races.length > 1. I considered prefixing only when the election has more than one ranked race (maximally conservative for existing consumers), but followed the codebase's own convention instead: the candidate columns key their prefixing on the total race count, and a mixed election (one ranked race among several) already prefixes every candidate column, so leaving two bare bookkeeping columns floating among prefixed ones is the inconsistent — and ambiguous — reading. Multi-race exports of these two columns were wrong or ambiguous before, so no correct consumer behavior is being broken there.
  • Single-race exports are byte-identical to today's output (verified in the harness below), so the common case keeps full backward compatibility.

Verification

The frontend package has no test runner configured, so I verified with a standalone harness that replicates the export's header/row assembly for the two-race election above, before and after. Assertions (all passing): headers are unique; b1's Mayor overvote_rank = 2; b1's Council has_duplicate_rank = FALSE; b2's Mayor overvote_rank blank and has_duplicate_rank = FALSE; b2's Council has_duplicate_rank = TRUE; and a single-race election's export is byte-identical before vs after.

npx tsc --noEmit passes in packages/frontend.

Also checked for in-repo consumers of this export: the CVR importer (UploadElections.tsx / cvrParsers.tsx) reads a different rank1,rank2,… column format, not this file, and no docs or tests assert the old header layout.

Screenshots / Videos (frontend only)

No visual change — the export file contents change as shown in the before/after headers above.

Related Issues

Part of the ballot-data export bug list in #1556. Note for docs: the exporting-data help page written under #1545 deliberately avoided asserting that overvote_rank / has_duplicate_rank are per-race scoped because of this bug — once this merges, that page can be tightened to state the per-race scoping outright.

🤖 Generated with Claude Code

With two or more ranked (IRV/STV) races on one ballot, the CSV export's
overvote_rank and has_duplicate_rank columns collided: the header emitted
the same unprefixed label and key once per ranked race, and the row
builder wrote every race's values into the same two keys, so the last
ranked race's values were repeated under every race's columns. An
auditor reading a multi-race export got wrong per-race data with no
warning (e.g. a duplicate rank in one race reported as TRUE in all of
them, and a real overvote in an earlier race silently erased).

Fix: scope the two columns per race, exactly the way the candidate
columns already are — keys always carry the race_id, and labels take the
`${race.title}!!` prefix under the same condition the candidate labels
use (election.races.length > 1). Single-race exports remain
byte-identical for existing consumers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 19, 2026

Copy link
Copy Markdown

Deploy Preview for bettervoting ready!

Name Link
🔨 Latest commit f1330e8
🔍 Latest deploy log https://app.netlify.com/projects/bettervoting/deploys/6a8619bad8f3370009987667
😎 Deploy Preview https://deploy-preview-1576--bettervoting.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@masiarek, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 52 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5536f3bd-25b1-458a-82f9-bfacb0a11ce0

📥 Commits

Reviewing files that changed from the base of the PR and between 454a38a and f1330e8.

📒 Files selected for processing (1)
  • packages/frontend/src/components/Election/Results/BallotDataExport.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant