Skip to content

Commit 4b7ffe6

Browse files
committed
Modernize for Go 1.27, SIMD acceleration, and API consolidation
- Add ARM64 AdvSIMD (NEON) and AMD64 AVX/SSE assembly kernels for inner beatpath relaxation - Add multi-core parallel relaxation for elections with N >= 64 choices - Add fast-path Winner function using O(N^2) Condorcet check with fallback - Consolidate Compute and Duels iterator using Go range-over-func (iter.Seq) - Consolidate NewPreferences with generic Number precision constraint - Add VoteRanked for high-performance slice-based ballot ingestion - Add VoteFrom, Map, and export/import preferences helpers - Modernize bitset with Go 1.22+ integer ranges and 0-allocation inline buffer - Update CI workflow, documentation, and benchmarks
1 parent 8d84a12 commit 4b7ffe6

18 files changed

Lines changed: 1460 additions & 237 deletions

.github/workflows/go.yml

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,19 @@ jobs:
99
os: [ubuntu-latest, macos-latest, windows-latest]
1010

1111
steps:
12-
- name: Set up Go
13-
uses: actions/setup-go@v4
14-
with:
15-
go-version: "1.21"
16-
1712
- name: Checkout
18-
uses: actions/checkout@v1
19-
with:
20-
fetch-depth: 1
13+
uses: actions/checkout@v4
2114

22-
- name: Cache Go modules
23-
uses: actions/cache@v1
15+
- name: Set up Go
16+
uses: actions/setup-go@v5
2417
with:
25-
path: ~/go/pkg/mod
26-
key: ${{ runner.os }}-build-${{ hashFiles('**/go.sum') }}
27-
restore-keys: |
28-
${{ runner.OS }}-build-${{ env.cache-name }}-
29-
${{ runner.OS }}-build-
30-
${{ runner.OS }}-
18+
go-version: "1.27"
19+
cache: true
3120

3221
- name: Lint
33-
uses: golangci/golangci-lint-action@v2
22+
uses: golangci/golangci-lint-action@v6
3423
with:
35-
version: v1.54.2
24+
version: latest
3625
args: --timeout 10m
3726

3827
- name: Vet

README.md

