Skip to content

Commit

Permalink
Merge pull request #39 from lzear/coombs
Browse files Browse the repository at this point in the history
Coombs
  • Loading branch information
lzear authored Jan 9, 2021
2 parents 38c8849 + aa6c010 commit 2c1f2ba
Show file tree
Hide file tree
Showing 17 changed files with 125 additions and 57 deletions.
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

[![version](https://img.shields.io/npm/v/votes)](https://www.npmjs.com/package/votes)
![bundle size](https://img.shields.io/bundlephobia/min/votes)
[![Known Vulnerabilities](https://snyk.io/test/github/lzear/votes/badge.svg?targetFile=package.json)](https://snyk.io/test/github/lzear/votes?targetFile=package.json)
![downloads](https://img.shields.io/npm/dm/votes)
![Semantic release](https://github.com/lzear/votes/workflows/Semantic%20release/badge.svg)
[![Build Status](https://travis-ci.com/lzear/votes.svg?branch=master)](https://travis-ci.com/lzear/votes)
[![Codacy Badge](https://app.codacy.com/project/badge/Coverage/d2378c63d95f41efb79072176f015976)](https://www.codacy.com/gh/lzear/votes/dashboard?utm_source=github.com&utm_medium=referral&utm_content=lzear/votes&utm_campaign=Badge_Coverage)
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/08af655918d741d1bffca7ec12ba72be)](https://app.codacy.com/gh/lzear/votes?utm_source=github.com&utm_medium=referral&utm_content=lzear/votes&utm_campaign=Badge_Grade_Settings)
[![CodeClimate Coverage](https://api.codeclimate.com/v1/badges/0a98aa30f16e04bc3eac/test_coverage)](https://codeclimate.com/github/lzear/votes/test_coverage)
[![CodeClimate Maintainability](https://api.codeclimate.com/v1/badges/0a98aa30f16e04bc3eac/maintainability)](https://codeclimate.com/github/lzear/votes/maintainability)
[![CodeFactor](https://www.codefactor.io/repository/github/lzear/votes/badge)](https://www.codefactor.io/repository/github/lzear/votes)
![codecov](https://codecov.io/gh/lzear/votes/branch/master/graph/badge.svg?token=Fd9Jk4FeBY)
![last commit](https://img.shields.io/github/last-commit/lzear/votes)
![language](https://img.shields.io/github/languages/top/lzear/votes)
Expand Down Expand Up @@ -49,11 +51,10 @@ See
[Comparison of electoral systems (Wikipedia)](https://en.wikipedia.org/wiki/Comparison_of_electoral_systems)
for more information.

**⚠️Maximal lotteries & Randomized Condorcet⚠️** (Errors included):
Returns probabilities for each candidate that should be used for a lottery
between the Candidates. If a candidate is the Condorcet winner, its
probability will be 1. Despite being non-deterministic,
those methods are the most fair.
**⚠️Maximal lotteries & Randomized Condorcet⚠️** (Errors included): Returns
probabilities for each candidate that should be used for a lottery between the
Candidates. If a candidate is the Condorcet winner, its probability will be 1.
Despite being non-deterministic, those methods are the most fair.

**Ranked pairs**: Using the duel results as edges, build an acyclic graph
starting by the strongest score differences. The roots of the graph are the
Expand Down Expand Up @@ -81,12 +82,16 @@ The winner is the most-approved candidate.
**Borda's count**: For each voter, every candidate is given a number of points
which equals the number of candidates ranked lower in the voter's preference.

**Instant runoff**: Considering only the top choice of each voter, the candidate
**Instant-runoff**: Considering only the top choice of each voter, the candidate
with the fewest votes is eliminated. The election repeats until there is a
winner. This voting system is very similar to single transferable vote method.

**Two-round system**: If no candidate receives 50% of the votes in the first
round, then a second round of voting is held with only the top two candidates.
**Coombs' method**: Each round, the candidate with the most last rank is
eliminated. The election repeats until there is a winner.

**Contingent vote** (immediate **Two-round system**): If no candidate receives
50% of the votes in the first round, then a second round of voting is held with
only the top two candidates.

**Plurality**: Simple voting method where only the preferred candidate of each
voter gets 1 point. AKA first-past-the-post.
Expand Down
4 changes: 2 additions & 2 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ coverage:
status:
project:
default:
target: 90% # the required coverage value
threshold: 1% # the leniency in hitting the target
target: 90% # the required coverage value
threshold: 1% # the leniency in hitting the target
patch:
default:
target: 80%
3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@
"url": "https://github.com/lzear/votes.git"
},
"license": "MIT",
"engines": {
"node": ">=6.0.0"
},
"scripts": {
"eslint": "eslint --ext .js --ext .ts .",
"fix:prettier": "prettier --write \"**/*.*\"",
Expand Down
2 changes: 1 addition & 1 deletion src/methods/approbation/approbation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ import { approbation } from '.'
it('skips empty votes', () => {
expect(
approbation.computeFromBallots([{ weight: 1, ranking: [] }], ['a']),
).toMatchObject({})
).toStrictEqual({ a: 0 })
})
3 changes: 2 additions & 1 deletion src/methods/borda/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import {
Ballot,
} from '../../types'
import { scoresZero } from '../../utils/scoresZero'
import { normalizeBallots } from '../../utils'

export const borda: SystemUsingRankings = {
type: VotingSystem.Borda,
computeFromBallots(ballots: Ballot[], candidates: string[]): ScoreObject {
const result: ScoreObject = scoresZero(candidates)
ballots.forEach((ballot) => {
normalizeBallots(ballots, candidates).forEach((ballot) => {
let voteValue = candidates.length - 1
ballot.ranking.forEach((candidatesAtRank) => {
const value = voteValue - (candidatesAtRank.length - 1) / 2
Expand Down
33 changes: 33 additions & 0 deletions src/methods/coombs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import difference from 'lodash/difference'
import {
SystemUsingRankings,
ScoreObject,
VotingSystem,
Ballot,
} from '../../types'
import { firstPastThePost } from '../first-past-the-post'
import { scoresToRanking } from '../../utils'

export const coombs: SystemUsingRankings = {
type: VotingSystem.Coombs,
computeFromBallots(ballots: Ballot[], candidates: string[]): ScoreObject {
const score: ScoreObject = {}
const reversedBallots = ballots.map((ballot) => ({
ranking: [...ballot.ranking].reverse(),
weight: ballot.weight,
}))
let remainingCandidates = [...candidates]
let points = 0
while (remainingCandidates.length > 0) {
const fptpScore = firstPastThePost.computeFromBallots(
reversedBallots,
remainingCandidates,
)
const ranking = scoresToRanking(fptpScore)
for (const winner of ranking[0]) score[winner] = points
remainingCandidates = difference(remainingCandidates, ranking[0])
points++
}
return score
},
}
2 changes: 1 addition & 1 deletion src/methods/first-past-the-post/fptp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ import { firstPastThePost } from '.'
it('skips empty votes', () => {
expect(
firstPastThePost.computeFromBallots([{ weight: 1, ranking: [] }], ['a']),
).toMatchObject({})
).toStrictEqual({ a: 0 })
})
3 changes: 2 additions & 1 deletion src/methods/first-past-the-post/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import {
Ballot,
} from '../../types'
import { scoresZero } from '../../utils/scoresZero'
import { normalizeBallots } from '../../utils'

export const iterateFirstChoices = (
ballots: Ballot[],
candidates: string[],
compute: (rank: string[]) => number,
): ScoreObject => {
const result: ScoreObject = scoresZero(candidates)
ballots.forEach((ballot) => {
normalizeBallots(ballots, candidates).forEach((ballot) => {
if (ballot.ranking.length) {
const votes = ballot.ranking[0].filter((c) => candidates.includes(c))
votes.forEach(
Expand Down
2 changes: 2 additions & 0 deletions src/methods/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { VotingSystem } from '../types'
import { approbation } from './approbation'
import { borda } from './borda'
import { coombs } from './coombs'
import { copeland } from './copeland'
import { firstPastThePost } from './first-past-the-post'
import { instantRunoff } from './instant-runoff'
Expand All @@ -15,6 +16,7 @@ import { twoRoundRunoff } from './two-round-runoff'
export const methods = {
[VotingSystem.Approbation]: approbation,
[VotingSystem.Borda]: borda,
[VotingSystem.Coombs]: coombs,
[VotingSystem.Copeland]: copeland,
[VotingSystem.FirstPastThePost]: firstPastThePost,
[VotingSystem.InstantRunoff]: instantRunoff,
Expand Down
2 changes: 1 addition & 1 deletion src/methods/two-round-runoff/two-round-runoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ import { twoRoundRunoff } from '.'
it('skips empty votes', () => {
expect(
twoRoundRunoff.computeFromBallots([{ weight: 1, ranking: [] }], ['a']),
).toMatchObject({})
).toStrictEqual({ a: 0 })
})
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type ScoreObject = { [candidate: string]: number }
export enum VotingSystem {
Approbation = 'APPROBATION',
Borda = 'BORDA',
Coombs = 'COOMBS',
Copeland = 'COPELAND',
FirstPastThePost = 'FIRST_PAST_THE_POST',
Kemeny = 'KEMENY',
Expand Down
2 changes: 1 addition & 1 deletion src/utils/makeMatrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { makeAntisymetric, matrixFromBallots } from '.'
import { abcde, balinski } from '../test/testUtils'

it('makes antisymetric', () => {
expect(makeAntisymetric(matrixFromBallots(balinski, abcde))).toMatchObject({
expect(makeAntisymetric(matrixFromBallots(balinski, abcde))).toStrictEqual({
array: [
[0, -34, -34, -34, -28],
[34, 0, -2, 58, 4],
Expand Down
12 changes: 6 additions & 6 deletions src/utils/normalize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ it('groups ballots', () => {
{ weight: 4, ranking: [['d', 'b'], ['c']] },
{ weight: 5, ranking: [['a', 'b'], ['c']] },
]),
).toMatchObject([
).toStrictEqual([
{ weight: 8, ranking: [['a', 'b'], ['c']] },
{ weight: 4, ranking: [['d', 'b'], ['c']] },
])
Expand All @@ -31,13 +31,13 @@ it('throw on duplicated candidates', () => {
})

it('removes duplicated candidates', () => {
expect(removeDuplicatedCandidates([['a', 'b'], ['a']])).toMatchObject([
expect(removeDuplicatedCandidates([['a', 'b'], ['a']])).toStrictEqual([
['a', 'b'],
])
})

it('normalizes rankinput', () => {
expect(normalizeRankinput([['a', 'b'], 'c', 'd', ['e']])).toMatchObject([
expect(normalizeRankinput([['a', 'b'], 'c', 'd', ['e']])).toStrictEqual([
['a', 'b'],
['c'],
['d'],
Expand All @@ -52,7 +52,7 @@ it('gets candidates from ballots', () => {
{ weight: 4, ranking: [['d', 'b'], ['c']] },
{ weight: 5, ranking: [['a', 'b'], ['c']] },
]),
).toMatchObject(['a', 'b', 'c', 'd'])
).toStrictEqual(['a', 'b', 'c', 'd'])
})

it('normalizes ballots', () => {
Expand All @@ -62,7 +62,7 @@ it('normalizes ballots', () => {
{ weight: 4, ranking: [['d', 'b'], [], ['c']] },
{ weight: 5, ranking: [['a', 'b'], ['c']] },
]),
).toMatchObject([
).toStrictEqual([
{ weight: 3, ranking: [['a', 'b'], ['c']] },
{ weight: 4, ranking: [['d', 'b'], ['c']] },
{ weight: 5, ranking: [['a', 'b'], ['c']] },
Expand All @@ -76,7 +76,7 @@ it('normalizes ballots', () => {
],
['a', 'b', 'c'],
),
).toMatchObject([
).toStrictEqual([
{ weight: 3, ranking: [['a', 'b'], ['c']] },
{ weight: 4, ranking: [['b'], ['c']] },
{ weight: 5, ranking: [['a', 'b'], ['c']] },
Expand Down
34 changes: 27 additions & 7 deletions src/utils/sanity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,31 @@ import { scoresFromBallots, scoresToRanking } from '.'

describe('sanity check', () => {
it.each(Object.values(VotingSystem) as VotingSystem[])(
'empty list %p',
'empty list and empty candidates %p',
(system) => {
expect(scoresToRanking(scoresFromBallots([], [], system))).toStrictEqual(
[],
)
},
)
it.each(Object.values(VotingSystem) as VotingSystem[])(
'empty ballot list %p',
(system) => {
expect(
scoresToRanking(scoresFromBallots([], ['a', 'b', 'c'], system))[0],
).toMatchObject(['a', 'b', 'c'])
).toStrictEqual(['a', 'b', 'c'])
},
)
it.each(Object.values(VotingSystem) as VotingSystem[])(
'empty candidates list %p',
(system) => {
expect(
scoresFromBallots(
[{ ranking: [['a'], ['b'], ['c']], weight: 1 }],
[],
system,
),
).toStrictEqual({})
},
)
it.each(Object.values(VotingSystem) as VotingSystem[])(
Expand All @@ -22,7 +42,7 @@ describe('sanity check', () => {
system,
),
)[0],
).toMatchObject(['a'])
).toStrictEqual(['a'])
},
)

Expand All @@ -37,7 +57,7 @@ describe('sanity check', () => {
system,
),
)[0],
).toMatchObject(['a', 'd'])
).toStrictEqual(['a', 'd'])
},
)

Expand All @@ -56,23 +76,23 @@ describe('sanity check', () => {
system,
),
)[0],
).toMatchObject(['a', 'b', 'c'])
).toStrictEqual(['a', 'b', 'c'])
},
)
it.each(Object.values(VotingSystem) as VotingSystem[])(
'dummyProfile %p',
(system) => {
expect(
scoresToRanking(scoresFromBallots(dummyProfile, abcde, system))[0],
).toMatchObject(['a'])
).toStrictEqual(['a'])
},
)
it.each(Object.values(VotingSystem) as VotingSystem[])(
'dummyProfile10 %p',
(system) => {
expect(
scoresToRanking(scoresFromBallots(dummyProfile10, abcde, system))[0],
).toMatchObject(['a'])
).toStrictEqual(['a'])
},
)
})
2 changes: 1 addition & 1 deletion src/utils/scores.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { scoresToRanking } from './scores'

it('convert scores to ranking', () => {
expect(scoresToRanking({ a: 10, b: 5, c: 15 })).toMatchObject([
expect(scoresToRanking({ a: 10, b: 5, c: 15 })).toStrictEqual([
['c'],
['a'],
['b'],
Expand Down
Loading

0 comments on commit 2c1f2ba

Please sign in to comment.