Skip to content
Open
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
18 changes: 18 additions & 0 deletions .github/workflows/tally.yml
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'
Copy link
Member

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?

- name: Check out repository
uses: actions/checkout@v4
- name: Run unit tests and fuzzer
run: cd ${{ github.workspace }}/elections/tools && make fuzztimed
2 changes: 2 additions & 0 deletions elections/tools/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
tally
cmd/.git_hash
36 changes: 36 additions & 0 deletions elections/tools/Makefile
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}
17 changes: 17 additions & 0 deletions elections/tools/README.md
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
Copy link
Member

Choose a reason for hiding this comment

The 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.
243 changes: 243 additions & 0 deletions elections/tools/cmd/tally.go
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.",
Copy link
Member

Choose a reason for hiding this comment

The 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

644 is more normal I think, otherwise all users get write permission.

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)
}
}
22 changes: 22 additions & 0 deletions elections/tools/examples/config.yaml
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
32 changes: 32 additions & 0 deletions elections/tools/examples/results.md
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)*
4 changes: 4 additions & 0 deletions elections/tools/examples/votes.csv
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,
14 changes: 14 additions & 0 deletions elections/tools/go.mod
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
)
Loading
Loading