Skip to content

Commit e8a2ec6

Browse files
committed
Add fuzzer
1 parent 74296d9 commit e8a2ec6

File tree

8 files changed

+245
-19
lines changed

8 files changed

+245
-19
lines changed

elections/tools/Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ tally: ${GO_SRC} go.sum cmd/.git_hash
1919
test:
2020
go test ./...
2121

22+
.PHONY: fuzz
23+
fuzz:
24+
go test -v -fuzz=Fuzz ./pkg/score/
25+
2226
.PHONY: clean
2327
clean:
2428
rm -f ${ARTIFACTS}

elections/tools/pkg/score/score.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package score
22

33
import (
44
"fmt"
5-
"math"
65
"maps"
6+
"math"
7+
8+
"cmp"
79
)
810

911
// A row-major square matrix representing a match-up between two or more candidates.
@@ -48,7 +50,6 @@ func addMatrices(a, b MatchupMatrix) MatchupMatrix {
4850
return c
4951
}
5052

51-
5253
// Takes a flat ranking row (isomorphic to the input CSV) and outputs a matchup matrix in a row-major format.
5354
func rankRowToMatchupMatrix(rankRow []int, candidateCount int) MatchupMatrix {
5455
m := newMatchupMatrix(candidateCount)
@@ -165,6 +166,15 @@ func leastPreference(placements PlacementMatrix, removedCandidates map[int]bool)
165166
return leastPreferenceInternal(placements, candidatesToConsider, 0)
166167
}
167168

