Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions docs/docusaurus/docs/05-reference/07-ballot_encoding.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ id: ballot_encoding
title: Ballot Encoding Specification
---

import MultiContestCapacityTable from '@site/src/components/MultiContestCapacityTable';

<!--
-- SPDX-FileCopyrightText: 2025 Sequent Tech Inc <legal@sequentech.io>
SPDX-License-Identifier: AGPL-3.0-only
Expand Down Expand Up @@ -316,6 +318,8 @@ classes:
- more than one explicit invalid marker
- more than one explicit blank marker
- invalid voting bounds
- a generated multi-contest ballot style whose maximum encoded size exceeds
the platform's fixed-size payload (Section 11.2)

### 9.2 Structural decoding errors

Expand Down Expand Up @@ -395,7 +399,42 @@ This codec is intended for:
- one explicit blank flag per contest when the contest defines an explicit
blank marker

### 11.2 Layout
### 11.2 Encoding mode

Each ballot style persists an encoding mode that determines how many ordinary
candidate slots (`vote_slot_count`) each contest reserves:

- **`legacy`**: `vote_slot_count` equals `contest.max_votes`. A selection with
more ordinary candidates than `max_votes` cannot be encoded.
- **`expanded-capacity`**: `vote_slot_count` equals the contest's number of
ordinary candidates, so a selection can include every ordinary candidate.

The mode is resolved once per election, when its ballot styles are generated:
`expanded-capacity` applies to every contest in the election if *any* contest
in that election has an over-vote policy of `allowed`, `allowed-with-msg`, or
`allowed-with-msg-and-alert` — the policies that let a voter select more than
`max_votes` in the first place. Otherwise the election uses `legacy`.

The mode must be resolved once and persisted on the ballot style, not
re-derived from a contest's current over-vote policy at encode or decode
time. Encoding and decoding of the same ballot style can happen far apart (a
ballot may be decoded for tallying long after it was cast), so re-deriving the
mode from live configuration risks disagreement between the encode-time and
decode-time layouts if that configuration changes in between.

Maximum-selections and overvote **validation** (Section 8.2) always uses
`contest.max_votes`, independently of the encoding mode: `expanded-capacity`
only widens what the codec can *represent*; it does not change what counts as
a valid vote. A selection wider than `max_votes` is still reported as an
overvote error — `expanded-capacity` only keeps that overvote from also being
an encoding failure.

Because a ballot style's total encoded size must still fit the platform's
fixed-size encrypted payload, election publication must validate the maximum
encoded size of every generated ballot style and reject publication if any
contest's expanded slot count would exceed that limit.

### 11.3 Layout

The multi-contest representation is:

Expand All @@ -407,18 +446,18 @@ Each contest segment is encoded as:

1. explicit invalid flag
2. explicit blank flag, if the contest defines an explicit blank marker
3. `max_votes` sparse ordinary-candidate positions
3. `vote_slot_count` sparse ordinary-candidate positions (Section 11.2)

The number of positions is therefore:

```text
(1 if decline-to-vote is enabled else 0)
+ number_of_contests
+ number_of_contests_with_explicit_blank_markers
+ sum(contest.max_votes)
+ sum(contest.vote_slot_count)
```

### 11.3 Slot meaning
### 11.4 Slot meaning

The explicit invalid and explicit blank flags use base `2`.

Expand All @@ -434,15 +473,15 @@ Values are interpreted as:
- `1..n` = selected candidate position plus one

Unlike the single-contest codec, the multi-contest codec is **sparse**: it
stores up to `max_votes` selected candidate references rather than one boolean
slot per ordinary candidate.
stores up to `vote_slot_count` selected candidate references rather than one
boolean slot per ordinary candidate.

Explicit invalid and explicit blank marker candidates are not part of the sparse
ordinary-candidate positions. Selecting one of those marker candidates sets the
corresponding bit instead, and decoding that bit reconstructs the marker
candidate as selected.

### 11.4 Decoding
### 11.5 Decoding

To decode a multi-contest ballot:

Expand All @@ -455,6 +494,22 @@ To decode a multi-contest ballot:
bits
6. apply the same semantic validation rules described earlier

