diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index c7f063c..84431a4 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v2 with: - go-version: 1.16 + go-version: 1.19 - name: Checkout uses: actions/checkout@v1 @@ -34,7 +34,7 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@v2 with: - version: v1.41.1 + version: v1.50.0 args: --timeout 10m - name: Vet diff --git a/README.md b/README.md index 2412060..d658505 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,16 @@ The Schulze method is a [Condorcet method](https://en.wikipedia.org/wiki/Condorc White paper [Markus Schulze, "The Schulze Method of Voting"](https://arxiv.org/pdf/1804.02973.pdf). -## Compute +## Vote and Compute -`Compute(v VoteMatrix) (scores []Score, tie bool)` is the core function in the library. It implements the Schulze method on the most compact required representation of votes, called `VoteMatrix`. It returns the ranked list of choices from the matrix, with the first one as the winner. In case that there are multiple choices with the same score, the returned `tie` boolean flag is true. +`Vote` and `Compute` are the core functions in the library. They implement the Schulze method on the most compact required representation of votes, here called preferences that is properly initialized with the `NewPreferences` function. `Vote` writes the `Ballot` values to the provided preferences and `Compute` returns the ranked list of choices from the preferences, with the first one as the winner. In case that there are multiple choices with the same score, the returned `tie` boolean flag is true. -`VoteMatrix` holds number of votes for every pair of choices. A convenient structure to record this map is implemented as the `Voting` type in this package, but it is not required to be used. +The act of voting represents calling the `Vote` function with a `Ballot` map where keys in the map are choices and values are their rankings. Lowest number represents the highest rank. Not all choices have to be ranked and multiple choices can have the same rank. Ranks do not have to be in consecutive order. ## Voting -`Voting` is the in-memory data structure that allows voting ballots to be submitted, to export the `VoteMatrix` and also to compute the ranked list of choices. +`Voting` holds number of votes for every pair of choices. It is a convenient construct to use when the preferences slice does not have to be exposed, and should be kept safe from accidental mutation. Methods on the Voting type are not safe for concurrent calls. -The act of voting represents calling the `Voting.Vote(b Ballot) error` function with a `Ballot` map where keys in the map are choices and values are their rankings. Lowest number represents the highest rank. Not all choices have to be ranked and multiple choices can have the same rank. Ranks do not have to be in consecutive order. ## Example @@ -35,27 +34,27 @@ import ( ) func main() { - // Create a new voting. - e := schulze.NewVoting("A", "B", "C", "D", "E") + choices := []string{"A", "B", "C"} + preferences := schulze.NewPreferences(len(choices)) // First vote. - if err := e.Vote(schulze.Ballot{ + if err := schulze.Vote(preferences, choices, schulze.Ballot[string]{ "A": 1, }); err != nil { log.Fatal(err) } // Second vote. - if err := e.Vote(schulze.Ballot{ + if err := schulze.Vote(preferences, choices, schulze.Ballot[string]{ "A": 1, "B": 1, - "D": 2, + "C": 2, }); err != nil { log.Fatal(err) } // Calculate the result. - result, tie := e.Compute() + result, tie := schulze.Compute(preferences, choices) if tie { log.Fatal("tie") } @@ -63,10 +62,6 @@ func main() { } ``` -## Alternative voting implementations - -Function `Compute` is deliberately left exported with `VoteMatrix` map to allow different voting implementations. The `Voting` type in this package is purely in-memory but in reality, a proper way of authenticating users and storing the voting records are crucial and may require implementation with specific persistence features. - ## License This application is distributed under the BSD-style license found in the [LICENSE](LICENSE) file. diff --git a/bitset.go b/bitset.go new file mode 100644 index 0000000..b9f11d7 --- /dev/null +++ b/bitset.go @@ -0,0 +1,20 @@ +// Copyright (c) 2022, Janoš Guljaš +// All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package schulze + +type bitSet []uint64 + +func newBitset(size uint) bitSet { + return bitSet(make([]uint64, size/64+1)) +} + +func (s bitSet) set(i uint) { + s[i/64] |= 1 << (i % 64) +} + +func (s bitSet) isSet(i uint) bool { + return s[i/64]&(1<<(i%64)) != 0 +} diff --git a/bitset_test.go b/bitset_test.go new file mode 100644 index 0000000..8b3cd62 --- /dev/null +++ b/bitset_test.go @@ -0,0 +1,46 @@ +// Copyright (c) 2022, Janoš Guljaš +// All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package schulze + +import ( + "math/rand" + "testing" + "time" +) + +func TestBitSet(t *testing.T) { + contains := func(i uint, values []uint) bool { + for _, v := range values { + if i == v { + return true + } + } + return false + } + + seed := time.Now().UnixNano() + r := rand.New(rand.NewSource(seed)) + size := uint(r.Intn(12345)) + s := newBitset(size) + values := make([]uint, 0) + for i, count := uint(0), uint(r.Intn(100)); i < count; i++ { + values = append(values, uint(r.Intn(int(size)))) + } + for _, v := range values { + s.set(v) + } + for i := uint(0); i < size; i++ { + if contains(i, values) { + if !s.isSet(i) { + t.Errorf("expected value %v is not set (seed %v)", i, seed) + } + } else { + if s.isSet(i) { + t.Errorf("value %v is unexpectedly set (seed %v)", i, seed) + } + } + } +} diff --git a/errors.go b/errors.go index 7f63e5b..5f4c616 100644 --- a/errors.go +++ b/errors.go @@ -7,12 +7,10 @@ package schulze import "fmt" -// UnknownChoiceError represent an error in case that a choice that is not in -// the voting is used. -type UnknownChoiceError struct { - Choice string +type UnknownChoiceError[C comparable] struct { + Choice C } -func (e *UnknownChoiceError) Error() string { - return fmt.Sprintf("unknown choice %s", e.Choice) +func (e *UnknownChoiceError[C]) Error() string { + return fmt.Sprintf("schulze: unknown choice %v", e.Choice) } diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..4463b52 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,47 @@ +// Copyright (c) 2022, Janoš Guljaš +// All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package schulze_test + +import ( + "errors" + "strings" + "testing" + + "resenje.org/schulze" +) + +func TestVoting_Vote_UnknownChoiceError(t *testing.T) { + v := schulze.NewVoting([]int{0, 2, 5, 7}) + + err := v.Vote(schulze.Ballot[int]{20: 1}) + var verr *schulze.UnknownChoiceError[int] + if !errors.As(err, &verr) { + t.Fatalf("got error %v, want UnknownChoiceError", err) + } + if verr.Choice != 20 { + t.Fatalf("got unknown choice error choice %v, want %v", verr.Choice, 20) + } + if !strings.Contains(verr.Error(), "20") { + t.Fatal("choice index not found in error string") + } +} + +func TestVote_UnknownChoiceError(t *testing.T) { + choices := []int{0, 2, 5, 7} + preferences := schulze.NewPreferences(len(choices)) + + err := schulze.Vote(choices, preferences, schulze.Ballot[int]{20: 1}) + var verr *schulze.UnknownChoiceError[int] + if !errors.As(err, &verr) { + t.Fatalf("got error %v, want UnknownChoiceError", err) + } + if verr.Choice != 20 { + t.Fatalf("got unknown choice error choice %v, want %v", verr.Choice, 20) + } + if !strings.Contains(verr.Error(), "20") { + t.Fatal("choice index not found in error string") + } +} diff --git a/example_test.go b/example_test.go index 15dbf16..cb71616 100644 --- a/example_test.go +++ b/example_test.go @@ -14,26 +14,57 @@ import ( func ExampleVoting() { // Create a new voting. - e := schulze.NewVoting("A", "B", "C", "D", "E") + v := schulze.NewVoting([]string{"A", "B", "C"}) // First vote. - if err := e.Vote(schulze.Ballot{ + if err := v.Vote(schulze.Ballot[string]{ "A": 1, }); err != nil { log.Fatal(err) } // Second vote. - if err := e.Vote(schulze.Ballot{ + if err := v.Vote(schulze.Ballot[string]{ "A": 1, "B": 1, - "D": 2, + "C": 2, }); err != nil { log.Fatal(err) } // Calculate the result. - result, tie := e.Compute() + result, tie := v.Compute() + if tie { + log.Fatal("tie") + } + fmt.Println("winner:", result[0].Choice) + + // Output: winner: A +} + +func ExampleNewPreferences() { + // Create a new voting. + choices := []string{"A", "B", "C"} + preferences := schulze.NewPreferences(len(choices)) + + // First vote. + if err := schulze.Vote(preferences, choices, schulze.Ballot[string]{ + "A": 1, + }); err != nil { + log.Fatal(err) + } + + // Second vote. + if err := schulze.Vote(preferences, choices, schulze.Ballot[string]{ + "A": 1, + "B": 1, + "C": 2, + }); err != nil { + log.Fatal(err) + } + + // Calculate the result. + result, tie := schulze.Compute(preferences, choices) if tie { log.Fatal("tie") } diff --git a/go.mod b/go.mod index e79ae3e..c22a3ec 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module resenje.org/schulze -go 1.16 +go 1.19 diff --git a/schulze.go b/schulze.go index b72cb3b..4467051 100644 --- a/schulze.go +++ b/schulze.go @@ -3,83 +3,159 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -// Package schulze implements the Schulze method for single winner voting. +// Package schulze implements the Schulze preferential voting method. package schulze import ( + "fmt" "sort" ) -// Score represents a total number of wins for a single choice. -type Score struct { - Choice string - Wins int +// NewPreferences initializes a fixed size slice that stores all pairwise +// preferences for voting. The resulting slice supposed to be updated by the +// Vote function with Ballot preferences and read by the Results function to +// order choices by their wins. +func NewPreferences(choicesLength int) []int { + return make([]int, choicesLength*choicesLength) } -// VoteMatrix holds number of votes for every pair of choices. -type VoteMatrix map[string]map[string]int +// Ballot represents a single vote with ranked choices. Lowest number represents +// the highest rank. Not all choices have to be ranked and multiple choices can +// have the same rank. Ranks do not have to be in consecutive order. +type Ballot[C comparable] map[C]int + +// Vote updates the preferences passed as th first argument with the Ballot +// values. +func Vote[C comparable](preferences []int, choices []C, b Ballot[C]) error { + ranks, choicesCount, err := ballotRanks(choices, b) + if err != nil { + return fmt.Errorf("ballot ranks: %w", err) + } + + for rank, choices1 := range ranks { + rest := ranks[rank+1:] + for _, i := range choices1 { + for _, choices1 := range rest { + for _, j := range choices1 { + preferences[int(i)*choicesCount+int(j)]++ + } + } + } + } + + return nil +} + +// Result represents a total number of wins for a single choice. +type Result[C comparable] struct { + // The choice value. + Choice C + // 0-based ordinal number of the choice in the choice slice. + Index int + // Number of wins in pairwise comparisons to other choices votings. + Wins int +} // Compute calculates a sorted list of choices with the total number of wins for -// each of them. If there are multiple winners, tie boolean parameter is true. -func Compute(v VoteMatrix) (scores []Score, tie bool) { - choicesMap := make(map[string]struct{}) - for c1, row := range v { - for c2 := range row { - choicesMap[c1] = struct{}{} - choicesMap[c2] = struct{}{} +// each of them by reading preferences data previously populated by the Vote +// function. If there are multiple winners, tie boolean parameter is true. +func Compute[C comparable](preferences []int, choices []C) (results []Result[C], tie bool) { + strengths := calculatePairwiseStrengths(choices, preferences) + return calculateResults(choices, strengths) +} + +type choiceIndex int + +func getChoiceIndex[C comparable](choices []C, choice C) choiceIndex { + for i, c := range choices { + if c == choice { + return choiceIndex(i) } } - size := len(choicesMap) + return -1 +} + +func ballotRanks[C comparable](choices []C, b Ballot[C]) (ranks [][]choiceIndex, choicesLen int, err error) { + choicesLen = len(choices) + ballotLen := len(b) + hasUnrankedChoices := ballotLen != choicesLen - choices := make([]string, 0, size) - for c := range choicesMap { - choices = append(choices, c) + ballotRanks := make(map[int][]choiceIndex, ballotLen) + var rankedChoices bitSet + if hasUnrankedChoices { + rankedChoices = newBitset(uint(choicesLen)) } - choiceIndexes := make(map[string]int) - for i, c := range choices { - choiceIndexes[c] = i + choicesLen = len(choices) + + for choice, rank := range b { + index := getChoiceIndex(choices, choice) + if index < 0 { + return nil, 0, &UnknownChoiceError[C]{Choice: choice} + } + ballotRanks[rank] = append(ballotRanks[rank], index) + + if hasUnrankedChoices { + rankedChoices.set(uint(index)) + } } - matrix := makeVoteCountMatrix(size) - for c1, row := range v { - for c2, count := range row { - matrix[choiceIndexes[c1]][choiceIndexes[c2]] = voteCount(count) + rankNumbers := make([]int, 0, len(ballotRanks)) + for rank := range ballotRanks { + rankNumbers = append(rankNumbers, rank) + } + + sort.Slice(rankNumbers, func(i, j int) bool { + return rankNumbers[i] < rankNumbers[j] + }) + + ranks = make([][]choiceIndex, 0, len(rankNumbers)) + for _, rankNumber := range rankNumbers { + ranks = append(ranks, ballotRanks[rankNumber]) + } + + if hasUnrankedChoices { + unranked := make([]choiceIndex, 0, choicesLen-ballotLen) + for i := uint(0); int(i) < choicesLen; i++ { + if !rankedChoices.isSet(i) { + unranked = append(unranked, choiceIndex(i)) + } + } + if len(unranked) > 0 { + ranks = append(ranks, unranked) } } - return compute(matrix, choices) -} -func compute(matrix [][]voteCount, choices []string) (scores []Score, tie bool) { - strengths := calculatePairwiseStrengths(matrix) - return calculteScores(strengths, choices) + return ranks, choicesLen, nil } -func calculatePairwiseStrengths(m [][]voteCount) [][]strength { - size := len(m) - strengths := makeStrenghtMatrix(size) +func calculatePairwiseStrengths[C comparable](choices []C, preferences []int) []strength { + choicesCount := len(choices) - for i := 0; i < size; i++ { - for j := 0; j < size; j++ { + strengths := make([]strength, choicesCount*choicesCount) + + for i := 0; i < choicesCount; i++ { + for j := 0; j < choicesCount; j++ { if i != j { - c := m[i][j] - if c > m[j][i] { - strengths[i][j] = strength(c) + c := preferences[i*choicesCount+j] + if c > preferences[j*choicesCount+i] { + strengths[i*choicesCount+j] = strength(c) } } } } - for i := 0; i < size; i++ { - for j := 0; j < size; j++ { + for i := 0; i < choicesCount; i++ { + for j := 0; j < choicesCount; j++ { if i != j { - for k := 0; k < size; k++ { + for k := 0; k < choicesCount; k++ { if (i != k) && (j != k) { - strengths[j][k] = max( - strengths[j][k], + jk := j*choicesCount + k + strengths[jk] = max( + strengths[jk], min( - strengths[j][i], - strengths[i][k], + strengths[j*choicesCount+i], + strengths[i*choicesCount+k], ), ) } @@ -91,66 +167,40 @@ func calculatePairwiseStrengths(m [][]voteCount) [][]strength { return strengths } -func calculteScores(strengths [][]strength, choices []string) (scores []Score, tie bool) { - size := len(strengths) - wins := make(map[int][]int) +func calculateResults[C comparable](choices []C, strengths []strength) (results []Result[C], tie bool) { + choicesCount := len(choices) + results = make([]Result[C], 0, choicesCount) - for i := 0; i < size; i++ { + for i := 0; i < choicesCount; i++ { var count int - for j := 0; j < size; j++ { + for j := 0; j < choicesCount; j++ { if i != j { - if strengths[i][j] > strengths[j][i] { + if strengths[i*choicesCount+j] > strengths[j*choicesCount+i] { count++ } } } + results = append(results, Result[C]{Choice: choices[i], Index: i, Wins: count}) - wins[count] = append(wins[count], i) } - scores = make([]Score, 0, len(wins)) - - for count, choicesIndex := range wins { - for _, index := range choicesIndex { - scores = append(scores, Score{Choice: choices[index], Wins: count}) + sort.Slice(results, func(i, j int) bool { + if results[i].Wins == results[j].Wins { + return results[i].Index < results[j].Index } - } - - sort.Slice(scores, func(i, j int) bool { - if scores[i].Wins == scores[j].Wins { - return scores[i].Choice < scores[j].Choice - } - return scores[i].Wins > scores[j].Wins + return results[i].Wins > results[j].Wins }) - if len(scores) >= 2 { - tie = scores[0].Wins == scores[1].Wins + if len(results) >= 2 { + tie = results[0].Wins == results[1].Wins } - return scores, tie + return results, tie } -type voteCount int - type strength int -func makeVoteCountMatrix(size int) [][]voteCount { - matrix := make([][]voteCount, size) - for i := 0; i < size; i++ { - matrix[i] = make([]voteCount, size) - } - return matrix -} - -func makeStrenghtMatrix(size int) [][]strength { - matrix := make([][]strength, size) - for i := 0; i < size; i++ { - matrix[i] = make([]strength, size) - } - return matrix -} - func min(a, b strength) strength { if a < b { return a diff --git a/schulze_test.go b/schulze_test.go index d26e03c..b298dcf 100644 --- a/schulze_test.go +++ b/schulze_test.go @@ -6,8 +6,11 @@ package schulze_test import ( + "math/rand" "reflect" + "strconv" "testing" + "time" "resenje.org/schulze" ) @@ -16,102 +19,120 @@ func TestVoting(t *testing.T) { for _, tc := range []struct { name string choices []string - ballots []schulze.Ballot - result []schulze.Score + ballots []schulze.Ballot[string] + result []schulze.Result[string] tie bool }{ { name: "empty", - result: []schulze.Score{}, + result: []schulze.Result[string]{}, }, { name: "single option no votes", choices: []string{"A"}, - result: []schulze.Score{ - {Choice: "A", Wins: 0}, + result: []schulze.Result[string]{ + {Choice: "A", Index: 0, Wins: 0}, }, }, { name: "single option one vote", choices: []string{"A"}, - ballots: []schulze.Ballot{ + ballots: []schulze.Ballot[string]{ {"A": 1}, }, - result: []schulze.Score{ - {Choice: "A", Wins: 0}, + result: []schulze.Result[string]{ + {Choice: "A", Index: 0, Wins: 0}, }, }, { name: "two options one vote", choices: []string{"A", "B"}, - ballots: []schulze.Ballot{ + ballots: []schulze.Ballot[string]{ {"A": 1}, }, - result: []schulze.Score{ - {Choice: "A", Wins: 1}, - {Choice: "B", Wins: 0}, + result: []schulze.Result[string]{ + {Choice: "A", Index: 0, Wins: 1}, + {Choice: "B", Index: 1, Wins: 0}, }, }, { name: "two options two votes", choices: []string{"A", "B"}, - ballots: []schulze.Ballot{ + ballots: []schulze.Ballot[string]{ {"A": 1}, {"A": 1, "B": 2}, }, - result: []schulze.Score{ - {Choice: "A", Wins: 1}, - {Choice: "B", Wins: 0}, + result: []schulze.Result[string]{ + {Choice: "A", Index: 0, Wins: 1}, + {Choice: "B", Index: 1, Wins: 0}, }, }, { name: "three options three votes", choices: []string{"A", "B", "C"}, - ballots: []schulze.Ballot{ + ballots: []schulze.Ballot[string]{ {"A": 1}, {"A": 1, "B": 2}, {"A": 1, "B": 2, "C": 3}, }, - result: []schulze.Score{ - {Choice: "A", Wins: 2}, - {Choice: "B", Wins: 1}, - {Choice: "C", Wins: 0}, + result: []schulze.Result[string]{ + {Choice: "A", Index: 0, Wins: 2}, + {Choice: "B", Index: 1, Wins: 1}, + {Choice: "C", Index: 2, Wins: 0}, }, }, { name: "tie", choices: []string{"A", "B", "C"}, - ballots: []schulze.Ballot{ + ballots: []schulze.Ballot[string]{ {"A": 1}, {"B": 1}, }, - result: []schulze.Score{ - {Choice: "A", Wins: 1}, - {Choice: "B", Wins: 1}, - {Choice: "C", Wins: 0}, + result: []schulze.Result[string]{ + {Choice: "A", Index: 0, Wins: 1}, + {Choice: "B", Index: 1, Wins: 1}, + {Choice: "C", Index: 2, Wins: 0}, }, tie: true, }, { name: "complex", - choices: []string{"A", "B", "C", "D"}, - ballots: []schulze.Ballot{ + choices: []string{"A", "B", "C", "D", "E"}, + ballots: []schulze.Ballot[string]{ {"A": 1, "B": 1}, {"B": 1, "C": 1, "A": 2}, {"A": 1, "B": 2, "C": 2}, {"A": 1, "B": 200, "C": 10}, }, - result: []schulze.Score{ - {Choice: "A", Wins: 3}, - {Choice: "B", Wins: 1}, - {Choice: "C", Wins: 1}, - {Choice: "D", Wins: 0}, + result: []schulze.Result[string]{ + {Choice: "A", Index: 0, Wins: 4}, + {Choice: "B", Index: 1, Wins: 2}, + {Choice: "C", Index: 2, Wins: 2}, + {Choice: "D", Index: 3, Wins: 0}, + {Choice: "E", Index: 4, Wins: 0}, + }, + }, + { + name: "duplicate choice", // only the first of the duplicate choices should receive votes + choices: []string{"A", "B", "C", "C", "C"}, + ballots: []schulze.Ballot[string]{ + {"A": 1, "B": 1}, + {"B": 1, "C": 1, "A": 2}, + {"A": 1, "B": 2, "C": 2}, + {"A": 1, "B": 200, "C": 10}, + }, + result: []schulze.Result[string]{ + {Choice: "A", Index: 0, Wins: 4}, + {Choice: "B", Index: 1, Wins: 2}, + {Choice: "C", Index: 2, Wins: 2}, + {Choice: "C", Index: 3, Wins: 0}, + {Choice: "C", Index: 4, Wins: 0}, }, }, { name: "example from wiki page", choices: []string{"A", "B", "C", "D", "E"}, - ballots: []schulze.Ballot{ + ballots: []schulze.Ballot[string]{ {"A": 1, "C": 2, "B": 3, "E": 4, "D": 5}, {"A": 1, "C": 2, "B": 3, "E": 4, "D": 5}, {"A": 1, "C": 2, "B": 3, "E": 4, "D": 5}, @@ -165,26 +186,26 @@ func TestVoting(t *testing.T) { {"E": 1, "B": 2, "A": 3, "D": 4, "C": 5}, {"E": 1, "B": 2, "A": 3, "D": 4, "C": 5}, }, - result: []schulze.Score{ - {Choice: "E", Wins: 4}, - {Choice: "A", Wins: 3}, - {Choice: "C", Wins: 2}, - {Choice: "B", Wins: 1}, - {Choice: "D", Wins: 0}, + result: []schulze.Result[string]{ + {Choice: "E", Index: 4, Wins: 4}, + {Choice: "A", Index: 0, Wins: 3}, + {Choice: "C", Index: 2, Wins: 2}, + {Choice: "B", Index: 1, Wins: 1}, + {Choice: "D", Index: 3, Wins: 0}, }, }, } { t.Run(tc.name, func(t *testing.T) { - e := schulze.NewVoting(tc.choices...) + t.Run("functional", func(t *testing.T) { + preferences := schulze.NewPreferences(len(tc.choices)) - for _, b := range tc.ballots { - if err := e.Vote(b); err != nil { - t.Fatal(err) + for _, b := range tc.ballots { + if err := schulze.Vote(preferences, tc.choices, b); err != nil { + t.Fatal(err) + } } - } - t.Run("direct", func(t *testing.T) { - result, tie := e.Compute() + result, tie := schulze.Compute(preferences, tc.choices) if tie != tc.tie { t.Errorf("got tie %v, want %v", tie, tc.tie) } @@ -192,9 +213,16 @@ func TestVoting(t *testing.T) { t.Errorf("got result %+v, want %+v", result, tc.result) } }) + t.Run("Voting", func(t *testing.T) { + v := schulze.NewVoting(tc.choices) + + for _, b := range tc.ballots { + if err := v.Vote(b); err != nil { + t.Fatal(err) + } + } - t.Run("indirect", func(t *testing.T) { - result, tie := schulze.Compute(e.VoteMatrix()) + result, tie := v.Compute() if tie != tc.tie { t.Errorf("got tie %v, want %v", tie, tc.tie) } @@ -205,3 +233,109 @@ func TestVoting(t *testing.T) { }) } } + +func BenchmarkNewVoting(b *testing.B) { + choices := newChoices(1000) + + b.ResetTimer() + + for n := 0; n < b.N; n++ { + _ = schulze.NewVoting(choices) + } +} + +func BenchmarkVoting_Vote(b *testing.B) { + v := schulze.NewVoting(newChoices(1000)) + + b.ResetTimer() + + for n := 0; n < b.N; n++ { + if err := v.Vote(schulze.Ballot[string]{ + "a": 1, + }); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkVote(b *testing.B) { + const choicesCount = 1000 + + choices := newChoices(choicesCount) + preferences := schulze.NewPreferences(choicesCount) + + b.ResetTimer() + + for n := 0; n < b.N; n++ { + if err := schulze.Vote(preferences, choices, schulze.Ballot[string]{ + "a": 1, + }); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkVoting_Results(b *testing.B) { + rand.Seed(time.Now().UnixNano()) + + const choicesCount = 100 + + choices := newChoices(choicesCount) + + v := schulze.NewVoting(choices) + + for i := 0; i < 1000; i++ { + ballot := make(schulze.Ballot[string]) + ballot[choices[rand.Intn(choicesCount)]] = 1 + ballot[choices[rand.Intn(choicesCount)]] = 1 + ballot[choices[rand.Intn(choicesCount)]] = 2 + ballot[choices[rand.Intn(choicesCount)]] = 3 + ballot[choices[rand.Intn(choicesCount)]] = 20 + ballot[choices[rand.Intn(choicesCount)]] = 20 + if err := v.Vote(ballot); err != nil { + b.Fatal(err) + } + } + + b.ResetTimer() + + for n := 0; n < b.N; n++ { + _, _ = v.Compute() + } +} + +func BenchmarkResults(b *testing.B) { + rand.Seed(time.Now().UnixNano()) + + const choicesCount = 100 + + choices := newChoices(choicesCount) + preferences := schulze.NewPreferences(choicesCount) + + for i := 0; i < 1000; i++ { + ballot := make(schulze.Ballot[string]) + ballot[choices[rand.Intn(choicesCount)]] = 1 + ballot[choices[rand.Intn(choicesCount)]] = 1 + ballot[choices[rand.Intn(choicesCount)]] = 2 + ballot[choices[rand.Intn(choicesCount)]] = 3 + ballot[choices[rand.Intn(choicesCount)]] = 20 + ballot[choices[rand.Intn(choicesCount)]] = 20 + if err := schulze.Vote(preferences, choices, ballot); err != nil { + b.Fatal(err) + } + } + + b.ResetTimer() + + for n := 0; n < b.N; n++ { + _, _ = schulze.Compute(preferences, choices) + } +} + +func newChoices(count int) []string { + choices := make([]string, 0, count) + for i := 0; i < count; i++ { + choices = append(choices, strconv.FormatInt(int64(i), 36)) + } + return choices +} diff --git a/voting.go b/voting.go index b150296..cd7e23e 100644 --- a/voting.go +++ b/voting.go @@ -1,124 +1,34 @@ -// Copyright (c) 2021, Janoš Guljaš +// Copyright (c) 2022, Janoš Guljaš // All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package schulze -import "sort" - -// Voting holds voting state in memory for a list of choices and provides -// methods to vote, to export current voting state and to calculate the winner -// using the Schulze method. -type Voting struct { - choices []string - matrix [][]voteCount -} - -// NewVoting initializes a new voting with provided choices. -func NewVoting(choices ...string) *Voting { - return &Voting{ - choices: choices, - matrix: makeVoteCountMatrix(len(choices)), - } +// Voting holds number of votes for every pair of choices. It is a convenient +// construct to use when the preferences slice does not have to be exposed, and +// should be kept safe from accidental mutation. Methods on the Voting type are +// not safe for concurrent calls. +type Voting[C comparable] struct { + choices []C + preferences []int } -// Ballot represents a single vote with ranked choices. Lowest number represents -// the highest rank. Not all choices have to be ranked and multiple choices can -// have the same rank. Ranks do not have to be in consecutive order. -type Ballot map[string]int - -func (e *Voting) Vote(b Ballot) error { - ranks, err := ballotRanks(b, e.choices) - if err != nil { - return err - } - - for rank, choices1 := range ranks { - rest := ranks[rank+1:] - for _, i := range choices1 { - for _, choices1 := range rest { - for _, j := range choices1 { - e.matrix[i][j]++ - } - } - } +// NewVoting initializes a new voting state for the provided choices. +func NewVoting[C comparable](choices []C) *Voting[C] { + return &Voting[C]{ + choices: choices, + preferences: NewPreferences(len(choices)), } - - return nil } -// VoteMatrix returns the state of the voting in a form of VoteMatrix with -// pairwise number of votes. -func (e *Voting) VoteMatrix() VoteMatrix { - l := len(e.matrix) - matrix := make(VoteMatrix, l) - - for i := 0; i < l; i++ { - for j := 0; j < l; j++ { - if _, ok := matrix[e.choices[i]]; !ok { - matrix[e.choices[i]] = make(map[string]int, l) - } - matrix[e.choices[i]][e.choices[j]] = int(e.matrix[i][j]) - } - } - - return matrix +// Vote adds a voting preferences by a single voting ballot. +func (v *Voting[C]) Vote(b Ballot[C]) error { + return Vote(v.preferences, v.choices, b) } // Compute calculates a sorted list of choices with the total number of wins for // each of them. If there are multiple winners, tie boolean parameter is true. -func (e *Voting) Compute() (scores []Score, tie bool) { - return compute(e.matrix, e.choices) -} - -func ballotRanks(b Ballot, choices []string) ([][]choiceIndex, error) { - ballotRanks := make(map[int][]choiceIndex) - rankedChoices := make(map[choiceIndex]struct{}) - - for o, rank := range b { - index := getChoiceIndex(o, choices) - if index < 0 { - return nil, &UnknownChoiceError{o} - } - ballotRanks[rank] = append(ballotRanks[rank], index) - rankedChoices[index] = struct{}{} - } - - rankNumbers := make([]int, 0, len(ballotRanks)) - for rank := range ballotRanks { - rankNumbers = append(rankNumbers, rank) - } - - sort.Slice(rankNumbers, func(i, j int) bool { - return rankNumbers[i] < rankNumbers[j] - }) - - ranks := make([][]choiceIndex, 0) - for _, rankNumber := range rankNumbers { - ranks = append(ranks, ballotRanks[rankNumber]) - } - - unranked := make([]choiceIndex, 0) - for i, l := choiceIndex(0), len(choices); int(i) < l; i++ { - if _, ok := rankedChoices[i]; !ok { - unranked = append(unranked, i) - } - } - if len(unranked) > 0 { - ranks = append(ranks, unranked) - } - - return ranks, nil -} - -type choiceIndex int - -func getChoiceIndex(choice string, choices []string) choiceIndex { - for i, o := range choices { - if o == choice { - return choiceIndex(i) - } - } - return -1 +func (v *Voting[C]) Compute() (results []Result[C], tie bool) { + return Compute(v.preferences, v.choices) }