-
Notifications
You must be signed in to change notification settings - Fork 35
Elections tooling #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
af331c7
c196b46
febb04e
74296d9
e8a2ec6
9a10ef2
4274b5b
a4747e0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
name: Tally Elections Tool | ||
run-name: Test Tally Elections Tool | ||
on: | ||
pull_request: | ||
paths: | ||
- 'elections/tools/**' | ||
jobs: | ||
tally_test: | ||
runs-on: ubuntu-latest | ||
steps: | ||
- run: echo "The name of the branch is ${{ github.ref }} and the repository is ${{ github.repository }}." | ||
- uses: actions/setup-go@v5 | ||
with: | ||
go-version: '1.24.x' | ||
- name: Check out repository | ||
uses: actions/checkout@v4 | ||
- name: Run unit tests and fuzzer | ||
run: cd ${{ github.workspace }}/elections/tools && make fuzztimed |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
tally | ||
cmd/.git_hash |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
ARTIFACTS=tally cmd/.git_hash | ||
|
||
GO_SRC=$(shell find cmd pkg -name '*.go' -a -not -name '*_test.go') | ||
|
||
.PHONY: all | ||
all: ${ARTIFACTS} | ||
|
||
go.sum: go.mod | ||
go mod tidy | ||
|
||
cmd/.git_hash: | ||
git rev-parse HEAD | tr -d '\n' >"$@" | ||
|
||
tally: ${GO_SRC} go.sum cmd/.git_hash | ||
echo ${GO_SRC} | ||
go build ./cmd/tally.go | ||
|
||
.PHONY: test | ||
test: | ||
go test ./... | ||
|
||
.PHONY: fuzz | ||
fuzz: | ||
go test -v -fuzz=Fuzz ./pkg/score/ | ||
|
||
.PHONY: fuzztimed | ||
fuzztimed: | ||
go test -v -fuzz=Fuzz -fuzztime=60s ./pkg/score/ | ||
|
||
.PHONY: fmt | ||
fmt: | ||
go fmt ./... | ||
|
||
.PHONY: clean | ||
clean: | ||
rm -f ${ARTIFACTS} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
# gRPC Steering Committee Elections Tooling | ||
|
||
This directory contains tooling used to conduct the annual gRPC Steering | ||
Committee elections. | ||
|
||
First, write a config file using [the example config](examples/config.yaml) as a | ||
guide. If you have not changed the ballot format since the last election, the | ||
only change you will need to make is the list of candidates. | ||
Comment on lines
+6
to
+8
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we make it a practice to check in the inputs and outputs from this tool so that people in the future can see concrete examples for how it was used so they know very precisely what to do when they go to use it? |
||
|
||
To generate a results markdown file given a CSV of ballots, use the following command: | ||
|
||
``` | ||
go run ./cmd/tally.go [VOTES_CSV] -c [CONFIG_FILE] | ||
``` | ||
|
||
This will generate a markdown file at `results.md` with the results of the | ||
election. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,243 @@ | ||
package main | ||
|
||
import ( | ||
_ "embed" | ||
"encoding/csv" | ||
"fmt" | ||
"io" | ||
"os" | ||
"strconv" | ||
"strings" | ||
|
||
"sigs.k8s.io/yaml" | ||
|
||
"github.com/grpc/grpc-community/elections/tools/pkg/score" | ||
|
||
"github.com/spf13/cobra" | ||
) | ||
|
||
const ( | ||
DefaultConfigFileName = "config.yaml" | ||
|
||
HeaderRowsDefault = 1 | ||
FirstRankingColumnIndex = 1 | ||
) | ||
|
||
//go:embed .git_hash | ||
var gitHash string | ||
|
||
type tallyConfig struct { | ||
// The number of header rows before ranking rows begin. | ||
HeaderRows int `json:"headerRows",omitempty` | ||
|
||
// The number of columns before rank data starts on each row. | ||
PrefixColCount int `json:"prefixColCount",omitempty` | ||
|
||
// The number of columns after the rank data on each row. | ||
SuffixColCount int `json:"suffixColCount",omitempty` | ||
|
||
Candidates []string `json:"candidates"` | ||
|
||
WinnerCount int `json:"winnerCount"` | ||
} | ||
|
||
// Returns a list of raw rankings -- nearly identical to the textual input | ||
// Blank cells are represented by 0. | ||
func getRankRows(headerRows, prefixColCount, suffixColCount, candidateCount int, cr *csv.Reader) ([][]int, error) { | ||
|
||
var outRows [][]int | ||
rowCount := 0 | ||
for { | ||
row, err := cr.Read() | ||
if err == io.EOF { | ||
break | ||
} | ||
|
||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
rowCount += 1 | ||
if rowCount <= headerRows { | ||
continue | ||
} | ||
|
||
if len(row) != candidateCount+prefixColCount+suffixColCount { | ||
return nil, fmt.Errorf("row %d has %d columns, expected %d (%d ranking columns + %d prefix columns + %d suffix columns)", rowCount, len(row), candidateCount+prefixColCount+suffixColCount, candidateCount, prefixColCount, suffixColCount) | ||
} | ||
|
||
rankingRow := row[prefixColCount : len(row)-suffixColCount] | ||
|
||
rankings := map[int]bool{} | ||
|
||
var outRow []int | ||
for i, cell := range rankingRow { | ||
if cell == "" { | ||
// Blank cells indicate no preference and are represented here by a 0. | ||
outRow = append(outRow, 0) | ||
continue | ||
} | ||
|
||
cellInt, err := strconv.Atoi(cell) | ||
if err != nil { | ||
return nil, fmt.Errorf("invalid non-integer cell at row %d, col %d: %v", rowCount, i+prefixColCount, err) | ||
} | ||
|
||
if cellInt < 1 { | ||
return nil, fmt.Errorf("cell at row %d, col %d has value %d, the lowest allowed ranking is 1", rowCount, 1+prefixColCount, cellInt) | ||
} | ||
|
||
if cellInt > candidateCount { | ||
return nil, fmt.Errorf("cell at row %d, col %d has value %d, the highest allowed ranking is %d (the number of candidates)", rowCount, 1+prefixColCount, cellInt, candidateCount) | ||
} | ||
|
||
if _, ok := rankings[cellInt]; ok { | ||
return nil, fmt.Errorf("found multiple instances of ranking %d in row %d", cellInt, rowCount) | ||
} | ||
rankings[cellInt] = true | ||
|
||
outRow = append(outRow, cellInt) | ||
} | ||
|
||
outRows = append(outRows, outRow) | ||
} | ||
|
||
return outRows, nil | ||
} | ||
|
||
var rootCommand = &cobra.Command{ | ||
Use: "tally [csv-file]", | ||
Short: "Tallies results from a gRPC Steering Committee Election using the Condorcet IRV method.", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alright, now that I know what you're doing, I still have no idea what you're doing. Condorcet is typically used for single winner scenarios. However, there's multiple ways of applying it when multiple winners are needed: https://en.wikipedia.org/wiki/Multiwinner_voting#Condorcet_committees You say you are using IRV. When I click on "Condorcet IRV" in wikipedia, it takes me to: https://en.wikipedia.org/wiki/Tideman_alternative_method But that's about single-winner situations. So what multi-winner algorithm are you implementing? https://www.jstor.org/stable/43662603 |
||
Long: ` | ||
Arguments: | ||
csv-file: The path to the input CSV file. | ||
`, | ||
Args: cobra.ExactArgs(1), | ||
RunE: run, | ||
} | ||
|
||
var outputMarkdownPath string | ||
var configFilePath string | ||
|
||
func generateResultsMarkdown(candidates []string, responseCount int, winners, losers, tie []int, sumMatrix [][]int) string { | ||
var sb strings.Builder | ||
|
||
sb.WriteString(fmt.Sprintf("*tallied results for %d total ballots*\n\n", responseCount)) | ||
|
||
if len(tie) > 0 { | ||
sb.WriteString("This election has resulted in a **tie**. A revote must be held with the eliminated candidates excluded.\n\n") | ||
|
||
sb.WriteString("## Candidates Eligible for Re-vote\n") | ||
for _, c := range winners { | ||
sb.WriteString(fmt.Sprintf("- %s\n", candidates[c])) | ||
} | ||
for _, c := range tie { | ||
sb.WriteString(fmt.Sprintf("- %s\n", candidates[c])) | ||
} | ||
sb.WriteString("\n") | ||
|
||
sb.WriteString("## Eliminated Candidates\n") | ||
for _, loser := range losers { | ||
sb.WriteString(fmt.Sprintf("- %s\n", candidates[loser])) | ||
} | ||
sb.WriteString("\n") | ||
} else { | ||
|
||
sb.WriteString("## Elected Steering Committee\n") | ||
|
||
for _, candidateIndex := range winners { | ||
sb.WriteString(fmt.Sprintf("- %s\n", candidates[candidateIndex])) | ||
} | ||
sb.WriteString("\n") | ||
|
||
sb.WriteString("## Instant Run-Off Elimination\n") | ||
sb.WriteString("Candidates were eliminated according to the Condorcet IRV method in the following order:\n") | ||
|
||
if len(losers) == 0 { | ||
sb.WriteString("\n*no candidates were eliminated*\n") | ||
} | ||
|
||
for _, loser := range losers { | ||
sb.WriteString(fmt.Sprintf("- %s\n", candidates[loser])) | ||
} | ||
|
||
sb.WriteString("\n") | ||
} | ||
|
||
sb.WriteString("## Sum Matrix\n") | ||
sb.WriteString("*([definition on Wikipedia](https://en.wikipedia.org/wiki/Condorcet_method#Basic_procedure))*\n") | ||
|
||
// Column Headers | ||
sb.WriteString("| |") | ||
for _, candidate := range candidates { | ||
sb.WriteString(fmt.Sprintf(" %s |", candidate)) | ||
} | ||
sb.WriteString("\n") | ||
|
||
// Header divider row | ||
sb.WriteString("| -- |") | ||
for i := 0; i < len(candidates); i++ { | ||
sb.WriteString(" -- |") | ||
} | ||
sb.WriteString("\n") | ||
|
||
for candidateAIndex, candidateA := range candidates { | ||
sb.WriteString(fmt.Sprintf("| **%s** |", candidateA)) | ||
for candidateBIndex, _ := range candidates { | ||
sb.WriteString(fmt.Sprintf(" %d |", sumMatrix[candidateAIndex][candidateBIndex])) | ||
} | ||
sb.WriteString("\n") | ||
} | ||
|
||
sb.WriteString("\n") | ||
sb.WriteString("---\n") | ||
|
||
sb.WriteString(fmt.Sprintf("*Results generated by [tally version %s](https://github.com/grpc/grpc-community/blob/%s/elections/tools)*\n", gitHash, gitHash)) | ||
|
||
return sb.String() | ||
} | ||
|
||
func run(cmd *cobra.Command, args []string) error { | ||
csvFilename := os.Args[1] | ||
|
||
configStr, err := os.ReadFile(configFilePath) | ||
if err != nil { | ||
return fmt.Errorf("unable to read %s: %v\n", configFilePath, err) | ||
} | ||
|
||
var conf tallyConfig | ||
err = yaml.UnmarshalStrict(configStr, &conf) | ||
|
||
if err != nil { | ||
return fmt.Errorf("config error: %v\n", err) | ||
} | ||
|
||
csvFile, err := os.Open(csvFilename) | ||
if err != nil { | ||
return fmt.Errorf("unable to open %s: %v\n", csvFilename, err) | ||
} | ||
|
||
cr := csv.NewReader(csvFile) | ||
rankRows, err := getRankRows(conf.HeaderRows, conf.PrefixColCount, conf.SuffixColCount, len(conf.Candidates), cr) | ||
if err != nil { | ||
return fmt.Errorf("non-compliant csv in %s: %v\n", csvFilename, err) | ||
} | ||
|
||
losers, winners, tie, matrixSum := score.ScoreRows(rankRows, conf.WinnerCount) | ||
|
||
md := generateResultsMarkdown(conf.Candidates, len(rankRows), winners, losers, tie, matrixSum) | ||
os.WriteFile(outputMarkdownPath, []byte(md), 0666) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
fmt.Printf("Wrote %s\n", outputMarkdownPath) | ||
|
||
return nil | ||
} | ||
|
||
func main() { | ||
rootCommand.PersistentFlags().StringVarP(&outputMarkdownPath, "output", "o", "results.md", "The path of the output markdown file") | ||
rootCommand.PersistentFlags().StringVarP(&configFilePath, "config", "c", DefaultConfigFileName, "The path of the config file") | ||
|
||
if err := rootCommand.Execute(); err != nil { | ||
fmt.Fprintf(os.Stderr, "%v\n", err) | ||
os.Exit(1) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
# The number of header rows to ignore for tallying. | ||
headerRows: 1 | ||
|
||
# The number of columns before rank data starts on each row. | ||
prefixColCount: 1 | ||
|
||
# The number of columsn after the rank data on each row. | ||
suffixColCount: 1 | ||
|
||
# The number of winners to be selected from the candidatesj | ||
winnerCount: 7 | ||
|
||
candidates: | ||
- Person A | ||
- Person B | ||
- Person C | ||
- Person D | ||
- Person E | ||
- Person F | ||
- Person G | ||
- Person H | ||
- Person I |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
*tallied results for 3 total ballots* | ||
|
||
## Elected Steering Committee | ||
- Person A | ||
- Person B | ||
- Person C | ||
- Person D | ||
- Person E | ||
- Person F | ||
- Person G | ||
|
||
## Instant Run-Off Elimination | ||
Candidates were eliminated according to the Condorcet IRV method in the following order: | ||
- Person I | ||
- Person H | ||
|
||
## Sum Matrix | ||
*([definition on Wikipedia](https://en.wikipedia.org/wiki/Condorcet_method#Basic_procedure))* | ||
| | Person A | Person B | Person C | Person D | Person E | Person F | Person G | Person H | Person I | | ||
| -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | | ||
| **Person A** | 0 | 3 | 1 | 3 | 3 | 3 | 3 | 3 | 3 | | ||
| **Person B** | 0 | 0 | 1 | 3 | 3 | 3 | 3 | 3 | 3 | | ||
| **Person C** | 2 | 2 | 0 | 3 | 3 | 3 | 3 | 3 | 3 | | ||
| **Person D** | 0 | 0 | 0 | 0 | 1 | 3 | 3 | 3 | 3 | | ||
| **Person E** | 0 | 0 | 0 | 2 | 0 | 2 | 2 | 2 | 2 | | ||
| **Person F** | 0 | 0 | 0 | 0 | 1 | 0 | 3 | 3 | 3 | | ||
| **Person G** | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 2 | 2 | | ||
| **Person H** | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 2 | | ||
| **Person I** | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 1 | 0 | | ||
|
||
--- | ||
*Results generated by [tally version c196b46313212b87311668d00b34ee8f66a25cd1](https://github.com/grpc/grpc-community/blob/c196b46313212b87311668d00b34ee8f66a25cd1/elections/tools)* |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
Timestamp,"Assign a rank to each candidate, with 1 being your most preferred candidate and 9 being your least preferred candidate. You may leave any number of the candidates blank to imply no preference. [Person A]","Assign a rank to each candidate, with 1 being your most preferred candidate and 9 being your least preferred candidate. You may leave any number of the candidates blank to imply no preference. [Person B]","Assign a rank to each candidate, with 1 being your most preferred candidate and 9 being your least preferred candidate. You may leave any number of the candidates blank to imply no preference. [Person C]","Assign a rank to each candidate, with 1 being your most preferred candidate and 9 being your least preferred candidate. You may leave any number of the candidates blank to imply no preference. [Person D]","Assign a rank to each candidate, with 1 being your most preferred candidate and 9 being your least preferred candidate. You may leave any number of the candidates blank to imply no preference. [Person E]","Assign a rank to each candidate, with 1 being your most preferred candidate and 9 being your least preferred candidate. You may leave any number of the candidates blank to imply no preference. [Person F]","Assign a rank to each candidate, with 1 being your most preferred candidate and 9 being your least preferred candidate. You may leave any number of the candidates blank to imply no preference. [Person G]","Assign a rank to each candidate, with 1 being your most preferred candidate and 9 being your least preferred candidate. You may leave any number of the candidates blank to imply no preference. [Person H]","Assign a rank to each candidate, with 1 being your most preferred candidate and 9 being your least preferred candidate. You may leave any number of the candidates blank to imply no preference. [Person I]",Email Address | ||
10/31/2024 15:30:20,1,2,3,4,,6,7,8,9, | ||
10/31/2024 15:33:54,2,3,1,5,4,6,9,7,8, | ||
10/31/2024 16:33:54,2,3,1,5,4,6,7,9,8, |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
module github.com/grpc/grpc-community/elections/tools | ||
|
||
go 1.24 | ||
|
||
require ( | ||
github.com/spf13/cobra v1.9.1 | ||
sigs.k8s.io/yaml v1.5.0 | ||
) | ||
|
||
require ( | ||
github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
github.com/spf13/pflag v1.0.6 // indirect | ||
go.yaml.in/yaml/v2 v2.4.2 // indirect | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Assume this will not get updated for years. Is there a
go-stable-latest
or something that can be used instead?