Lines changed: 53 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,75 +4,87 @@
44
[![PkgGoDev](https://pkg.go.dev/badge/resenje.org/schulze)](https://pkg.go.dev/resenje.org/schulze)
55
[![NewReleases](https://newreleases.io/badge.svg)](https://newreleases.io/github/janos/schulze)
66

7-
Schulze is a Go implementation of the [Schulze method](https://en.wikipedia.org/wiki/Schulze_method) voting system. The system is developed in 1997 by Markus Schulze. It is a single winner preferential voting. The Schulze method is also known as Schwartz Sequential dropping (SSD), cloneproof Schwartz sequential dropping (CSSD), the beatpath method, beatpath winner, path voting, and path winner.
7+
Schulze is a Go implementation of the [Schulze method](https://en.wikipedia.org/wiki/Schulze_method) voting system. The system was developed in 1997 by Markus Schulze. It is a single winner preferential voting system. The Schulze method is also known as Schwartz Sequential dropping (SSD), cloneproof Schwartz sequential dropping (CSSD), the beatpath method, beatpath winner, path voting, and path winner.
88

99
The Schulze method is a [Condorcet method](https://en.wikipedia.org/wiki/Condorcet_method), which means that if there is a candidate who is preferred by a majority over every other candidate in pairwise comparisons, then this candidate will be the winner when the Schulze method is applied.
1010

11-
White paper [Markus Schulze, "The Schulze Method of Voting"](https://arxiv.org/pdf/1804.02973.pdf).
11+
White paper: [Markus Schulze, "The Schulze Method of Voting"](https://arxiv.org/pdf/1804.02973.pdf).
1212

1313
## Usage
1414

15-
`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.
15+
`Vote` and `Compute` are the core functions in the library. They implement the Schulze method on the most compact required representation of votes, called preferences, 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 of a tie for the top position, the returned `tie` boolean flag is true.
1616

1717
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.
1818

19-
### Additional features
19+
Alternatively, `VoteRanked` accepts a pre-ordered slice of ranks (`[][]C`), avoiding map hashing overhead and enabling high-performance, low-allocation ingestion.
2020

21-
This implementation of Schulze voting method adds capabilities to
21+
### Features
2222

23-
- remove the ballot from voting results, allowing the vote to be changed
24-
- add, remove or rearrange choices at any time during the voting process, while preserving consistency of the state just as the choices were present from the beginning
23+
- **Reversible voting**: The `Unvote` function allows rolling back a previously added `Ballot` using its returned `Record`, enabling voters to change their vote without re-tallying all ballots.
24+
- **Dynamic candidate adjustment**: `SetChoices` updates the pairwise preferences if choices need to be added, removed, or rearranged during active voting, while mathematically preserving consistency.
25+
- **Choice Validation**: `ValidateChoices` ensures choices list is not empty and contains no duplicate candidates.
26+
- **Direct ranked slice voting**: `VoteRanked` accepts ranked slices of candidates (`[][]C`).
27+
- **Generic Ballot Ingestion**: `VoteFrom` enables voting directly from arbitrary external structures using a custom ranking function.
28+
- **Type transformations (`Map`)**: `Record`, `Result`, `Duel`, and `Voting` support Go method type parameters via `.Map(...)` to translate between choice identifiers (e.g. `UUID` $\leftrightarrow$ `string` $\leftrightarrow$ `int`).
29+
- **Standard Go iterators**: `Duels` provides a standard `iter.Seq[*Duel[C]]` iterator for range-over-func loops.
30+
- **Hardware SIMD Acceleration**: Uses ARM64 NEON and AMD64 AVX/SSE vector assembly kernels to accelerate inner matrix relaxation.
31+
- **Fast Winner Calculation**: `Winner` returns the election winner using an $O(N^2)$ Condorcet fast-path with fallback to full beatpath computation for cycles.
32+
- **Configurable numeric precision**: Pairwise preferences support generic `Number` types (`int`, `uint32`, `uint16`, etc.) for memory optimization.
2533

26-
`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.
34+
## Voting State
2735

28-
`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 are ranked as previous ballots did not rank them or were ranked the last, as they were present in initial choices but were not ranked in any ballots.
36+
### `Voting`
2937

30-
## Voting
38+
`Voting[C]` holds the 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.
3139

32-
`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.
40+
## Results & Duels
3341

34-
## Results
42+
Results are computed by `Compute`, returning the ranked list of choices and an iterator over all pairwise `Duels`. With Go range-over-func iterators, you can iterate over duels directly:
3543

36-
Results are provided by the `Compute` function which returns the ranked list of choices from the preferences, but also the iterator function over all `Duels` that represent pairwise comparisons between two choices. Duels can be used to represent and analyze results in more details.
44+
```go
45+
for duel := range v.Duels() {
46+
winner, defeated := duel.Outcome()
47+
// analyze duel outcomes...
48+
}
49+
```
3750

3851
## Example
3952

4053
```go
4154
package main
4255

4356
import (
44-
"fmt"
45-
"log"
57+
"fmt"
58+
"log"
4659

47-
"resenje.org/schulze"
60+
"resenje.org/schulze"
4861
)
4962

5063
func main() {
51-
choices := []string{"A", "B", "C"}
52-
preferences := schulze.NewPreferences(len(choices))
53-
54-
// First vote.
55-
if _, err := schulze.Vote(preferences, choices, schulze.Ballot[string]{
56-
"A": 1,
57-
}); err != nil {
58-
log.Fatal(err)
59-
}
60-
61-
// Second vote.
62-
if _, err := schulze.Vote(preferences, choices, schulze.Ballot[string]{
63-
"A": 1,
64-
"B": 1,
65-
"C": 2,
66-
}); err != nil {
67-
log.Fatal(err)
68-
}
69-
70-
// Calculate the result.
71-
result, _, tie := schulze.Compute(preferences, choices)
72-
if tie {
73-
log.Fatal("tie")
74-
}
75-
fmt.Println("winner:", result[0].Choice)
64+
choices := []string{"A", "B", "C"}
65+
v := schulze.NewVoting(choices)
66+
67+
// First vote using map Ballot.
68+
if _, err := v.Vote(schulze.Ballot[string]{
69+
"A": 1,
70+
}); err != nil {
71+
log.Fatal(err)
72+
}
73+
74+
// Second vote using ranked slices.
75+
if _, err := v.VoteRanked([][]string{
76+
{"A", "B"},
77+
{"C"},
78+
}); err != nil {
79+
log.Fatal(err)
80+
}
81+
82+
// Calculate the result.
83+
result, _, tie := v.Compute()
84+
if tie {
85+
log.Fatal("tie")
86+
}
87+
fmt.Println("winner:", result[0].Choice)
7688
}
7789
```
7890

bitset.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,29 @@
55

66
package schulze
77

8-
type bitSet []uint64
8+
type bitSet struct {
9+
single uint64
10+
words []uint64
11+
}
912

1013
func newBitset(size uint64) bitSet {
11-
return bitSet(make([]uint64, size/64+1))
14+
if size <= 64 {
15+
return bitSet{}
16+
}
17+
return bitSet{words: make([]uint64, (size+63)/64)}
1218
}
1319

14-
func (s bitSet) set(i uint64) {
15-
s[i/64] |= 1 << (i % 64)
20+
func (s *bitSet) set(i uint64) {
21+
if len(s.words) == 0 {
22+
s.single |= 1 << i
23+
return
24+
}
25+
s.words[i/64] |= 1 << (i % 64)
1626
}
1727

1828
func (s bitSet) isSet(i uint64) bool {
19-
return s[i/64]&(1<<(i%64)) != 0
29+
if len(s.words) == 0 {
30+
return s.single&(1<<i) != 0
31+
}
32+
return s.words[i/64]&(1<<(i%64)) != 0
2033
}

bitset_test.go

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,14 @@ package schulze
77

88
import (
99
"math/rand"
10+
"slices"
1011
"testing"
1112
"time"
1213
)
1314

1415
func TestBitSet(t *testing.T) {
1516
contains := func(i uint64, values []uint64) bool {
16-
for _, v := range values {
17-
if i == v {
18-
return true
19-
}
20-
}
21-
return false
17+
return slices.Contains(values, i)
2218
}
2319

2420
seed := time.Now().UnixNano()
@@ -32,7 +28,7 @@ func TestBitSet(t *testing.T) {
3228
for _, v := range values {
3329
s.set(v)
3430
}
35-
for i := uint64(0); i < size; i++ {
31+
for i := range size {
3632
if contains(i, values) {
3733
if !s.isSet(i) {
3834
t.Errorf("expected value %v is not set (seed %v)", i, seed)

errors.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,38 @@
55

66
package schulze
77

8-
import "fmt"
8+
import (
9+
"errors"
10+
"fmt"
11+
)
912

13+
// ErrEmptyChoices indicates that an operation was attempted with an empty choices slice.
14+
var ErrEmptyChoices = errors.New("schulze: choices cannot be empty")
15+
16+
// UnknownChoiceError indicates that a choice in a ballot is not among known choices.
1017
type UnknownChoiceError[C comparable] struct {
1118
Choice C
1219
}
1320

1421
func (e *UnknownChoiceError[C]) Error() string {
1522
return fmt.Sprintf("schulze: unknown choice %v", e.Choice)
1623
}
24+
25+
// DuplicateChoiceError indicates that choices list contains duplicates.
26+
type DuplicateChoiceError[C comparable] struct {
27+
Choice C
28+
}
29+
30+
func (e *DuplicateChoiceError[C]) Error() string {
31+
return fmt.Sprintf("schulze: duplicate choice %v", e.Choice)
32+
}
33+
34+
// InvalidPreferencesError indicates that the preferences slice length does not match choicesCount*choicesCount.
35+
type InvalidPreferencesError struct {
36+
Length int
37+
Wanted int
38+
}
39+
40+
func (e *InvalidPreferencesError) Error() string {
41+
return fmt.Sprintf("schulze: invalid preferences length %d, wanted %d", e.Length, e.Wanted)
42+
}

errors_test.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ func TestVoting_Vote_UnknownChoiceError(t *testing.T) {
3131

3232
func TestVote_UnknownChoiceError(t *testing.T) {
3333
choices := []int{0, 2, 5, 7}
34-
preferences := schulze.NewPreferences(len(choices))
34+
preferences := schulze.NewPreferences[int](len(choices))
3535

36-
_, err := schulze.Vote(choices, preferences, schulze.Ballot[int]{20: 1})
36+
_, err := schulze.Vote(preferences, choices, schulze.Ballot[int]{20: 1})
3737
var verr *schulze.UnknownChoiceError[int]
3838
if !errors.As(err, &verr) {
3939
t.Fatalf("got error %v, want UnknownChoiceError", err)
@@ -45,3 +45,32 @@ func TestVote_UnknownChoiceError(t *testing.T) {
4545
t.Fatal("choice index not found in error string")
4646
}
4747
}
48+
49+
func TestValidateChoices_Errors(t *testing.T) {
50+
if err := schulze.ValidateChoices([]string{}); !errors.Is(err, schulze.ErrEmptyChoices) {
51+
t.Fatalf("got error %v, want ErrEmptyChoices", err)
52+
}
53+
54+
err := schulze.ValidateChoices([]string{"A", "B", "A"})
55+
var dupErr *schulze.DuplicateChoiceError[string]
56+
if !errors.As(err, &dupErr) {
57+
t.Fatalf("got error %v, want DuplicateChoiceError", err)
58+
}
59+
if dupErr.Choice != "A" {
60+
t.Fatalf("got choice %v, want A", dupErr.Choice)
61+
}
62+
}
63+
64+
func TestInvalidPreferencesError(t *testing.T) {
65+
choices := []string{"A", "B"}
66+
badPrefs := make([]int, 2) // wanted 4
67+
68+
_, err := schulze.Vote(badPrefs, choices, schulze.Ballot[string]{"A": 1})
69+
var invErr *schulze.InvalidPreferencesError
70+
if !errors.As(err, &invErr) {
71+
t.Fatalf("got error %v, want InvalidPreferencesError", err)
72+
}
73+
if invErr.Length != 2 || invErr.Wanted != 4 {
74+
t.Fatalf("got lengths %d/%d, want 2/4", invErr.Length, invErr.Wanted)
75+
}
76+
}

example_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ func ExampleVoting() {
6767
func ExampleNewPreferences() {
6868
// Create a new voting.
6969
choices := []string{"A", "B", "C"}
70-
preferences := schulze.NewPreferences(len(choices))
70+
preferences := schulze.NewPreferences[int](len(choices))
7171

7272
// First vote.
7373
if _, err := schulze.Vote(preferences, choices, schulze.Ballot[string]{

export_test.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@
55

66
package schulze
77

8-
// Preferences reruns a copy of preferences for testing purposes.
9-
func (v *Voting[C]) Preferences() []int {
10-
p := make([]int, len(v.preferences))
11-
copy(p, v.preferences)
12-
return p
8+
import "unsafe"
9+
10+
func RelaxRow(rowI, rowJ unsafe.Pointer, jip int, n int) {
11+
relaxRow(rowI, rowJ, jip, n)
1312
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module resenje.org/schulze
22

3-
go 1.19
3+
go 1.27

0 commit comments

Comments
 (0)