diff --git a/README.md b/README.md index d658505..446e354 100644 --- a/README.md +++ b/README.md @@ -10,12 +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). -## Vote and Compute +## Usage `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. 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. +`Unvote` function allows to update the pairwise preferences in a way to cancel the previously added `Ballot` to preferences using `Vote` function. It is useful to change the vote without the need to re-vote all ballots. + +`SetChoices` allows to update the pairwise preferences if the choices has to be changed during voting. New choices can be added, existing choices can be removed or rearranged. New choices will have no preferences against existing choices, neither existing choices will have preferences against the new choices. + ## Voting `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. diff --git a/export_test.go b/export_test.go new file mode 100644 index 0000000..fcfe176 --- /dev/null +++ b/export_test.go @@ -0,0 +1,13 @@ +// 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 + +// Preferences reruns a copy of preferences for testing purposes. +func (v *Voting[C]) Preferences() []int { + p := make([]int, len(v.preferences)) + copy(p, v.preferences) + return p +} diff --git a/schulze.go b/schulze.go index 3bc3bba..2721b4e 100644 --- a/schulze.go +++ b/schulze.go @@ -35,6 +35,30 @@ func Unvote[C comparable](preferences []int, choices []C, b Ballot[C]) error { return vote(preferences, choices, b, -1) // subtract one to decrement every pairwise preference } +// SetChoices updates the preferences passed as the first argument by changing +// its values to accommodate the changes to the choices. It is required to +// pass the exact choices as the second parameter and complete updated choices +// as the third argument. +func SetChoices[C comparable](preferences []int, current, updated []C) []int { + currentLength := len(current) + updatedLength := len(updated) + updatedPreferences := NewPreferences(updatedLength) + for iUpdated := 0; iUpdated < updatedLength; iUpdated++ { + iCurrent := int(getChoiceIndex(current, updated[iUpdated])) + for j := 0; j < updatedLength; j++ { + if iUpdated < currentLength && updated[iUpdated] == current[iUpdated] && j < currentLength && updated[j] == current[j] { + updatedPreferences[iUpdated*updatedLength+j] = preferences[iUpdated*currentLength+j] + } else { + jCurrent := int(getChoiceIndex(current, updated[j])) + if iCurrent >= 0 && jCurrent >= 0 { + updatedPreferences[iUpdated*updatedLength+j] = preferences[iCurrent*currentLength+jCurrent] + } + } + } + } + return updatedPreferences +} + // vote updates the preferences with ballot values according to the passed // choices. The weight is the value which is added to the preferences slice // values for pairwise wins. If the weight is 1, the ballot is added, and if it diff --git a/schulze_test.go b/schulze_test.go index 142b98f..d23deda 100644 --- a/schulze_test.go +++ b/schulze_test.go @@ -6,6 +6,9 @@ package schulze_test import ( + "bytes" + "fmt" + "io" "math/rand" "reflect" "strconv" @@ -312,6 +315,444 @@ func TestVoting(t *testing.T) { } } +func TestSetChoices(t *testing.T) { + validatePreferences := func(t *testing.T, updatedPreferences, validationPreferences, currentPreferences []int, currentChoices, updatedChoices []string) { + t.Helper() + + if fmt.Sprint(updatedPreferences) != fmt.Sprint(validationPreferences) { + t.Errorf("\ngot preferences\n%v\nwant\n%v\nbased on\n%v\n", sprintPreferences(updatedChoices, updatedPreferences), sprintPreferences(updatedChoices, validationPreferences), sprintPreferences(currentChoices, currentPreferences)) + } else { + t.Logf("\nnupdated preferences\n%v\nvalidation preferences\n%v\nbased on\n%v\n", sprintPreferences(updatedChoices, updatedPreferences), sprintPreferences(updatedChoices, validationPreferences), sprintPreferences(currentChoices, currentPreferences)) + } + } + + for _, tc := range []struct { + name string + ballots []schulze.Ballot[string] + current []string + updated []string + }{ + { + name: "no votes, no choices", + current: []string{}, + updated: []string{}, + }, + { + name: "no votes, no change", + current: []string{"A", "B", "C", "D", "E"}, + updated: []string{"A", "B", "C", "D", "E"}, + }, + { + name: "single vote, single choice", + ballots: []schulze.Ballot[string]{ + {"A": 1}, + }, + current: []string{"A"}, + updated: []string{"A"}, + }, + { + name: "single vote, no change", + ballots: []schulze.Ballot[string]{ + {"B": 1}, + }, + current: []string{"A", "B", "C", "D", "E"}, + updated: []string{"A", "B", "C", "D", "E"}, + }, + { + name: "multiple votes, no change", + ballots: []schulze.Ballot[string]{ + {"B": 1}, + {"A": 1, "C": 2, "D": 2}, + {"B": 1, "D": 2, "E": 3}, + }, + current: []string{"A", "B", "C", "D", "E"}, + updated: []string{"A", "B", "C", "D", "E"}, + }, + { + name: "single vote, swap two choices", + ballots: []schulze.Ballot[string]{ + {"C": 1}, + }, + current: []string{"A", "B", "C", "D", "E"}, + updated: []string{"A", "B", "D", "C", "E"}, + }, + { + name: "multiple votes, swap two choices", + ballots: []schulze.Ballot[string]{ + {"A": 1}, + {"A": 1}, + {"B": 1}, + {"B": 1}, + {"B": 1, "A": 2}, + {"C": 1}, + {"C": 1}, + {"C": 1, "B": 2}, + {"C": 2, "B": 2, "A": 3}, + {"D": 1}, + {"D": 1}, + {"D": 1, "C": 2}, + {"D": 2, "C": 2, "B": 3}, + {"D": 1, "C": 3, "B": 3, "A": 4}, + {"E": 1}, + {"E": 1}, + {"E": 2, "D": 2}, + {"E": 1, "D": 2}, + {"E": 2, "D": 2, "C": 3}, + {"E": 1, "D": 2, "C": 3, "B": 3}, + {"E": 2, "D": 2, "C": 3, "B": 4, "A": 5}, + }, + current: []string{"A", "B", "C", "D", "E"}, + updated: []string{"A", "B", "D", "C", "E"}, + }, + { + name: "multiple votes, remove first choice", + ballots: []schulze.Ballot[string]{ + {"A": 1}, + {"A": 1}, + {"B": 1}, + {"B": 1}, + {"B": 1, "A": 2}, + {"C": 1}, + {"C": 1}, + {"C": 1, "B": 2}, + {"C": 2, "B": 2, "A": 3}, + {"D": 1}, + {"D": 1}, + {"D": 1, "C": 2}, + {"D": 2, "C": 2, "B": 3}, + {"D": 1, "C": 3, "B": 3, "A": 4}, + {"E": 1}, + {"E": 1}, + {"E": 2, "D": 2}, + {"E": 1, "D": 2}, + {"E": 2, "D": 2, "C": 3}, + {"E": 1, "D": 2, "C": 3, "B": 3}, + {"E": 2, "D": 2, "C": 3, "B": 4, "A": 5}, + }, + current: []string{"A", "B", "C", "D", "E"}, + updated: []string{"B", "C", "D", "E"}, + }, + { + name: "multiple votes, remove last choice", + ballots: []schulze.Ballot[string]{ + {"A": 1}, + {"A": 1}, + {"B": 1}, + {"B": 1}, + {"B": 1, "A": 2}, + {"C": 1}, + {"C": 1}, + {"C": 1, "B": 2}, + {"C": 2, "B": 2, "A": 3}, + {"D": 1}, + {"D": 1}, + {"D": 1, "C": 2}, + {"D": 2, "C": 2, "B": 3}, + {"D": 1, "C": 3, "B": 3, "A": 4}, + {"E": 1}, + {"E": 1}, + {"E": 2, "D": 2}, + {"E": 1, "D": 2}, + {"E": 2, "D": 2, "C": 3}, + {"E": 1, "D": 2, "C": 3, "B": 3}, + {"E": 2, "D": 2, "C": 3, "B": 4, "A": 5}, + }, + current: []string{"A", "B", "C", "D", "E"}, + updated: []string{"A", "B", "C", "D"}, + }, + { + name: "multiple votes, remove middle choice", + ballots: []schulze.Ballot[string]{ + {"A": 1}, + {"A": 1}, + {"B": 1}, + {"B": 1}, + {"B": 1, "A": 2}, + {"C": 1}, + {"C": 1}, + {"C": 1, "B": 2}, + {"C": 2, "B": 2, "A": 3}, + {"D": 1}, + {"D": 1}, + {"D": 1, "C": 2}, + {"D": 2, "C": 2, "B": 3}, + {"D": 1, "C": 3, "B": 3, "A": 4}, + {"E": 1}, + {"E": 1}, + {"E": 2, "D": 2}, + {"E": 1, "D": 2}, + {"E": 2, "D": 2, "C": 3}, + {"E": 1, "D": 2, "C": 3, "B": 3}, + {"E": 2, "D": 2, "C": 3, "B": 4, "A": 5}, + }, + current: []string{"A", "B", "C", "D", "E"}, + updated: []string{"A", "B", "D", "E"}, + }, + { + name: "multiple votes, remove multiple choices", + ballots: []schulze.Ballot[string]{ + {"A": 1}, + {"A": 1}, + {"B": 1}, + {"B": 1}, + {"B": 1, "A": 2}, + {"C": 1}, + {"C": 1}, + {"C": 1, "B": 2}, + {"C": 2, "B": 2, "A": 3}, + {"D": 1}, + {"D": 1}, + {"D": 1, "C": 2}, + {"D": 2, "C": 2, "B": 3}, + {"D": 1, "C": 3, "B": 3, "A": 4}, + {"E": 1}, + {"E": 1}, + {"E": 2, "D": 2}, + {"E": 1, "D": 2}, + {"E": 2, "D": 2, "C": 3}, + {"E": 1, "D": 2, "C": 3, "B": 3}, + {"E": 2, "D": 2, "C": 3, "B": 4, "A": 5}, + }, + current: []string{"A", "B", "C", "D", "E"}, + updated: []string{"B", "C"}, + }, + { + name: "multiple votes, remove and swap multiple choices", + ballots: []schulze.Ballot[string]{ + {"A": 1}, + {"A": 1}, + {"B": 1}, + {"B": 1}, + {"B": 1, "A": 2}, + {"C": 1}, + {"C": 1}, + {"C": 1, "B": 2}, + {"C": 2, "B": 2, "A": 3}, + {"D": 1}, + {"D": 1}, + {"D": 1, "C": 2}, + {"D": 2, "C": 2, "B": 3}, + {"D": 1, "C": 3, "B": 3, "A": 4}, + {"E": 1}, + {"E": 1}, + {"E": 2, "D": 2}, + {"E": 1, "D": 2}, + {"E": 2, "D": 2, "C": 3}, + {"E": 1, "D": 2, "C": 3, "B": 3}, + {"E": 2, "D": 2, "C": 3, "B": 4, "A": 5}, + }, + current: []string{"A", "B", "C", "D", "E"}, + updated: []string{"B", "D", "C"}, + }, + { + name: "single vote append choice", + ballots: []schulze.Ballot[string]{ + {"A": 1}, + }, + current: []string{"A", "B"}, + updated: []string{"A", "B", "C"}, + }, + { + name: "multiple votes, append choice", + ballots: []schulze.Ballot[string]{ + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"B": 1, "A": 2}, + {"B": 1}, + {"B": 1, "A": 2}, + {"B": 1, "A": 2}, + {"B": 1, "A": 2}, + {"B": 1, "A": 2}, + {"B": 1, "A": 2}, + {"C": 1}, + {"C": 1}, + {"C": 1}, + {"C": 1, "B": 2}, + {"C": 2, "B": 2, "A": 3}, + {"D": 1}, + {"D": 1}, + {"D": 1, "C": 2}, + {"D": 2, "C": 2, "B": 3}, + {"D": 1, "C": 3, "B": 3, "A": 4}, + {"E": 1}, + {"E": 1}, + {"E": 2, "D": 2}, + {"E": 1, "D": 2}, + {"E": 2, "D": 2, "C": 3}, + {"E": 1, "D": 2, "C": 3, "B": 3}, + {"E": 2, "D": 2, "C": 3, "B": 4, "A": 5}, + {"A": 1, "B": 2, "C": 3, "D": 4, "E": 5}, + {"A": 1, "B": 2, "C": 3, "D": 4}, + {"F": 1}, + }, + current: []string{"A", "B", "C", "D", "E", "F"}, + updated: []string{"A", "B", "C", "D", "E", "F", "G"}, + }, + { + name: "multiple votes, new choices", + ballots: []schulze.Ballot[string]{ + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"B": 1, "A": 2}, + {"B": 1}, + {"B": 1, "A": 2}, + {"B": 1, "A": 2}, + {"B": 1, "A": 2}, + {"B": 1, "A": 2}, + {"B": 1, "A": 2}, + {"C": 1}, + {"C": 1}, + {"C": 1}, + {"C": 1, "B": 2}, + {"C": 2, "B": 2, "A": 3}, + {"D": 1}, + {"D": 1}, + {"D": 1, "C": 2}, + {"D": 2, "C": 2, "B": 3}, + {"D": 1, "C": 3, "B": 3, "A": 4}, + {"E": 1}, + {"E": 1}, + {"E": 2, "D": 2}, + {"E": 1, "D": 2}, + {"E": 2, "D": 2, "C": 3}, + {"E": 1, "D": 2, "C": 3, "B": 3}, + {"E": 2, "D": 2, "C": 3, "B": 4, "A": 5}, + {"A": 1, "B": 2, "C": 3, "D": 4, "E": 5}, + {"A": 1, "B": 2, "C": 3, "D": 4}, + {"F": 1}, + }, + current: []string{"A", "B", "C", "D", "E", "F"}, + updated: []string{"G", "A", "B", "H", "C", "D", "E", "F", "I", "J"}, + }, + { + name: "multiple votes, new, remove and swap choices", + ballots: []schulze.Ballot[string]{ + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"A": 1}, + {"B": 1, "A": 2}, + {"B": 1}, + {"B": 1, "A": 2}, + {"B": 1, "A": 2}, + {"B": 1, "A": 2}, + {"B": 1, "A": 2}, + {"B": 1, "A": 2}, + {"C": 1}, + {"C": 1}, + {"C": 1}, + {"C": 1, "B": 2}, + {"C": 2, "B": 2, "A": 3}, + {"D": 1}, + {"D": 1}, + {"D": 1, "C": 2}, + {"D": 2, "C": 2, "B": 3}, + {"D": 1, "C": 3, "B": 3, "A": 4}, + {"E": 1}, + {"E": 1}, + {"E": 2, "D": 2}, + {"E": 1, "D": 2}, + {"E": 2, "D": 2, "C": 3}, + {"E": 1, "D": 2, "C": 3, "B": 3}, + {"E": 2, "D": 2, "C": 3, "B": 4, "A": 5}, + {"A": 1, "B": 2, "C": 3, "D": 4, "E": 5}, + {"A": 1, "B": 2, "C": 3, "D": 4}, + {"F": 1}, + }, + current: []string{"A", "B", "C", "D", "E", "F", "G", "H"}, + updated: []string{"I", "A", "C", "H", "J", "K", "D", "B", "F", "L", "M"}, + }, + { + name: "thousand random votes, new, remove and swap choices", + ballots: randomBallots(t, []string{"A", "B", "C", "D", "E", "F"}, 1000), + current: []string{"A", "B", "C", "D", "E", "F"}, + updated: []string{"G", "A", "C", "H", "D", "B", "F", "I", "J"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Run("functional", func(t *testing.T) { + currentPreferences := schulze.NewPreferences(len(tc.current)) + for _, b := range tc.ballots { + if err := schulze.Vote(currentPreferences, tc.current, b); err != nil { + t.Fatal(err) + } + } + updatedChoicesCount := len(tc.updated) + validationPreferences := schulze.NewPreferences(updatedChoicesCount) + for _, b := range tc.ballots { + b := removeChoices(b, removedChoices(tc.current, tc.updated)) + if err := schulze.Vote(validationPreferences, tc.updated, b); err != nil { + t.Fatal(err) + } + } + + // annulate wins for the unknown choices in validation preferences + for i := 0; i < updatedChoicesCount; i++ { + for _, j := range indexesOfNewChoices(tc.current, tc.updated) { + validationPreferences[i*updatedChoicesCount+j] = 0 + } + } + + updatedPreferences := schulze.SetChoices(currentPreferences, tc.current, tc.updated) + + validatePreferences(t, updatedPreferences, validationPreferences, currentPreferences, tc.current, tc.updated) + }) + t.Run("Voting", func(t *testing.T) { + currentVoting := schulze.NewVoting(tc.current) + for _, b := range tc.ballots { + if err := currentVoting.Vote(b); err != nil { + t.Fatal(err) + } + } + currentPreferences := currentVoting.Preferences() + validationVoting := schulze.NewVoting(tc.updated) + for _, b := range tc.ballots { + b := removeChoices(b, removedChoices(tc.current, tc.updated)) + if err := validationVoting.Vote(b); err != nil { + t.Fatal(err) + } + } + + // annulate wins for the unknown choices in validation preferences + updatedChoicesCount := len(tc.updated) + validationPreferences := validationVoting.Preferences() + for i := 0; i < updatedChoicesCount; i++ { + for _, j := range indexesOfNewChoices(tc.current, tc.updated) { + validationPreferences[i*updatedChoicesCount+j] = 0 + } + } + + currentVoting.SetChoices(tc.updated) + updatedPreferences := currentVoting.Preferences() + + validatePreferences(t, updatedPreferences, validationPreferences, currentPreferences, tc.current, tc.updated) + }) + }) + } +} + func BenchmarkNewVoting(b *testing.B) { choices := newChoices(1000) @@ -417,3 +858,162 @@ func newChoices(count int) []string { } return choices } + +func randomBallots[C comparable](t *testing.T, choices []C, count int) []schulze.Ballot[C] { + t.Helper() + + seed := time.Now().UnixNano() + t.Logf("random ballots seed: %v", seed) + + random := rand.New(rand.NewSource(seed)) + + ballots := make([]schulze.Ballot[C], 0, count) + + choicesLength := len(choices) + for i := 0; i < count; i++ { + b := make(schulze.Ballot[C]) + for i := 0; i < choicesLength; i++ { + b[choices[random.Intn(choicesLength)]] = random.Intn(choicesLength) + } + ballots = append(ballots, b) + } + + return ballots +} + +func indexesOfNewChoices[C comparable](old, new []C) (indexes []int) { + for i, c := range new { + if !contains(old, c) { + indexes = append(indexes, i) + } + } + return indexes +} + +func removedChoices[C comparable](old, new []C) (removed []C) { + for _, c := range old { + if !contains(new, c) { + removed = append(removed, c) + } + } + return removed +} + +func removeChoices[C comparable](b schulze.Ballot[C], choices []C) schulze.Ballot[C] { + r := make(map[C]int) + for c, v := range b { + if contains(choices, c) { + continue + } + r[c] = v + } + return r +} + +func fprintPreferences[C comparable](w io.Writer, choices []C, preferences []int) (int, error) { + var width int + for _, c := range choices { + l := len(fmt.Sprint(c)) + if l > width { + width = l + } + } + for _, p := range preferences { + l := len(strconv.Itoa(p)) + if l > width { + width = l + } + } + format := fmt.Sprintf("%%%vv ", width) + var count int + write := func(v string) error { + n, err := fmt.Fprint(w, v) + if err != nil { + return err + } + count += n + return nil + } + + if err := write(fmt.Sprintf(format, "")); err != nil { + return count, err + } + for _, c := range choices { + if err := write(fmt.Sprintf(format, c)); err != nil { + return count, err + } + } + if err := write("\n"); err != nil { + return count, err + } + + m := matrix(preferences) + + for i, col := range m { + if err := write(fmt.Sprintf(format, choices[i])); err != nil { + return count, err + } + for _, p := range col { + if err := write(fmt.Sprintf(format, p)); err != nil { + return count, err + } + } + if err := write("\n"); err != nil { + return count, err + } + } + + return count, nil +} + +func sprintPreferences[C comparable](choices []C, preferences []int) string { + var buf bytes.Buffer + _, _ = fprintPreferences(&buf, choices, preferences) + return buf.String() +} + +func matrix(preferences []int) [][]int { + l := len(preferences) + choicesCount := floorSqrt(l) + if choicesCount*choicesCount != l { + return nil + } + + matrix := make([][]int, 0, choicesCount) + + for i := 0; i < choicesCount; i++ { + matrix = append(matrix, preferences[i*choicesCount:(i+1)*choicesCount]) + } + return matrix +} + +func floorSqrt(x int) int { + if x == 0 || x == 1 { + return x + } + start := 1 + end := x / 2 + ans := 0 + for start <= end { + mid := (start + end) / 2 + if mid*mid == x { + return mid + } + if mid*mid < x { + start = mid + 1 + ans = mid + } else { + end = mid - 1 + } + } + return ans +} + +func contains[C comparable](s []C, e C) bool { + for _, x := range s { + if x == e { + return true + } + } + return false +} diff --git a/voting.go b/voting.go index 234a538..21d1a31 100644 --- a/voting.go +++ b/voting.go @@ -32,6 +32,12 @@ func (v *Voting[C]) Unvote(b Ballot[C]) error { return Unvote(v.preferences, v.choices, b) } +// SetChoices updates the voting accommodate the changes to the choices. It is +// required to pass a complete updated choices. +func (v *Voting[C]) SetChoices(updated []C) { + v.preferences = SetChoices(v.preferences, v.choices, updated) +} + // 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 (v *Voting[C]) Compute() (results []Result[C], tie bool) {