169+
// Returns:
170+
//
171+
// -1 if a loses to b
172+
// 1 if a wins against b
173+
// 0 if there is a tie
174+
func beats(sumMatrix MatchupMatrix, a, b int) int {
175+
return cmp.Compare(sumMatrix[a][b], sumMatrix[b][a])
176+
}
177+
168178
// Returns
169179
// - the candidate to eliminate, if tie is not true
170180
// - whether or not there has been a tie
@@ -173,10 +183,10 @@ func findEliminatee(sumMatrix MatchupMatrix, candidates []int) (int, bool) {
173183
for _, c := range candidates {
174184
wins[c] = 0
175185
for _, o := range candidates {
176-
if c == 0 {
186+
if c == o {
177187
continue
178188
}
179-
if sumMatrix[c][o] > sumMatrix[o][c] {
189+
if beats(sumMatrix, c, o) == 1 {
180190
wins[c] += 1
181191
}
182192
}
@@ -229,20 +239,30 @@ func scoreSumMatrixInternal(sumMatrix MatchupMatrix, placements PlacementMatrix,
229239
// unless there's a cycle, in which case, there's a tie
230240

231241
e, tie := findEliminatee(sumMatrix, least)
242+
232243
if tie {
233244
if len(sumMatrix)-len(removedCandidates)-len(least) >= winnerCount {
234245
// If the choice of which one of these to eliminate doesn't affect the overall result, we can eliminate them all.
235-
for c, _ := range least {
246+
for _, c := range least {
236247
losers = append(losers, c)
237248
removedCandidates[c] = true
238249
}
239250
return scoreSumMatrixInternal(sumMatrix, placements, winnerCount, removedCandidates, losers)
240251
} else {
252+
// TODO: This section is probably too deeply nested now.
241253
// This is a tie.
242254
winners := []int{}
243255
for c := 0; c < len(sumMatrix); c++ {
244256
if _, ok := removedCandidates[c]; !ok {
245-
winners = append(winners, c)
257+
isTie := false
258+
for _, l := range least {
259+
if c == l {
260+
isTie = true
261+
}
262+
}
263+
if !isTie {
264+
winners = append(winners, c)
265+
}
246266
}
247267
}
248268
tied := []int{}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package score
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"bytes"
8+
"encoding/binary"
9+
)
10+
11+
func factorial(x int) int {
12+
f := 1
13+
for i := 2; i <= x; i++ {
14+
f *= i
15+
}
16+
return f
17+
}
18+
19+
// Returnst he smallest number of bits required to represent the supplied int.
20+
func log2(x int) int {
21+
i := 0
22+
n := 1
23+
for {
24+
if n >= x {
25+
return i
26+
}
27+
i++
28+
n *= 2
29+
}
30+
}
31+
32+
func sliceRemove(s []int, i int) []int {
33+
return append(s[:i], s[i+1:]...)
34+
}
35+
36+
func lehmerCodeToRowInternal(lehmer int, i int, permutation []int, elements []int) []int {
37+
f := factorial(i)
38+
d := lehmer / f
39+
k := lehmer % f
40+
permutation = append(permutation, elements[d])
41+
elements = sliceRemove(elements, d)
42+
43+
if i == 0 {
44+
return permutation
45+
}
46+
47+
return lehmerCodeToRowInternal(k, i-1, permutation, elements)
48+
}
49+
50+
func lehmerCodeToRow(lehmer int, elementCount int) []int {
51+
elements := []int{}
52+
for i := 1; i <= elementCount; i++ {
53+
elements = append(elements, i)
54+
}
55+
return lehmerCodeToRowInternal(lehmer, elementCount-1, []int{}, elements)
56+
}
57+
58+
func rowEncodingToRows(rowEncoding []byte, numCandidates, numVoters int) [][]int {
59+
// We'll draw `numVoters` permutations from the byte slice. If there are no remaining bytes, we will assume the next row is a 0.
60+
// Each row will be a Lehmer Code represented by ceil(ceil(log_2(numCandidates!)) / 8) bytes.
61+
62+
// So we will draw no more than numVoters * ceil(ceil(log_2(numCandidates!)) / 8) bytes from the slice.
63+
encodingIndex := 0
64+
drawBytes := func(n int) []byte {
65+
b := []byte{}
66+
for i := 0; i < n; i++ {
67+
if encodingIndex < len(rowEncoding) {
68+
b = append(b, rowEncoding[i])
69+
encodingIndex++
70+
} else {
71+
b = append(b, 0)
72+
}
73+
}
74+
return b
75+
}
76+
77+
permutationCount := factorial(numCandidates)
78+
79+
// TODO: By using more bits than we actually need (the ceiling operation),
80+
// we're wasting bits by not generating unique configurations from them. For every datum, we should be wasting
81+
// less than a single bit. This will require sub-byte data tracking.
82+
byteCount := (log2(permutationCount) + 7) / 8
83+
84+
readLehmerCode := func() int {
85+
b := drawBytes(byteCount)
86+
padBytes := 8 - (len(b) % 8)
87+
for i := 0; i < padBytes; i++ {
88+
b = append(b, 0)
89+
}
90+
// fmt.Printf("b: %#v\n", b)
91+
var num uint64
92+
err := binary.Read(bytes.NewReader(b), binary.LittleEndian, &num)
93+
if err != nil {
94+
panic(fmt.Sprintf("error marshalling bytes to int: %v", err))
95+
}
96+
97+
return int(num) % permutationCount
98+
}
99+
100+
// TODO: Support abstained votes with a bit mask.
101+
rows := [][]int{}
102+
for i := 0; i < numVoters; i++ {
103+
rows = append(rows, lehmerCodeToRow(readLehmerCode(), numCandidates))
104+
}
105+
106+
return rows
107+
}
108+
109+
// Returns:
110+
// - winner - the Condorcet winner if `ok` is true
111+
// - ok - whether or not there is a Condorcet winner
112+
func getCondorcetWinner(sumMatrix MatchupMatrix) (int, bool) {
113+
candidateCount := len(sumMatrix)
114+
losslessCandidates := []int{}
115+
for a := 0; a < candidateCount; a++ {
116+
lossless := true
117+
for b := 0; b < candidateCount; b++ {
118+
if a == b {
119+
continue
120+
}
121+
122+
if beats(sumMatrix, a, b) != 1 {
123+
lossless = false
124+
break
125+
}
126+
}
127+
if lossless == true {
128+
losslessCandidates = append(losslessCandidates, a)
129+
}
130+
}
131+
132+
if len(losslessCandidates) == 1 {
133+
return losslessCandidates[0], true
134+
}
135+
136+
return 0, false
137+
}
138+
139+
func FuzzScoreRows(f *testing.F) {
140+
f.Fuzz(func(t *testing.T, rowEncoding []byte) {
141+
// TODO: Vary the integer parameters here a bit.
142+
initialWinnerCount := 7
143+
voterCount := 7
144+
candidateCount := 9
145+
rows := rowEncodingToRows(rowEncoding, candidateCount, voterCount)
146+
147+
var oldLosers []int = nil
148+
149+
for winnerCount := initialWinnerCount; winnerCount >= 1; winnerCount-- {
150+
t.Run(fmt.Sprintf("WinnerCount=%d", winnerCount), func(t *testing.T) {
151+
losers, winners, tie, sumMatrix := ScoreRows(rows, winnerCount)
152+
153+
ctxMsg := fmt.Sprintf("winners: %v\nlosers: %v\ntie: %v\nrows: %v\nseed: %v\nsumMatrix: %v", winners, losers, tie, rows, rowEncoding, sumMatrix)
154+
155+
t.Run("WinnerCount", func(t *testing.T) {
156+
if len(tie) == 0 && len(winners) != winnerCount {
157+
t.Errorf("selected wrong number of winners, want: %d, got: %d", winnerCount, len(winners))
158+
}
159+
})
160+
161+
t.Run("ReturnTotal", func(t *testing.T) {
162+
total := len(winners) + len(losers) + len(tie)
163+
if total != candidateCount {
164+
t.Errorf("expected winners, losers, and tie to total %d but got %d\n%s\n", candidateCount, total, ctxMsg)
165+
}
166+
})
167+
168+
t.Run("CondorcetWinner", func(t *testing.T) {
169+
if cw, ok := getCondorcetWinner(sumMatrix); ok {
170+
cwInWinners := false
171+
for _, w := range winners {
172+
if w == cw {
173+
cwInWinners = true
174+
}
175+
}
176+
177+
if !cwInWinners {
178+
t.Errorf("Condorcet winner %d was not in returned winners\n%s", cw, ctxMsg)
179+
}
180+
}
181+
})
182+
183+
if oldLosers != nil {
184+
t.Run("LosersMonotonic", func(t *testing.T) {
185+
if len(losers) < len(oldLosers) {
186+
t.Errorf("decreased winner count but losers were not a superset of previous losers")
187+
}
188+
189+
for i := 0; i < len(oldLosers); i++ {
190+
if losers[i] != oldLosers[i] {
191+
t.Errorf("losers at winnerCount=%d (%v) was not a superset of losers at winnerCount=%d (%v)", winnerCount, losers, winnerCount+1, oldLosers)
192+
}
193+
}
194+
})
195+
}
196+
oldLosers = losers
197+
})
198+
}
199+
200+
// To test:
201+
// x The winners, losers, and tie always sum to the candidates
202+
// - When decreasing the winner count, the new losers are always a superset of the old ones.
203+
// - The intersection of the Smith set and the losers is always null
204+
})
205+
}

elections/tools/pkg/score/score_test.go

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"reflect"
88
)
99

10-
func TestScoreRowsBasic(t *testing.T) {
10+
func TestScoreRows(t *testing.T) {
1111
tcs := []struct {
1212
Name string
1313
Rows [][]int
@@ -28,18 +28,6 @@ func TestScoreRowsBasic(t *testing.T) {
2828
WantLosers: []int{4, 8},
2929
WantTie: []int{},
3030
},
31-
{
32-
Name: "Basic tie",
33-
Rows: [][]int{
34-
{1, 2, 3, 4, 0, 6, 7, 8, 9},
35-
{2, 3, 1, 4, 0, 6, 9, 7, 8},
36-
{2, 3, 1, 4, 0, 6, 7, 9, 8},
37-
},
38-
WinnerCount: 2,
39-
WantWinners: []int{0, 1, 2},
40-
WantLosers: []int{4, 8, 7, 6, 5, 3},
41-
WantTie: []int{0, 1},
42-
},
4331
{
4432
Name: "Blocks 1",
4533
Rows: [][]int{
@@ -88,6 +76,7 @@ func TestScoreRowsBasic(t *testing.T) {
8876
WantLosers: []int{7, 8},
8977
WantTie: []int{},
9078
},
79+
// TODO: Add a tied case.
9180
}
9281

9382
for _, tc := range tcs {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
go test fuzz v1
2+
[]byte("00000000000000000000000000000000")
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
go test fuzz v1
2+
[]byte("0")
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
go test fuzz v1
2+
[]byte("0X\x94000000000000000000")
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
go test fuzz v1
2+
[]byte("\xd5")

0 commit comments

Comments
 (0)