A decoder must use the same encoding mode (Section 11.2) that was used to
encode the ballot style, read from the persisted ballot style rather than
re-derived, or it will misread the sparse ordinary-candidate positions.

### 11.6 Illustrative capacity limits

Contest segments compose multiplicatively, not additively (Section 11.4), so
the number of contests needed to exceed the platform's `MAX_SIZE_BYTES` limit
(29 bytes, Section 11.2) falls quickly as each contest's candidate count
grows. The table below assumes single-choice (`max_votes = 1`)
plurality-at-large contests with no decline-to-vote flag and no explicit
blank markers — the smallest possible per-contest footprint of one explicit
invalid bit plus one ordinary-candidate slot.

<MultiContestCapacityTable />

## 12. Interoperability requirements

An implementation is compatible with this specification only if it:
Expand Down
44 changes: 44 additions & 0 deletions docs/docusaurus/src/components/MultiContestCapacityTable/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react';

// sequent_core::ballot_codec::multi_ballot::BallotChoices::MAX_SIZE_BYTES
const MAX_SIZE_BYTES = 29;

const CANDIDATES_PER_CONTEST = [2, 5, 10, 20, 50, 100];

// Per-contest factor for a single-choice (max_votes = 1) plurality-at-large
// contest with no decline-to-vote flag and no explicit blank marker: base 2
// for the explicit invalid flag times base (candidates + 1) for the one
// ordinary-candidate slot (Section 11.4).
function contestsNeeded(candidatesPerContest) {
const limit = 256n ** BigInt(MAX_SIZE_BYTES);
const factor = 2n * BigInt(candidatesPerContest + 1);

let product = 1n;
let count = 0;
while (product <= limit) {
product *= factor;
count += 1;
}
return count;
}

export default function MultiContestCapacityTable() {
return (
<table>
<thead>
<tr>
<th>Candidates per contest</th>
<th style={{textAlign: 'right'}}>Contests needed to exceed {MAX_SIZE_BYTES} bytes</th>
</tr>
</thead>
<tbody>
{CANDIDATES_PER_CONTEST.map((candidates) => (
<tr key={candidates}>
<td>{candidates}</td>
<td style={{textAlign: 'right'}}>{contestsNeeded(candidates)}</td>
</tr>
))}
</tbody>
</table>
);
}
Binary file modified packages/admin-portal/rust/sequent-core-0.1.0.tgz
Binary file not shown.
37 changes: 35 additions & 2 deletions packages/admin-portal/src/resources/Publish/Publish.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: AGPL-3.0-only

import React, {ComponentType, useCallback, useContext, useEffect, useState} from "react"
import React, {ComponentType, useCallback, useContext, useEffect, useRef, useState} from "react"
import {Box} from "@mui/material"
import {useMutation} from "@apollo/client"
import {useTranslation} from "react-i18next"
Expand Down Expand Up @@ -59,6 +59,10 @@ enum ViewMode {
List,
}

interface IBallotPublicationAnnotations {
generation_error?: string
}

type TPublish = {
electionId?: string
electionEventId: string
Expand Down Expand Up @@ -411,9 +415,38 @@ const PublishMemo: React.MemoExoticComponent<ComponentType<TPublish>> = React.me
getPublishChanges,
])

// Surfaces ballot style generation failures (e.g. exceeding the
// ballot size limit) instead of polling forever for is_generated.
// The ref avoids notifying again when a refetch returns the same
// failed publication.
const notifiedGenerationErrorIdRef = useRef<Identifier | null>(null)
useEffect(() => {
const generationError = (
ballotPublication?.annotations as IBallotPublicationAnnotations | undefined
)?.generation_error

if (
generationError &&
ballotPublication &&
notifiedGenerationErrorIdRef.current !== ballotPublication.id
) {
notifiedGenerationErrorIdRef.current = ballotPublication.id
notify(t("publish.dialog.error_capacity", {message: generationError}), {
type: "error",
})
handleSetPublishStatus(PublishStatus.Void)
setViewMode(ViewMode.List)
setBallotPublicationId(null)
}
}, [ballotPublication, notify, t, handleSetPublishStatus])

