diff --git a/.github/workflows/tally.yml b/.github/workflows/tally.yml new file mode 100644 index 0000000..7795c27 --- /dev/null +++ b/.github/workflows/tally.yml @@ -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 diff --git a/elections/tools/.gitignore b/elections/tools/.gitignore new file mode 100644 index 0000000..32ccad4 --- /dev/null +++ b/elections/tools/.gitignore @@ -0,0 +1,2 @@ +tally +cmd/.git_hash diff --git a/elections/tools/Makefile b/elections/tools/Makefile new file mode 100644 index 0000000..458e6de --- /dev/null +++ b/elections/tools/Makefile @@ -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} diff --git a/elections/tools/README.md b/elections/tools/README.md new file mode 100644 index 0000000..3349da3 --- /dev/null +++ b/elections/tools/README.md @@ -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. + +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. diff --git a/elections/tools/cmd/tally.go b/elections/tools/cmd/tally.go new file mode 100644 index 0000000..0d94623 --- /dev/null +++ b/elections/tools/cmd/tally.go @@ -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.", + 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) + 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) + } +} diff --git a/elections/tools/examples/config.yaml b/elections/tools/examples/config.yaml new file mode 100644 index 0000000..228441f --- /dev/null +++ b/elections/tools/examples/config.yaml @@ -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 diff --git a/elections/tools/examples/results.md b/elections/tools/examples/results.md new file mode 100644 index 0000000..c8ce947 --- /dev/null +++ b/elections/tools/examples/results.md @@ -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)* diff --git a/elections/tools/examples/votes.csv b/elections/tools/examples/votes.csv new file mode 100644 index 0000000..d719c3b --- /dev/null +++ b/elections/tools/examples/votes.csv @@ -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, diff --git a/elections/tools/go.mod b/elections/tools/go.mod new file mode 100644 index 0000000..a1cc5c2 --- /dev/null +++ b/elections/tools/go.mod @@ -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 +) diff --git a/elections/tools/go.sum b/elections/tools/go.sum new file mode 100644 index 0000000..4e5fb00 --- /dev/null +++ b/elections/tools/go.sum @@ -0,0 +1,19 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= +sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= diff --git a/elections/tools/pkg/score/score.go b/elections/tools/pkg/score/score.go new file mode 100644 index 0000000..cf4b247 --- /dev/null +++ b/elections/tools/pkg/score/score.go @@ -0,0 +1,308 @@ +package score + +import ( + "fmt" + "maps" + "math" + + "cmp" +) + +// A row-major square matrix representing a match-up between two or more candidates. +type MatchupMatrix = [][]int + +func newMatchupMatrix(candidateCount int) MatchupMatrix { + var m MatchupMatrix + for i := 0; i < candidateCount; i++ { + var row []int + for j := 0; j < candidateCount; j++ { + row = append(row, 0) + } + m = append(m, row) + } + + return m +} + +func addMatrices(a, b MatchupMatrix) MatchupMatrix { + if len(a) != len(b) { + // We use panic here because this represents a programmer error, not a user error. + panic(fmt.Sprintf("incompatible matrices with row counts %d and %d", len(a), len(b))) + } + c := newMatchupMatrix(len(a)) + + for i := 0; i < len(a); i++ { + if len(a[i]) != len(b[i]) { + // We use panic here because this represents a programmer error, not a user error. + panic(fmt.Sprintf("incompatible matrices with column counts %d and %d on row %d", len(a[i]), len(b[i]), i)) + } + + if len(a[i]) != len(a) { + // We use panic here because this represents a programmer error, not a user error. + panic("encountered non-square matrix") + } + + for j := 0; j < len(a); j++ { + c[i][j] = a[i][j] + b[i][j] + } + } + + return c +} + +// Takes a flat ranking row (isomorphic to the input CSV) and outputs a matchup matrix in a row-major format. +func rankRowToMatchupMatrix(rankRow []int, candidateCount int) MatchupMatrix { + m := newMatchupMatrix(candidateCount) + + for i, ranking := range rankRow { + if ranking == 0 { + continue + } + for j := 0; j < candidateCount; j++ { + otherRanking := rankRow[j] + if i == j { + continue + } + + if ranking < otherRanking || otherRanking == 0 { + // A pairwise win occurs when candidate A's ranking is lower than candidate B or candidate A + // has received a ranking and candidate B has not. + m[i][j] = 1 + } + } + } + + return m +} + +// PlacementMatrix is a row-major matrix where row i represents candidate i and column j represents the number of times candidate i came in rank j. +type PlacementMatrix = [][]int + +// Slice of length `candidateCount` of slices of of length `candidateCount`. Values in the slice are tne number of placements of each rank that each candidate has. +func calculatePlacements(rankRows [][]int, candidateCount int) (placements PlacementMatrix) { + for i := 0; i < candidateCount; i++ { + candidateRankings := []int{} + for j := 0; j < candidateCount; j++ { + candidateRankings = append(candidateRankings, 0) + } + placements = append(placements, candidateRankings) + } + + for _, row := range rankRows { + for candidateIndex, ranking := range row { + if ranking == 0 { + // 0 represents no ranking + continue + } + placements[candidateIndex][ranking-1] += 1 + } + } + + return placements +} + +// Returns the candidates tied for last, in no particular order. +func leastPreferenceInternal(placements PlacementMatrix, candidatesToConsider map[int]bool, place int) []int { + if len(candidatesToConsider) == 0 { + panic("programming error") + } + + if place == len(placements) { + // There are no more places to break the tie. + panic("programming error") + } + + lowestRank := math.MaxInt + for candidateIndex := 0; candidateIndex < len(placements); candidateIndex++ { + if _, ok := candidatesToConsider[candidateIndex]; !ok { + continue + } + + rankCountForThisCandidate := placements[candidateIndex][place] + if rankCountForThisCandidate < lowestRank { + lowestRank = rankCountForThisCandidate + } + } + + lowestCandidates := map[int]bool{} + for candidateIndex := 0; candidateIndex < len(placements); candidateIndex++ { + if _, ok := candidatesToConsider[candidateIndex]; !ok { + continue + } + + if placements[candidateIndex][place] == lowestRank { + lowestCandidates[candidateIndex] = true + } + } + + lowestCandidatesSlice := []int{} + for c, _ := range lowestCandidates { + lowestCandidatesSlice = append(lowestCandidatesSlice, c) + } + + if len(lowestCandidates) == 1 { + return lowestCandidatesSlice + } else if len(lowestCandidates) > 1 { + if place == len(placements)-1 { + // There's no more place data to use. Return a tie. + return lowestCandidatesSlice + } else { + // Recur and use the next lowest place. + return leastPreferenceInternal(placements, lowestCandidates, place+1) + } + } else { + panic("programming error") + } +} + +// Returns the least preference candidate and whether or not there's been a tie. +func leastPreference(placements PlacementMatrix, removedCandidates map[int]bool) []int { + candidatesToConsider := map[int]bool{} + for candidateIndex := 0; candidateIndex < len(placements); candidateIndex++ { + if _, ok := removedCandidates[candidateIndex]; !ok { + candidatesToConsider[candidateIndex] = true + } + } + return leastPreferenceInternal(placements, candidatesToConsider, 0) +} + +// Returns: +// +// -1 if a loses to b +// 1 if a wins against b +// 0 if there is a tie +func beats(sumMatrix MatchupMatrix, a, b int) int { + return cmp.Compare(sumMatrix[a][b], sumMatrix[b][a]) +} + +// Returns +// - the candidate to eliminate, if tie is not true +// - whether or not there has been a tie +func findEliminatee(sumMatrix MatchupMatrix, candidates []int) (int, bool) { + wins := map[int]int{} + for _, c := range candidates { + wins[c] = 0 + for _, o := range candidates { + if c == o { + continue + } + if beats(sumMatrix, c, o) == 1 { + wins[c] += 1 + } + } + } + + potentialElims := []int{} + for _, c := range candidates { + if wins[c] == 0 { + potentialElims = append(potentialElims, c) + } + } + + if len(potentialElims) == 1 { + return potentialElims[0], false + } else { + return 0, true + } +} + +// Returns: +// - losers in order from first elimination to last elimination +// - winners in no particular order +// - candidates who tied and who have not conclusively won or lost, in no particular order. +func scoreSumMatrixInternal(sumMatrix MatchupMatrix, placements PlacementMatrix, winnerCount int, removedCandidates map[int]bool, losers []int) ([]int, []int, []int) { + remainderCount := len(sumMatrix) - len(removedCandidates) + if remainderCount == winnerCount { + winners := []int{} + for i := 0; i < len(sumMatrix); i++ { + if _, ok := removedCandidates[i]; !ok { + winners = append(winners, i) + } + } + return losers, winners, []int{} + } else if remainderCount < winnerCount { + panic("Eliminated too many candidates") + } + + least := leastPreference(placements, removedCandidates) + if len(least) < 2 { + additionalRemoved := map[int]bool{} + maps.Copy(additionalRemoved, removedCandidates) + for _, loser := range least { + additionalRemoved[loser] = true + } + least = append(least, leastPreference(placements, additionalRemoved)...) + } + + // `least` now has at least two candidates in consideration + // eliminate the one that loses in a head-to-head match-up + // unless there's a cycle, in which case, there's a tie + + e, tie := findEliminatee(sumMatrix, least) + + if tie { + if len(sumMatrix)-len(removedCandidates)-len(least) >= winnerCount { + // If the choice of which one of these to eliminate doesn't affect the overall result, we can eliminate them all. + for _, c := range least { + losers = append(losers, c) + removedCandidates[c] = true + } + return scoreSumMatrixInternal(sumMatrix, placements, winnerCount, removedCandidates, losers) + } else { + // TODO: This section is probably too deeply nested now. + // This is a tie. + winners := []int{} + for c := 0; c < len(sumMatrix); c++ { + if _, ok := removedCandidates[c]; !ok { + isTie := false + for _, l := range least { + if c == l { + isTie = true + } + } + if !isTie { + winners = append(winners, c) + } + } + } + tied := []int{} + for c, _ := range least { + tied = append(tied, c) + } + return losers, winners, tied + } + } else { + // We have a single definitive candidate to eliminate. + removedCandidates[e] = true + losers = append(losers, e) + return scoreSumMatrixInternal(sumMatrix, placements, winnerCount, removedCandidates, losers) + } +} + +// Returns: +// - losers in order from first elimination to last elimination +// - winners in no particular order +// - candidates who tied and who have not conclusively won or lost, in no particular order. +func scoreSumMatrix(sumMatrix MatchupMatrix, placements PlacementMatrix, winnerCount int) (losers []int, winners []int, tied []int) { + return scoreSumMatrixInternal(sumMatrix, placements, winnerCount, map[int]bool{}, []int{}) +} + +// TODO: Test this function. + +// Returns: +// - losers in order from first elimination to last elimination +// - winners in no particular order +// - candidates who tied and who have not conclusively won or lost, in no particular order. +func ScoreRows(rankRows [][]int, winnerCount int) ([]int, []int, []int, MatchupMatrix) { + candidateCount := len(rankRows[0]) + matrixSum := newMatchupMatrix(candidateCount) + for _, row := range rankRows { + m := rankRowToMatchupMatrix(row, candidateCount) + matrixSum = addMatrices(matrixSum, m) + } + + placements := calculatePlacements(rankRows, candidateCount) + + losers, winners, tied := scoreSumMatrix(matrixSum, placements, winnerCount) + return losers, winners, tied, matrixSum +} diff --git a/elections/tools/pkg/score/score_fuzz_test.go b/elections/tools/pkg/score/score_fuzz_test.go new file mode 100644 index 0000000..7ad9618 --- /dev/null +++ b/elections/tools/pkg/score/score_fuzz_test.go @@ -0,0 +1,200 @@ +package score + +import ( + "fmt" + "testing" + + "bytes" + "encoding/binary" +) + +func factorial(x int) int { + f := 1 + for i := 2; i <= x; i++ { + f *= i + } + return f +} + +// Returnst he smallest number of bits required to represent the supplied int. +func log2(x int) int { + i := 0 + n := 1 + for { + if n >= x { + return i + } + i++ + n *= 2 + } +} + +func sliceRemove(s []int, i int) []int { + return append(s[:i], s[i+1:]...) +} + +func lehmerCodeToRowInternal(lehmer int, i int, permutation []int, elements []int) []int { + f := factorial(i) + d := lehmer / f + k := lehmer % f + permutation = append(permutation, elements[d]) + elements = sliceRemove(elements, d) + + if i == 0 { + return permutation + } + + return lehmerCodeToRowInternal(k, i-1, permutation, elements) +} + +func lehmerCodeToRow(lehmer int, elementCount int) []int { + elements := []int{} + for i := 1; i <= elementCount; i++ { + elements = append(elements, i) + } + return lehmerCodeToRowInternal(lehmer, elementCount-1, []int{}, elements) +} + +func rowEncodingToRows(rowEncoding []byte, numCandidates, numVoters int) [][]int { + // We draw `numVoters` permutations from the byte slice. If there are no remaining bytes, we will assume the next row is a 0. + // Each row will be a Lehmer Code represented by ceil(ceil(log_2(numCandidates!)) / 8) bytes. + + // So we will draw no more than numVoters * ceil(ceil(log_2(numCandidates!)) / 8) bytes from the slice. + encodingIndex := 0 + drawBytes := func(n int) []byte { + b := []byte{} + for i := 0; i < n; i++ { + if encodingIndex < len(rowEncoding) { + b = append(b, rowEncoding[i]) + encodingIndex++ + } else { + b = append(b, 0) + } + } + return b + } + + permutationCount := factorial(numCandidates) + + // TODO: By using more bits than we actually need (the ceiling operation), + // we're wasting bits by not generating unique configurations from them. For every datum, we should be wasting + // less than a single bit. This will require sub-byte data tracking. + byteCount := (log2(permutationCount) + 7) / 8 + + readLehmerCode := func() int { + b := drawBytes(byteCount) + padBytes := 8 - (len(b) % 8) + for i := 0; i < padBytes; i++ { + b = append(b, 0) + } + // fmt.Printf("b: %#v\n", b) + var num uint64 + err := binary.Read(bytes.NewReader(b), binary.LittleEndian, &num) + if err != nil { + panic(fmt.Sprintf("error marshalling bytes to int: %v", err)) + } + + return int(num) % permutationCount + } + + // TODO: Support abstained votes with a bit mask. + rows := [][]int{} + for i := 0; i < numVoters; i++ { + rows = append(rows, lehmerCodeToRow(readLehmerCode(), numCandidates)) + } + + return rows +} + +// Returns: +// - winner - the Condorcet winner if `ok` is true +// - ok - whether or not there is a Condorcet winner +func getCondorcetWinner(sumMatrix MatchupMatrix) (int, bool) { + candidateCount := len(sumMatrix) + losslessCandidates := []int{} + for a := 0; a < candidateCount; a++ { + lossless := true + for b := 0; b < candidateCount; b++ { + if a == b { + continue + } + + if beats(sumMatrix, a, b) != 1 { + lossless = false + break + } + } + if lossless == true { + losslessCandidates = append(losslessCandidates, a) + } + } + + if len(losslessCandidates) == 1 { + return losslessCandidates[0], true + } + + return 0, false +} + +func FuzzScoreRows(f *testing.F) { + f.Fuzz(func(t *testing.T, rowEncoding []byte) { + // TODO: Vary the integer parameters here a bit. + initialWinnerCount := 7 + voterCount := 7 + candidateCount := 9 + rows := rowEncodingToRows(rowEncoding, candidateCount, voterCount) + + var oldLosers []int = nil + + for winnerCount := initialWinnerCount; winnerCount >= 1; winnerCount-- { + t.Run(fmt.Sprintf("WinnerCount=%d", winnerCount), func(t *testing.T) { + losers, winners, tie, sumMatrix := ScoreRows(rows, winnerCount) + + ctxMsg := fmt.Sprintf("winners: %v\nlosers: %v\ntie: %v\nrows: %v\nseed: %v\nsumMatrix: %v", winners, losers, tie, rows, rowEncoding, sumMatrix) + + t.Run("WinnerCount", func(t *testing.T) { + if len(tie) == 0 && len(winners) != winnerCount { + t.Errorf("selected wrong number of winners, want: %d, got: %d", winnerCount, len(winners)) + } + }) + + t.Run("ReturnTotal", func(t *testing.T) { + total := len(winners) + len(losers) + len(tie) + if total != candidateCount { + t.Errorf("expected winners, losers, and tie to total %d but got %d\n%s\n", candidateCount, total, ctxMsg) + } + }) + + t.Run("CondorcetWinner", func(t *testing.T) { + if cw, ok := getCondorcetWinner(sumMatrix); ok { + cwInWinners := false + for _, w := range winners { + if w == cw { + cwInWinners = true + } + } + + if !cwInWinners { + t.Errorf("Condorcet winner %d was not in returned winners\n%s", cw, ctxMsg) + } + } + }) + + if oldLosers != nil { + t.Run("LosersMonotonic", func(t *testing.T) { + if len(losers) < len(oldLosers) { + t.Errorf("decreased winner count but losers were not a superset of previous losers") + } + + for i := 0; i < len(oldLosers); i++ { + if losers[i] != oldLosers[i] { + t.Errorf("losers at winnerCount=%d (%v) was not a superset of losers at winnerCount=%d (%v)", winnerCount, losers, winnerCount+1, oldLosers) + } + } + }) + } + oldLosers = losers + }) + } + }) +} diff --git a/elections/tools/pkg/score/score_test.go b/elections/tools/pkg/score/score_test.go new file mode 100644 index 0000000..dc13e70 --- /dev/null +++ b/elections/tools/pkg/score/score_test.go @@ -0,0 +1,106 @@ +package score + +import ( + "sort" + "testing" + + "reflect" +) + +func TestScoreRows(t *testing.T) { + tcs := []struct { + Name string + Rows [][]int + WinnerCount int + WantWinners []int + WantLosers []int + WantTie []int + }{ + { + Name: "Basic", + Rows: [][]int{ + {1, 2, 3, 4, 0, 6, 7, 8, 9}, + {2, 3, 1, 4, 0, 6, 9, 7, 8}, + {2, 3, 1, 4, 0, 6, 7, 9, 8}, + }, + WinnerCount: 7, + WantWinners: []int{0, 1, 2, 3, 5, 6, 7}, + WantLosers: []int{4, 8}, + WantTie: []int{}, + }, + { + Name: "Blocks 1", + Rows: [][]int{ + {1, 2, 3, 4, 5, 6, 7, 8, 9}, + {1, 2, 3, 4, 5, 6, 7, 8, 9}, + {1, 2, 3, 4, 5, 6, 7, 8, 9}, + {1, 2, 3, 4, 5, 6, 7, 8, 9}, + {1, 2, 3, 4, 5, 6, 7, 8, 9}, + {9, 8, 7, 6, 5, 4, 3, 2, 1}, + {9, 8, 7, 6, 5, 4, 3, 2, 1}, + }, + WinnerCount: 7, + WantWinners: []int{0, 1, 2, 3, 6, 7, 8}, + WantLosers: []int{5, 4}, + WantTie: []int{}, + }, + { + Name: "Blocks 2", + Rows: [][]int{ + {1, 2, 3, 4, 5, 6, 7, 8, 9}, + {2, 1, 3, 4, 5, 6, 7, 8, 9}, + {4, 2, 1, 3, 5, 6, 7, 8, 9}, + {7, 5, 3, 1, 2, 4, 6, 8, 9}, + {7, 6, 5, 3, 1, 2, 4, 8, 9}, + {8, 9, 7, 6, 5, 4, 3, 2, 1}, + {8, 9, 7, 6, 5, 4, 3, 2, 1}, + }, + WinnerCount: 7, + WantWinners: []int{0, 1, 2, 3, 4, 5, 8}, + WantLosers: []int{6, 7}, + WantTie: []int{}, + }, + { + Name: "Blocks 3", + Rows: [][]int{ + {1, 2, 3, 4, 5, 6, 7, 8, 9}, + {2, 1, 3, 4, 5, 6, 7, 8, 9}, + {4, 2, 1, 3, 5, 6, 7, 8, 9}, + {7, 5, 3, 1, 2, 4, 6, 8, 9}, + {7, 6, 5, 3, 1, 2, 4, 8, 9}, + {7, 6, 5, 4, 2, 1, 3, 8, 9}, + {8, 9, 7, 6, 5, 4, 3, 2, 1}, + }, + WinnerCount: 7, + WantWinners: []int{0, 1, 2, 3, 4, 5, 6}, + WantLosers: []int{7, 8}, + WantTie: []int{}, + }, + // TODO: Add a tied case. + } + + for _, tc := range tcs { + t.Run(tc.Name, func(t *testing.T) { + losers, winners, tie, _ := ScoreRows(tc.Rows, tc.WinnerCount) + + // Winner order does not matter. + sort.Ints(winners) + sort.Ints(tc.WantWinners) + if !reflect.DeepEqual(winners, tc.WantWinners) { + t.Errorf("yielded unexpected winners: got: %v\nwant: %v\n", winners, tc.WantWinners) + } + + if !reflect.DeepEqual(losers, tc.WantLosers) { + t.Errorf("yielded unexpected losers: got: %v\nwant: %v\n", losers, tc.WantLosers) + } + + // Tie order does not matter + sort.Ints(tie) + sort.Ints(tc.WantTie) + if !reflect.DeepEqual(tie, tc.WantTie) { + t.Errorf("yielded unexpected ties: got: %v\nwant: %v\n", tie, tc.WantTie) + } + + }) + } +} diff --git a/elections/tools/pkg/score/testdata/fuzz/FuzzScoreRows/356e28f5914a0f16 b/elections/tools/pkg/score/testdata/fuzz/FuzzScoreRows/356e28f5914a0f16 new file mode 100644 index 0000000..d08ef92 --- /dev/null +++ b/elections/tools/pkg/score/testdata/fuzz/FuzzScoreRows/356e28f5914a0f16 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("00000000000000000000000000000000") diff --git a/elections/tools/pkg/score/testdata/fuzz/FuzzScoreRows/582528ddfad69eb5 b/elections/tools/pkg/score/testdata/fuzz/FuzzScoreRows/582528ddfad69eb5 new file mode 100644 index 0000000..a96f559 --- /dev/null +++ b/elections/tools/pkg/score/testdata/fuzz/FuzzScoreRows/582528ddfad69eb5 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("0") diff --git a/elections/tools/pkg/score/testdata/fuzz/FuzzScoreRows/f17ed643e0d5fddd b/elections/tools/pkg/score/testdata/fuzz/FuzzScoreRows/f17ed643e0d5fddd new file mode 100644 index 0000000..ab13ec6 --- /dev/null +++ b/elections/tools/pkg/score/testdata/fuzz/FuzzScoreRows/f17ed643e0d5fddd @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("0X\x94000000000000000000") diff --git a/elections/tools/pkg/score/testdata/fuzz/FuzzScoreRows/f93565b05ef914b9 b/elections/tools/pkg/score/testdata/fuzz/FuzzScoreRows/f93565b05ef914b9 new file mode 100644 index 0000000..ebce5c0 --- /dev/null +++ b/elections/tools/pkg/score/testdata/fuzz/FuzzScoreRows/f93565b05ef914b9 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("\xd5")