The Worker calculates results from D1 ballots unless an administrator has stored manual results for the election.

Computed result envelope

{
  "election_id": "elec_123",
  "ballot_type": "SIMPLE_TRIPLE",
  "results": {},
  "turnout": {
    "total_tokens": 40,
    "used_tokens": 28,
    "unused_tokens": 12,
    "turnout_percent": 70
  }
}

Turnout uses token rows, not parsed ballot rows:

turnout_percent = used_tokens / total_tokens × 100

It is 0 when no tokens exist. Because token use is recorded before ballot insertion, used_tokens can exceed the number of stored ballots after a failed insert.

Yes, no, abstain

SIMPLE_TRIPLE counts the three literal payload choices:

{
  "yes": 18,
  "no": 7,
  "abstain": 3,
  "total": 28,
  "yesPercent": 64.28571428571429,
  "noPercent": 25,
  "abstainPercent": 10.714285714285714
}

Each percentage uses category / total × 100. The calculation returns raw JavaScript numbers; presentation code decides how to round them.

Malformed JSON is skipped. A parseable SIMPLE_TRIPLE payload with an unrecognized choice contributes to none of the counts.

Polls

Poll options come from the election’s candidates rows in display_order.

{
  "options": [
    {
      "option_id": "option-a",
      "option_name": "Courtyard",
      "count": 14,
      "percent": 70
    }
  ],
  "total_votes": 20,
  "total_selections": 31,
  "response_type": "multiple"
}
FieldCalculation
total_votesParsed POLL ballot payloads
total_selectionsDistinct selections counted across those ballots
Option countBallots selecting that option
Option percentcount / total_votes × 100

For multiple-choice polls, option percentages can total more than 100 percent. Duplicate option ids within one stored ballot are counted once during result calculation, even though the submission validator should reject them.

A parseable poll payload with neither selection field still increments total_votes but adds no selection. Malformed JSON is skipped.

Ranked Condorcet

The implementation first builds a pairwise matrix. For every ranked ballot, a candidate receives one preference over each candidate placed below it.

flowchart TD
  BALLOTS["Full candidate rankings"] --> MATRIX["Pairwise preference counts"]
  MATRIX --> UNBEATEN{"Candidate never loses a pair?"}
  UNBEATEN -->|yes| DIRECT["method: condorcet"]
  UNBEATEN -->|no| PAIRS["Sort victories by margin, then winning votes"]
  PAIRS --> LOCK["Lock edges that do not create a cycle"]
  LOCK --> TOPO["Topological order"]
  TOPO --> RANKED["method: ranked_pairs"]

The direct-winner check accepts a candidate whose pairwise count is greater than or equal to every opponent’s count. A tie therefore counts as “not losing.” If more than one candidate meets that condition, the first candidate in database display order wins.

When that branch succeeds:

  • method is condorcet;
  • winner is the first unbeaten candidate; and
  • ranking contains that winner followed by all remaining candidates in their original display order.

When no candidate is unbeaten, the Ranked Pairs branch:

  1. creates a directed edge for each non-tied pair;
  2. sorts edges by victory margin descending, then winning votes descending;
  3. skips an edge when it would create a cycle; and
  4. topologically sorts the locked graph, using candidate-id lexical order when multiple zero-indegree candidates are available.

The first ranked candidate is the winner. pairwiseMatrix omits tied pairs in both branches.

Implementation terminology

The direct branch is labeled condorcet in the response, although its “never loses” test permits pairwise ties rather than requiring a strict win over every opponent.

Manual results

PUT /admin/elections/:id/manual-results stores:

{
  "results": {
    "contests": [
      {
        "label": "Council vote",
        "items": [
          { "label": "YES", "votes": 18 },
          { "label": "NO", "votes": 7 }
        ]
      }
    ]
  },
  "certified_at": "2026-07-26T14:00:00Z",
  "certified_by": "Election committee",
  "notes": "Imported certified count"
}

Every contest and item needs a non-empty label. Votes must be non-negative integers.

For a simple election, labels YES, NO, and ABSTAIN are recognized case-insensitively after trimming; ABSTAINED and ABSTENTION also map to abstain. For a poll, the first non-empty contest becomes the option list.

Manual turnout is synthetic: total tokens and used tokens both equal the summed manual vote count, unused tokens are zero, and turnout is 100 percent when that count is nonzero. Ranked manual results do not produce a computed result envelope.

The legacy JSON route returns the stored contest payload, certification, and computed_results derived from it. Deleting manual results returns the election to D1 ballot calculation.

Cache and visibility

Computed results are cached in an in-memory Worker map for 60 seconds. A successful ballot submission clears that election’s key in the current isolate. There is no public force query parameter; route handlers request the normal cached result.

GET /e/:id/results:

  • redirects HTML clients to /e/:id;
  • returns JSON when Accept includes application/json; and
  • returns 403 before close when hide_results_until_close is true.

The combined election page calculates results after close, or while open when results are not hidden.

Sources

Verified against ihnyc-rc-vote commit 2451aa1.

  • src/utils/results.ts
  • src/utils/condorcet.ts
  • src/models/manual-results.ts
  • src/routes/results.ts
  • src/routes/vote.ts
  • src/templates/election-page.tsx