// Used in order to make sure new generated publications are viewed when task completes
useEffect(() => {
if (ballotPublication && ballotPublication.is_generated === false) {
const generationError = (
ballotPublication?.annotations as IBallotPublicationAnnotations | undefined
)?.generation_error

if (ballotPublication && ballotPublication.is_generated === false && !generationError) {
const intervalId = setInterval(() => {
refetch()
}, globalSettings.QUERY_POLL_INTERVAL_MS)
Expand Down
1 change: 1 addition & 0 deletions packages/admin-portal/src/translations/cat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2221,6 +2221,7 @@ const catalanTranslation: TranslationType = {
ko: "Cancel·lar",
error: "Error carregant les paperetes publicades",
error_publish: "Error publicant la papereta",
error_capacity: "Error en generar l'estil de papereta: {{message}}",
error_status: "Error canviant l'estat de la publicació",
error_preview: "S'ha produït un error en visualitzar la publicació",
diff: "Renderitzar tots els canvis podria fer que la pàgina no respongui. Esteu segur que voleu continuar?",
Expand Down
1 change: 1 addition & 0 deletions packages/admin-portal/src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2199,6 +2199,7 @@ const englishTranslation = {
ko: "Cancel",
error: "Error loading ballot publication",
error_publish: "Error publishing ballot publication",
error_capacity: "Ballot style generation failed: {{message}}",
error_status: "Error change ballot publication status",
error_preview: "Error previewing publication",
diff: "Rendering all changes might make the page unresponsive. Are you sure you want to continue?",
Expand Down
1 change: 1 addition & 0 deletions packages/admin-portal/src/translations/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2213,6 +2213,7 @@ const spanishTranslation: TranslationType = {
ko: "Cancelar",
error: "Error al cargar las papeletas publicadas",
error_publish: "Error al publicar la papeleta",
error_capacity: "Fallo al generar el estilo de papeleta: {{message}}",
error_status: "Error al cambiar el estado de la publicación",
error_preview: "Error al obtener la vista previa de la publicación",
diff: "Renderizar todos los cambios podría hacer que la página no responda. ¿Estás seguro de que quieres continuar?",
Expand Down
1 change: 1 addition & 0 deletions packages/admin-portal/src/translations/eu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2211,6 +2211,7 @@ const basqueTranslation: TranslationType = {
ko: "Ezeztatu",
error: "Errorea bozketa argitalpena kargatzerakoan",
error_publish: "Errorea bozketa argitalpena argitaratzerakoan",
error_capacity: "Bozketa-estiloa sortzeak huts egin du: {{message}}",
error_status: "Errorea bozketa argitalpen egoera aldatzerakoan",
error_preview: "Errorea argitalpena aurreikusterakoan",
diff: "Aldaketa guztiak errendatzeak orria erantzunik gabe utzi dezake. Ziur zaude jarraitu nahi duzula?",
Expand Down
1 change: 1 addition & 0 deletions packages/admin-portal/src/translations/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2225,6 +2225,7 @@ const frenchTranslation: TranslationType = {
ko: "Annuler",
error: "Erreur lors du chargement des bulletins publiés",
error_publish: "Erreur lors de la publication du bulletin",
error_capacity: "Échec de la génération du style de bulletin : {{message}}",
error_status: "Erreur lors du changement d'état de la publication",
error_preview: "Erreur lors de l'aperçu de la publication",
diff: "Afficher tous les changements pourrait rendre la page non réactive. Êtes-vous sûr de vouloir continuer ?",
Expand Down
1 change: 1 addition & 0 deletions packages/admin-portal/src/translations/gl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2217,6 +2217,7 @@ const galegoTranslation: TranslationType = {
ko: "Cancelar",
error: "Erro ao cargar a publicación de papeletas",
error_publish: "Erro ao publicar a publicación de papeletas",
error_capacity: "Erro ao xerar o estilo de papeleta: {{message}}",
error_status: "Erro ao cambiar o estado da publicación de papeletas",
error_preview: "Erro ao previsualizar a publicación",
diff: "Renderizar todos os cambios pode facer que a páxina non responda. ¿Estás seguro de que queres continuar?",
Expand Down
1 change: 1 addition & 0 deletions packages/admin-portal/src/translations/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2214,6 +2214,7 @@ const dutchTranslation: TranslationType = {
ko: "Annuleren",
error: "Fout bij laden publicatie stembiljet",
error_publish: "Fout bij publiceren publicatie stembiljet",
error_capacity: "Genereren van stembiljetstijl mislukt: {{message}}",
error_status: "Fout bij wijzigen status publicatie stembiljet",
error_preview: "Fout bij voorbeeldweergave publicatie",
diff: "Het weergeven van alle wijzigingen kan de pagina traag maken. Weet u zeker dat u wilt doorgaan?",
Expand Down
1 change: 1 addition & 0 deletions packages/admin-portal/src/translations/tl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2223,6 +2223,7 @@ const tagalogTranslation: TranslationType = {
ko: "Kanselahin",
error: "Error sa pag-load ng paglalathala ng balota",
error_publish: "Error sa paglalathala ng balota",
error_capacity: "Nabigo ang paggawa ng estilo ng balota: {{message}}",
error_status: "Error sa pagbabago ng katayuan ng paglalathala ng balota",
error_preview: "Error sa pag-preview ng publikasyon",
diff: "Ang pag-render ng lahat ng mga pagbabago ay maaaring magdulot ng pagka-antala sa pahina. Sigurado ka bang nais mong magpatuloy?",
Expand Down
Binary file modified packages/ballot-verifier/rust/sequent-core-0.1.0.tgz
Binary file not shown.
31 changes: 31 additions & 0 deletions packages/sequent-core/src/ballot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2577,6 +2577,35 @@ impl ElectionStatus {
}
}

/// How multi-contest ballots lay out each contest's choice slots.
#[allow(non_camel_case_types)]
#[derive(
Debug,
BorshSerialize,
BorshDeserialize,
Serialize,
Deserialize,
PartialEq,
Eq,
JsonSchema,
Copy,
Clone,
EnumString,
Display,
Default,
)]
pub enum MultiContestEncodingMode {
/// One slot per `contest.max_votes`.
#[strum(serialize = "legacy")]
#[serde(rename = "legacy")]
#[default]
LEGACY,
/// One slot per ordinary candidate, to allow over-voting.
#[strum(serialize = "expanded-capacity")]
#[serde(rename = "expanded-capacity")]
EXPANDED_CAPACITY,
}

#[derive(
BorshSerialize,
BorshDeserialize,
Expand Down Expand Up @@ -2604,6 +2633,8 @@ pub struct BallotStyle {
pub election_event_annotations: Option<HashMap<String, String>>,
pub election_annotations: Option<HashMap<String, String>>,
pub area_annotations: Option<AreaAnnotations>,
/// Absent means `MultiContestEncodingMode::LEGACY`.
pub multi_contest_encoding_mode: Option<MultiContestEncodingMode>,
}

#[derive(
Expand Down
8 changes: 8 additions & 0 deletions packages/sequent-core/src/ballot_codec/contest_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ pub struct ContestCodecContext<'a> {
/// Position of each non-marker candidate id in
/// `sorted_normal_candidates`.
pub normal_candidate_positions: HashMap<&'a str, usize>,
/// Choice slots for this contest in the multi-contest encoding.
/// Defaults to `max_votes`; widened for `EXPANDED_CAPACITY` styles.
/// Unused by the single-contest dense encoding.
pub vote_slot_count: usize,
}

/// Validates the contest configuration.
Expand Down Expand Up @@ -100,6 +104,9 @@ impl<'a> ContestCodecContext<'a> {
.map(|(position, candidate)| (candidate.id.as_str(), position))
.collect();

// Legacy default; widened later for EXPANDED_CAPACITY styles.
let vote_slot_count = usize::try_from(contest.max_votes).unwrap_or(0);

ContestCodecContext {
contest,
explicit_invalid_candidate,
Expand All @@ -108,6 +115,7 @@ impl<'a> ContestCodecContext<'a> {
sorted_normal_candidates,
candidates_by_id,
normal_candidate_positions,
vote_slot_count,
}
}

Expand Down
Loading
Loading