4 files changed, 355 insertions(+), 0 deletions(-)

A => go.mod
A => input-1.csv
A => input-2.csv
A => main.go
A => go.mod +3 -0
@@ 0,0 1,3 @@ 
+module ser1.net/sbb
+
+go 1.15

          
A => input-1.csv +15 -0
@@ 0,0 1,15 @@ 
+5,4,0
+4,1,5
+5,4,0
+5,4,0
+0,1,5
+0,1,5
+0,1,5
+5,4,0
+0,1,5
+4,1,5
+5,4,0
+5,4,0
+0,5,1
+0,1,5
+5,4,0

          
A => input-2.csv +15 -0
@@ 0,0 1,15 @@ 
+5,4,0
+4,1,5
+5,4,0
+5,4,0
+0,1,5
+0,1,5
+0,1,5
+5,4,0
+0,1,5
+4,1,5
+5,4,0
+5,4,0
+0,5,4
+0,1,5
+5,4,0

          
A => main.go +322 -0
@@ 0,0 1,322 @@ 
+package main
+
+// score-better-balance calculator
+// from https://www.reddit.com/r/EndFPTP/comments/lil4zz/scorebetterbalance_a_proposal_to_fix_some/
+
+import (
+	"encoding/csv"
+	"fmt"
+	"os"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+func main() {
+	/**************************/
+	/* Process the input data */
+	/**************************/
+	input := csv.NewReader(os.Stdin)
+	input.LazyQuotes = true
+	lines, err := input.ReadAll()
+	if err != nil {
+		panic(err)
+	}
+	// All of the row/column values are strings. Turn them into ints
+	ints, badVotes := toInts(lines)
+	if len(badVotes) > 0 {
+		fmt.Println("Vote errors encountered:")
+		// We'll print info about the votes at the end.
+	}
+
+	/*********************/
+	/* The SBB algorithm */
+	/*********************/
+
+	// As defined, R is max-min.
+	R := getRange(ints)
+	// Find the winner purely from score (step 1)
+	// > 1) get the score leader
+	w, _ := winner(ints)
+	// Calculate the alter matrix (step 2). This is a matrix of [0, r, -r] values
+	// `w` in this case is the column index of the leader from (1)
+	// > 2) alter every ballot to let everyone above the score leader +r points
+	// and everyone belowe the score leader -r points (allowing going out of
+	// normal range)
+	diffs := diff(ints, w, R)
+	// Sum the scores and diffs to compute the final (step 3)
+	ttl := total(ints, diffs)
+	// wc is the index of the final winner
+	wc := max(ttl)
+
+	printDetails(wc, lines, badVotes, ints, diffs, ttl)
+}
+
+// getRange finds the maximum and minimum score values in the entire file.
+// This means that if the legal range is 0-5, but nobody scores any
+// candidate above 4, the range will be 0-4.
+func getRange(vs [][]int) int {
+	var max, min int
+	for _, r := range vs {
+		for _, c := range r {
+			if c > max {
+				max = c
+			}
+			if c < min {
+				min = c
+			}
+		}
+	}
+	return max - min
+}
+
+// subtotal sums up the columns, and returns a single row of the sums
+func subtotal(lines [][]int) []int {
+	if len(lines) < 1 {
+		return []int{}
+	}
+	ttls := make([]int, len(lines[0]))
+	for _, row := range lines {
+		for c, cell := range row {
+			ttls[c] += cell
+		}
+	}
+	return ttls
+}
+
+// winner finds the column with the highest sum of values in a matrix,
+// and returns the index of the winning column as well as the calculated
+// score. the winner by score.
+func winner(lines [][]int) (col int, score int) {
+	ts := subtotal(lines)
+	i := max(ts)
+	return i, ts[i]
+}
+
+// max finds the column with the highest score in a row of scores and returns the index of that row.
+func max(cols []int) (col int) {
+	var score int
+	for c, cell := range cols {
+		if cell > score {
+			col = c
+			score = cell
+		}
+	}
+	return col
+}
+
+// diff calculates the difference matrix based on the leader score. This is step 2 in SBB:
+// - Given r, the range of scores -- aka score max - score min
+// - If candidate score > leader score (for this voter), value is +r
+// - If candidate score < leader score (for this voter), value is -r
+// - The leader value is 0
+// - If the value of a canditate has the same value as the voter gave the lead candidate, that value is 0
+// The function returns a matrix where every cell is 0, r, or -r
+func diff(lines [][]int, lidx, r int) [][]int {
+	rv := make([][]int, 0, len(lines))
+	for _, row := range lines {
+		lls := row[lidx]
+		dr := make([]int, len(row))
+		for c, cell := range row {
+			if c == lidx {
+				continue
+			}
+			switch {
+			case cell < lls:
+				dr[c] = -r
+			case cell > lls:
+				dr[c] = r
+			default:
+				dr[c] = 0
+			}
+		}
+		rv = append(rv, dr)
+	}
+	return rv
+}
+
+// total adds two matrixes together. This is part of step 3, where the score totals are modified by the
+// difference matrix ([-r, 0, r]). It then calculates the total for each column and returns a single
+// row of those values, s.t. for each column C, the value is the score S plus the modifier M.
+//
+// The matrixes `lines` and `diffs` must have the same dimension. This is the *only* error that can
+// occur in this function, and it results in an empty array being returned.
+func total(lines [][]int, diffs [][]int) []int {
+	if len(lines) < 1 || len(diffs) != len(lines) {
+		return []int{}
+	}
+	ttls := make([]int, len(lines[0]))
+	for r, row := range lines {
+		dr := diffs[r]
+		if len(dr) != len(row) {
+			return []int{}
+		}
+		for c, cell := range row {
+			ttls[c] += cell + dr[c]
+		}
+	}
+	return ttls
+}
+
+/****************************************************************************************/
+/* The functions below are all utility functions not relevant to the vote calculations; */
+/* they print or convert arrays and values.                                             */
+/****************************************************************************************/
+
+// printDetails cut out from main() to keep main terse; nothing here is
+// relevant to the voting calculation, and is only output.
+func printDetails(wc int, lines, badVotes [][]string, ints [][]int, diffs [][]int, ttl []int) {
+	// The rest of this is just dumping out the calculations.
+	fmt.Printf("Total votes in input: %d\n", len(lines))
+	printWithHeader("Summary of distributions", true, uniq(lines))
+	subttl := subtotal(ints)
+	printWithHeader("Subtotals", false, subttl)
+	subWc := max(subttl)
+	fmt.Printf("Winner by score: %s with %d\n", toS(subWc), subttl[subWc])
+	printWithHeader("Summary of diffs", true, uniq(toString(diffs)))
+	printWithHeader("Modifiers", false, subtotal(diffs))
+	printWithHeader("Totals", false, ttl)
+	fmt.Printf("Highest combined total: %s with %d\n", toS(wc), ttl[wc])
+
+	if len(badVotes) > 0 {
+		fmt.Printf("\n%d records were invalid.", len(badVotes))
+		printWithHeader("The # column is the line number in the input file.", true, badVotes)
+	}
+}
+
+// uniq finds unique vote sets and combines them. The returned array is a
+// collapsed matrix of votes with the first column being the number of votes
+// that voted the same way. This is mostly a utility that makes reasoning
+// about the data easier for humans, and is not used in the voting calculation.
+func uniq(lines [][]string) [][]string {
+	counts := make(map[string]int)
+	for _, row := range lines {
+		rs := strings.Join(row, ",")
+		counts[rs] += 1
+	}
+	rv := make([][]string, 0, len(counts))
+	for k, v := range counts {
+		votes := strings.Split(k, ",")
+		row := make([]string, 0, len(votes)+1)
+		row = append(row, strconv.Itoa(v))
+		row = append(row, votes...)
+		rv = append(rv, row)
+	}
+	return rv
+}
+
+// strToInt converts an array*array of strings to array*array of ints
+// If the cell is empty, it is recorded as 0
+// If there are non-int values in the record, the entire record is kicked out
+// The return errs array contains error records, with the first value [0] being the
+// original array line number.
+func toInts(lines [][]string) (ints [][]int, errs [][]string) {
+	ints = make([][]int, 0, len(lines))
+	errs = make([][]string, 0)
+	var err error
+outer:
+	for r, srow := range lines {
+		irow := make([]int, len(srow))
+		for c, cell := range srow {
+			if cell != "" {
+				irow[c], err = strconv.Atoi(cell)
+				if err != nil {
+					nsrow := append([]string{strconv.Itoa(r)}, srow...)
+					errs = append(errs, nsrow)
+					continue outer
+				}
+			}
+		}
+		ints = append(ints, irow)
+	}
+	return ints, errs
+}
+
+func toString(lines [][]int) [][]string {
+	strs := make([][]string, 0, len(lines))
+	for _, srow := range lines {
+		irow := make([]string, len(srow))
+		for c, cell := range srow {
+			irow[c] = strconv.Itoa(cell)
+		}
+		strs = append(strs, irow)
+	}
+	return strs
+}
+
+// printWithHeader is a utility function to print out a matrix, with an
+// optional header where each column is a candidate (A, B, C, etc). Optionally,
+// the first column is a count of the number of votes that voted exactly
+// the same way.
+func printWithHeader(d string, count bool, v interface{}) {
+	fmt.Println(d)
+	switch a := v.(type) {
+	case [][]string:
+		if count {
+			sort.Slice(a, func(i, j int) bool {
+				return a[i][0] > a[j][0]
+			})
+		}
+		printHeader(len(a[0]), count)
+		for _, r := range a {
+			if !count {
+				fmt.Printf(" \t")
+			}
+			fmt.Println(strings.Join(r, "\t"))
+		}
+	case [][]int:
+		if count {
+			sort.Slice(a, func(i, j int) bool {
+				return a[i][0] > a[j][0]
+			})
+		}
+		printHeader(len(a[0]), count)
+		for _, r := range a {
+			if !count {
+				fmt.Printf(" \t")
+			}
+			for _, c := range r {
+				fmt.Printf("%d\t", c)
+			}
+			fmt.Println()
+		}
+	case []int:
+		if count {
+			sort.Slice(a, func(i, j int) bool {
+				return a[i] > a[j]
+			})
+		}
+		printHeader(len(a), count)
+		if !count {
+			fmt.Printf(" \t")
+		}
+		for _, c := range a {
+			fmt.Printf("%d\t", c)
+		}
+		fmt.Println()
+	default:
+	}
+}
+
+// printHeader prints a line of n candidates starting with candidate A. c
+// determines whether or not the array contains a count as the first column
+// of the number of votes in the first column.
+func printHeader(n int, c bool) {
+	if c {
+		n -= 1
+	}
+	fmt.Printf("#\t")
+	a := byte('A')
+	var i byte
+	m := byte(n)
+	for i = 0; i < m; i++ {
+		fmt.Printf("%s\t", string([]byte{a + i}))
+	}
+	fmt.Println()
+}
+
+// toS converts a column index to a candidate string; column 0 is candidate A,
+// column 1 is candidate B, and so on. 0-based.
+func toS(b int) string {
+	return string([]byte{'A' + byte(b)})
+}