@snapshot-labs/snapshot.js 0.14.9 → 0.14.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/snapshot.cjs.js +156 -58
- package/dist/snapshot.esm.js +156 -58
- package/dist/snapshot.min.js +1 -1
- package/dist/src/multicall/starknet.d.ts +1 -1
- package/dist/src/voting/rankedChoice.d.ts +64 -0
- package/package.json +1 -1
- package/src/multicall/starknet.ts +70 -58
- package/src/networks.json +3 -3
- package/src/voting/rankedChoice.spec.js +172 -4
- package/src/voting/rankedChoice.ts +87 -7
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import { RpcProvider } from 'starknet';
|
|
2
|
-
export default function multicall(address: string, provider: RpcProvider, abi: any[], calls: any[], limit: number, options?: Record<string, any>): Promise<any[]
|
|
2
|
+
export default function multicall(address: string, provider: RpcProvider, abi: any[], calls: any[], limit: number, options?: Record<string, any>): Promise<any[]>;
|
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
import { RankedChoiceVote, Strategy } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Runs the complete Instant Runoff Voting (IRV) algorithm and returns the final results.
|
|
4
|
+
*
|
|
5
|
+
* Executes all elimination rounds until a winner is determined or fewer than 3 candidates remain.
|
|
6
|
+
* Each round eliminates the candidate with the fewest votes and redistributes their votes
|
|
7
|
+
* to voters' next preferences.
|
|
8
|
+
*
|
|
9
|
+
* @param votes - Array of valid ranked choice votes to process
|
|
10
|
+
* @returns Array of tuples representing the final candidate rankings, sorted by vote count (highest first).
|
|
11
|
+
* Each tuple contains [candidateIndex, [totalBalance, scoresArray]] where:
|
|
12
|
+
* - totalBalance: Sum of voting power from all voters who support this candidate
|
|
13
|
+
* - scoresArray: Breakdown of that voting power by voting strategy
|
|
14
|
+
* The relationship: totalBalance === scoresArray.reduce((a,b) => a + b, 0)
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* // Returns final results after IRV elimination rounds
|
|
18
|
+
* // [["2", [150, [60,50,40]]], ["1", [120, [70,30,20]]], ...]
|
|
19
|
+
* // Candidate 2 wins with 150 total voting power (60+50+40 from 3 strategies)
|
|
20
|
+
* // Candidate 1 has 120 total voting power (70+30+20 from 3 strategies)
|
|
21
|
+
*/
|
|
22
|
+
export declare function getFinalRound(votes: RankedChoiceVote[]): [string, [number, number[]]][];
|
|
2
23
|
export default class RankedChoiceVoting {
|
|
3
24
|
proposal: {
|
|
4
25
|
choices: string[];
|
|
@@ -13,6 +34,49 @@ export default class RankedChoiceVoting {
|
|
|
13
34
|
getValidVotes(): RankedChoiceVote[];
|
|
14
35
|
getScores(): number[];
|
|
15
36
|
getScoresByStrategy(): number[][];
|
|
37
|
+
/**
|
|
38
|
+
* Returns the total voting power from all submitted votes, including invalid ones.
|
|
39
|
+
*
|
|
40
|
+
* This method sums the balance (voting power) from ALL votes submitted to the proposal,
|
|
41
|
+
* regardless of whether they have valid choice arrays. This is useful for calculating
|
|
42
|
+
* total participation, quorum requirements, and percentage of total voting power.
|
|
43
|
+
*
|
|
44
|
+
* Note: This differs from IRV final results which only include valid votes. Invalid votes
|
|
45
|
+
* are excluded from IRV calculations but their voting power is still counted here for
|
|
46
|
+
* participation metrics.
|
|
47
|
+
*
|
|
48
|
+
* @returns Total voting power from all votes (valid + invalid)
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* // votes = [
|
|
52
|
+
* // { choice: [1,2,3,4], balance: 1000 }, // Valid
|
|
53
|
+
* // { choice: [1,5,2], balance: 500 }, // Invalid (index 5)
|
|
54
|
+
* // { choice: [2,1,4,3], balance: 750 } // Valid
|
|
55
|
+
* // ]
|
|
56
|
+
* // Returns: 2250 (includes invalid vote's 500 balance)
|
|
57
|
+
*/
|
|
16
58
|
getScoresTotal(): number;
|
|
59
|
+
/**
|
|
60
|
+
* Converts the selected choice indices into a human-readable string representation.
|
|
61
|
+
*
|
|
62
|
+
* Note: This method supports partial ranking where not all available choices
|
|
63
|
+
* need to be selected. The ordinal positions (1st, 2nd, etc.) reflect the
|
|
64
|
+
* order of valid selections only. Invalid choice indices are filtered out.
|
|
65
|
+
*
|
|
66
|
+
* @returns A formatted string showing the ranked choices with ordinal positions.
|
|
67
|
+
* Only valid choices are included, invalid indices are silently ignored.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* // With choices ['Alice', 'Bob', 'Carol', 'David'] and selected [1, 3, 2]
|
|
71
|
+
* // Returns: "(1st) Alice, (2nd) Carol, (3rd) Bob"
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* // Partial ranking with choices ['Alice', 'Bob', 'Carol', 'David'] and selected [4, 1]
|
|
75
|
+
* // Returns: "(1st) David, (2nd) Alice"
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* // With invalid choice index 5 in selected [1, 5]
|
|
79
|
+
* // Returns: "(1st) Alice" - invalid choice 5 is filtered out
|
|
80
|
+
*/
|
|
17
81
|
getChoiceString(): string;
|
|
18
82
|
}
|
package/package.json
CHANGED
|
@@ -17,67 +17,78 @@ function parseStarknetResult(rawResult: string[], functionAbi: any): any {
|
|
|
17
17
|
return rawResult;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
const
|
|
20
|
+
// Parse each output according to its type
|
|
21
|
+
const outputs = functionAbi.outputs;
|
|
22
|
+
const results: any[] = [];
|
|
23
|
+
let rawIndex = 0; // Track position in rawResult array
|
|
22
24
|
|
|
23
25
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
26
|
+
for (let outputIndex = 0; outputIndex < outputs.length; outputIndex++) {
|
|
27
|
+
const output = outputs[outputIndex];
|
|
28
|
+
const rawValue = rawResult[rawIndex];
|
|
29
|
+
|
|
30
|
+
switch (output.type) {
|
|
31
|
+
case 'core::felt252':
|
|
32
|
+
try {
|
|
33
|
+
results.push(shortString.decodeShortString(rawValue));
|
|
34
|
+
} catch {
|
|
35
|
+
results.push(rawValue);
|
|
36
|
+
}
|
|
37
|
+
rawIndex++;
|
|
38
|
+
break;
|
|
39
|
+
case 'core::integer::u8':
|
|
40
|
+
case 'core::integer::u16':
|
|
41
|
+
case 'core::integer::u32':
|
|
42
|
+
case 'core::integer::u64':
|
|
43
|
+
results.push(parseInt(rawValue, 16));
|
|
44
|
+
rawIndex++;
|
|
45
|
+
break;
|
|
46
|
+
case 'core::integer::u128':
|
|
47
|
+
case 'core::integer::usize':
|
|
48
|
+
results.push(BigInt(rawValue).toString());
|
|
49
|
+
rawIndex++;
|
|
50
|
+
break;
|
|
51
|
+
case 'core::integer::u256':
|
|
52
|
+
results.push(
|
|
53
|
+
uint256.uint256ToBN({
|
|
54
|
+
low: rawValue,
|
|
55
|
+
high: rawResult[rawIndex + 1] || '0x0'
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
rawIndex += 2; // u256 uses two slots
|
|
59
|
+
break;
|
|
60
|
+
case 'core::integer::i8':
|
|
61
|
+
case 'core::integer::i16':
|
|
62
|
+
case 'core::integer::i32':
|
|
63
|
+
case 'core::integer::i64':
|
|
64
|
+
results.push(parseInt(rawValue, 16));
|
|
65
|
+
rawIndex++;
|
|
66
|
+
break;
|
|
67
|
+
case 'core::integer::i128':
|
|
68
|
+
results.push(BigInt(rawValue).toString());
|
|
69
|
+
rawIndex++;
|
|
70
|
+
break;
|
|
71
|
+
case 'core::bool':
|
|
72
|
+
results.push(rawValue === '0x1' || rawValue === '0x01');
|
|
73
|
+
rawIndex++;
|
|
74
|
+
break;
|
|
75
|
+
case 'core::starknet::contract_address::ContractAddress':
|
|
76
|
+
case 'core::starknet::class_hash::ClassHash':
|
|
77
|
+
case 'core::starknet::storage_access::StorageAddress':
|
|
78
|
+
results.push(rawValue);
|
|
79
|
+
rawIndex++;
|
|
80
|
+
break;
|
|
81
|
+
case 'core::bytes_31::bytes31':
|
|
82
|
+
results.push(rawValue);
|
|
83
|
+
rawIndex++;
|
|
84
|
+
break;
|
|
85
|
+
default:
|
|
86
|
+
results.push(rawValue);
|
|
87
|
+
rawIndex++;
|
|
88
|
+
}
|
|
78
89
|
}
|
|
90
|
+
return results;
|
|
79
91
|
} catch {
|
|
80
|
-
// Fallback to raw result if parsing fails
|
|
81
92
|
return rawResult;
|
|
82
93
|
}
|
|
83
94
|
}
|
|
@@ -149,6 +160,7 @@ export default async function multicall(
|
|
|
149
160
|
const [, functionName] = calls[index];
|
|
150
161
|
const functionAbi = abi.find((item) => item.name === functionName);
|
|
151
162
|
|
|
152
|
-
|
|
163
|
+
const parsedResult = parseStarknetResult(result, functionAbi);
|
|
164
|
+
return parsedResult;
|
|
153
165
|
});
|
|
154
166
|
}
|
package/src/networks.json
CHANGED
|
@@ -1567,10 +1567,10 @@
|
|
|
1567
1567
|
"https://celo-mainnet--rpc.datahub.figment.io/apikey/e892a66dc36e4d2d98a5d6406d609796/"
|
|
1568
1568
|
],
|
|
1569
1569
|
"explorer": {
|
|
1570
|
-
"url": "https://
|
|
1570
|
+
"url": "https://celoscan.io"
|
|
1571
1571
|
},
|
|
1572
1572
|
"start": 6599803,
|
|
1573
|
-
"logo": "ipfs://
|
|
1573
|
+
"logo": "ipfs://bafkreidvcofeczigbjr7ddapgdugwso6v2l4iolfxys7qg6kfvu2uduyva"
|
|
1574
1574
|
},
|
|
1575
1575
|
"43113": {
|
|
1576
1576
|
"key": "43113",
|
|
@@ -2036,4 +2036,4 @@
|
|
|
2036
2036
|
"start": 7,
|
|
2037
2037
|
"logo": "ipfs://bafkreihbjafyh7eud7r6e5743esaamifcttsvbspfwcrfoc5ykodjdi67m"
|
|
2038
2038
|
}
|
|
2039
|
-
}
|
|
2039
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { test, expect } from 'vitest';
|
|
2
|
-
import RankedChoiceVoting from './rankedChoice';
|
|
1
|
+
import { test, expect, describe } from 'vitest';
|
|
2
|
+
import RankedChoiceVoting, { getFinalRound } from './rankedChoice';
|
|
3
3
|
import example from './examples/rankedChoice.json';
|
|
4
4
|
|
|
5
|
+
const TEST_CHOICES = ['Alice', 'Bob', 'Carol', 'David'];
|
|
6
|
+
|
|
5
7
|
const example2 = () => {
|
|
6
8
|
// Example with multiple (3) strategies
|
|
7
9
|
const proposal = {
|
|
8
|
-
choices:
|
|
10
|
+
choices: TEST_CHOICES
|
|
9
11
|
};
|
|
10
12
|
const strategies = [
|
|
11
13
|
{ name: 'ticket', network: 1, params: {} },
|
|
@@ -153,6 +155,37 @@ test.each(getScoresByStrategyTests)(
|
|
|
153
155
|
}
|
|
154
156
|
);
|
|
155
157
|
|
|
158
|
+
test('getScoresByStrategy should handle empty scores array from getFinalRound', () => {
|
|
159
|
+
// Using same votes as majority winner test where some choices have no first-place votes
|
|
160
|
+
const votes = [
|
|
161
|
+
{ choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice
|
|
162
|
+
{ choice: [1, 3, 2, 4], balance: 200, scores: [200] }, // First: Alice
|
|
163
|
+
{ choice: [1, 4, 2, 3], balance: 150, scores: [150] }, // First: Alice
|
|
164
|
+
{ choice: [2, 1, 3, 4], balance: 50, scores: [50] } // First: Bob
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
const proposal = { choices: TEST_CHOICES };
|
|
168
|
+
const strategies = [{ name: 'ticket', network: 1, params: {} }];
|
|
169
|
+
|
|
170
|
+
const ranked = new RankedChoiceVoting(
|
|
171
|
+
proposal,
|
|
172
|
+
votes,
|
|
173
|
+
strategies,
|
|
174
|
+
example.selectedChoice
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// This should not throw an error when reduce encounters empty scores arrays
|
|
178
|
+
const result = ranked.getScoresByStrategy();
|
|
179
|
+
|
|
180
|
+
// Expected: Alice gets all her votes (450), Bob gets his (50), Carol and David get 0
|
|
181
|
+
expect(result).toEqual([
|
|
182
|
+
[450], // Alice: 100 + 200 + 150
|
|
183
|
+
[50], // Bob: 50
|
|
184
|
+
[0], // Carol: no first-place votes
|
|
185
|
+
[0] // David: no first-place votes
|
|
186
|
+
]);
|
|
187
|
+
});
|
|
188
|
+
|
|
156
189
|
const getScoresTotalTests = [
|
|
157
190
|
[example.proposal, example.votes, example.strategies, example.scoresTotal],
|
|
158
191
|
[
|
|
@@ -178,7 +211,17 @@ test.each(getScoresTotalTests)(
|
|
|
178
211
|
|
|
179
212
|
test.each([
|
|
180
213
|
[[1, 2, 3, 4], '(1st) Alice, (2nd) Bob, (3rd) Carol, (4th) David'],
|
|
181
|
-
[[4, 2, 3, 1], '(1st) David, (2nd) Bob, (3rd) Carol, (4th) Alice']
|
|
214
|
+
[[4, 2, 3, 1], '(1st) David, (2nd) Bob, (3rd) Carol, (4th) Alice'],
|
|
215
|
+
// Invalid choices (out of range indices)
|
|
216
|
+
[[5], ''], // Choice index 5 doesn't exist (only 1-4 available)
|
|
217
|
+
[[0], ''], // Choice index 0 is invalid (choices are 1-indexed)
|
|
218
|
+
[[-1], ''], // Negative choice index
|
|
219
|
+
[[1, 5], '(1st) Alice'], // Mix of valid (1) and invalid (5) - invalid filtered out
|
|
220
|
+
[[0, 2], '(1st) Bob'], // Mix of invalid (0) and valid (2) - invalid filtered out
|
|
221
|
+
[[5, 6, 7], ''], // All invalid indices - all filtered out
|
|
222
|
+
[[1, 0, 3], '(1st) Alice, (2nd) Carol'], // Valid-invalid-valid pattern - invalid filtered out
|
|
223
|
+
[[100], ''], // Very high invalid index
|
|
224
|
+
[[], ''] // Empty array
|
|
182
225
|
])('getChoiceString %s %s', (selected, expected) => {
|
|
183
226
|
const ranked = new RankedChoiceVoting(
|
|
184
227
|
example.proposal,
|
|
@@ -188,3 +231,128 @@ test.each([
|
|
|
188
231
|
);
|
|
189
232
|
expect(ranked.getChoiceString()).toEqual(expected);
|
|
190
233
|
});
|
|
234
|
+
|
|
235
|
+
describe('isValidChoice', () => {
|
|
236
|
+
test.each([
|
|
237
|
+
[[1, 2, 3, 4], TEST_CHOICES],
|
|
238
|
+
[[4, 3, 2, 1], TEST_CHOICES],
|
|
239
|
+
[[2, 1, 4, 3], TEST_CHOICES],
|
|
240
|
+
[[1], ['Alice']]
|
|
241
|
+
])('should accept valid ranked choice: %s', (voteChoice, proposalChoices) => {
|
|
242
|
+
expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe(
|
|
243
|
+
true
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test.each([
|
|
248
|
+
['not-array', TEST_CHOICES],
|
|
249
|
+
[null, TEST_CHOICES],
|
|
250
|
+
[undefined, TEST_CHOICES]
|
|
251
|
+
])('should reject non-array input: %s', (voteChoice, proposalChoices) => {
|
|
252
|
+
expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe(
|
|
253
|
+
false
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test.each([
|
|
258
|
+
[[], TEST_CHOICES],
|
|
259
|
+
[[], []]
|
|
260
|
+
])('should reject empty choice array: %s', (voteChoice, proposalChoices) => {
|
|
261
|
+
expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe(
|
|
262
|
+
false
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test.each([
|
|
267
|
+
[[1, 5], TEST_CHOICES],
|
|
268
|
+
[[0, 1, 2, 3, 4], TEST_CHOICES],
|
|
269
|
+
[[-1, 1, 2, 3], TEST_CHOICES],
|
|
270
|
+
[[100], TEST_CHOICES]
|
|
271
|
+
])(
|
|
272
|
+
'should reject out-of-range indices: %s',
|
|
273
|
+
(voteChoice, proposalChoices) => {
|
|
274
|
+
expect(
|
|
275
|
+
RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)
|
|
276
|
+
).toBe(false);
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
test.each([
|
|
281
|
+
[[1, 1, 2, 3], TEST_CHOICES],
|
|
282
|
+
[[1, 2, 2, 3], TEST_CHOICES],
|
|
283
|
+
[
|
|
284
|
+
[1, 1],
|
|
285
|
+
['Alice', 'Bob']
|
|
286
|
+
]
|
|
287
|
+
])('should reject duplicate choices: %s', (voteChoice, proposalChoices) => {
|
|
288
|
+
expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe(
|
|
289
|
+
false
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test.each([
|
|
294
|
+
[[1], TEST_CHOICES],
|
|
295
|
+
[[1, 2], TEST_CHOICES]
|
|
296
|
+
])(
|
|
297
|
+
'should reject incomplete ranking when multiple choices available: %s',
|
|
298
|
+
(voteChoice, proposalChoices) => {
|
|
299
|
+
expect(
|
|
300
|
+
RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)
|
|
301
|
+
).toBe(false);
|
|
302
|
+
}
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('getFinalRound', () => {
|
|
307
|
+
test('should execute instant runoff voting with multiple elimination rounds', () => {
|
|
308
|
+
const votes = [
|
|
309
|
+
{ choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice
|
|
310
|
+
{ choice: [2, 1, 3, 4], balance: 200, scores: [200] }, // First: Bob
|
|
311
|
+
{ choice: [3, 1, 2, 4], balance: 150, scores: [150] }, // First: Carol
|
|
312
|
+
{ choice: [4, 1, 2, 3], balance: 50, scores: [50] } // First: David
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
const result = getFinalRound(votes);
|
|
316
|
+
|
|
317
|
+
expect(result).toEqual([
|
|
318
|
+
['2', [350, [350]]], // Bob wins with 350 total (200+150 after IRV)
|
|
319
|
+
['3', [150, [150]]] // Carol second with 150
|
|
320
|
+
]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('should handle choices with no first-place votes', () => {
|
|
324
|
+
const votes = [
|
|
325
|
+
{ choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice
|
|
326
|
+
{ choice: [1, 3, 2, 4], balance: 200, scores: [200] }, // First: Alice
|
|
327
|
+
{ choice: [2, 1, 3, 4], balance: 150, scores: [150] }, // First: Bob
|
|
328
|
+
{ choice: [2, 3, 1, 4], balance: 300, scores: [300] } // First: Bob
|
|
329
|
+
];
|
|
330
|
+
|
|
331
|
+
const result = getFinalRound(votes);
|
|
332
|
+
|
|
333
|
+
expect(result).toEqual([
|
|
334
|
+
['2', [450, [450]]], // Bob wins with 450 (150+300)
|
|
335
|
+
['1', [300, [300]]], // Alice second with 300 (100+200)
|
|
336
|
+
['3', [0, []]], // Carol has no first place votes - empty scores array (our fix)
|
|
337
|
+
['4', [0, []]] // David has no first place votes - empty scores array (our fix)
|
|
338
|
+
]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('should declare winner in first round when candidate has majority', () => {
|
|
342
|
+
const votes = [
|
|
343
|
+
{ choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice
|
|
344
|
+
{ choice: [1, 3, 2, 4], balance: 200, scores: [200] }, // First: Alice
|
|
345
|
+
{ choice: [1, 4, 2, 3], balance: 150, scores: [150] }, // First: Alice
|
|
346
|
+
{ choice: [2, 1, 3, 4], balance: 50, scores: [50] } // First: Bob
|
|
347
|
+
];
|
|
348
|
+
|
|
349
|
+
const result = getFinalRound(votes);
|
|
350
|
+
|
|
351
|
+
expect(result).toEqual([
|
|
352
|
+
['1', [450, [450]]], // Alice wins with majority in round 1 (100+200+150)
|
|
353
|
+
['2', [50, [50]]], // Bob gets remaining votes
|
|
354
|
+
['3', [0, []]], // Carol has no first place votes
|
|
355
|
+
['4', [0, []]] // David has no first place votes
|
|
356
|
+
]);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
@@ -66,7 +66,27 @@ function irv(
|
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Runs the complete Instant Runoff Voting (IRV) algorithm and returns the final results.
|
|
71
|
+
*
|
|
72
|
+
* Executes all elimination rounds until a winner is determined or fewer than 3 candidates remain.
|
|
73
|
+
* Each round eliminates the candidate with the fewest votes and redistributes their votes
|
|
74
|
+
* to voters' next preferences.
|
|
75
|
+
*
|
|
76
|
+
* @param votes - Array of valid ranked choice votes to process
|
|
77
|
+
* @returns Array of tuples representing the final candidate rankings, sorted by vote count (highest first).
|
|
78
|
+
* Each tuple contains [candidateIndex, [totalBalance, scoresArray]] where:
|
|
79
|
+
* - totalBalance: Sum of voting power from all voters who support this candidate
|
|
80
|
+
* - scoresArray: Breakdown of that voting power by voting strategy
|
|
81
|
+
* The relationship: totalBalance === scoresArray.reduce((a,b) => a + b, 0)
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* // Returns final results after IRV elimination rounds
|
|
85
|
+
* // [["2", [150, [60,50,40]]], ["1", [120, [70,30,20]]], ...]
|
|
86
|
+
* // Candidate 2 wins with 150 total voting power (60+50+40 from 3 strategies)
|
|
87
|
+
* // Candidate 1 has 120 total voting power (70+30+20 from 3 strategies)
|
|
88
|
+
*/
|
|
89
|
+
export function getFinalRound(
|
|
70
90
|
votes: RankedChoiceVote[]
|
|
71
91
|
): [string, [number, number[]]][] {
|
|
72
92
|
const rounds = irv(
|
|
@@ -77,7 +97,26 @@ function getFinalRound(
|
|
|
77
97
|
return finalRound.sortedByHighest;
|
|
78
98
|
}
|
|
79
99
|
|
|
80
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Converts IRV final results into a simple array of scores indexed by proposal choice order.
|
|
102
|
+
*
|
|
103
|
+
* Takes the ranked results from getFinalRound() (sorted by winner) and transforms them
|
|
104
|
+
* into an array where each position corresponds to the original proposal choice index.
|
|
105
|
+
* This allows easy lookup of any candidate's final vote total by their position in the proposal.
|
|
106
|
+
*
|
|
107
|
+
* @param votes - Array of valid ranked choice votes to process
|
|
108
|
+
* @param proposal - Proposal object containing the choices array
|
|
109
|
+
* @returns Array of total voting power for each choice, indexed by proposal order.
|
|
110
|
+
* Position 0 = first choice's votes, position 1 = second choice's votes, etc.
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* // proposal.choices = ['Alice', 'Bob', 'Carol', 'David']
|
|
114
|
+
* // After IRV: Bob won (150), David 2nd (120), Alice 3rd (100), Carol 4th (80)
|
|
115
|
+
* // Returns: [100, 150, 80, 120]
|
|
116
|
+
* // ↑ ↑ ↑ ↑
|
|
117
|
+
* // Alice Bob Carol David (proposal order)
|
|
118
|
+
*/
|
|
119
|
+
function getFinalScoresByChoice(
|
|
81
120
|
votes: RankedChoiceVote[],
|
|
82
121
|
proposal: { choices: string[] }
|
|
83
122
|
) {
|
|
@@ -134,7 +173,7 @@ export default class RankedChoiceVoting {
|
|
|
134
173
|
}
|
|
135
174
|
|
|
136
175
|
getScores(): number[] {
|
|
137
|
-
return
|
|
176
|
+
return getFinalScoresByChoice(this.getValidVotes(), this.proposal);
|
|
138
177
|
}
|
|
139
178
|
|
|
140
179
|
getScoresByStrategy(): number[][] {
|
|
@@ -148,16 +187,57 @@ export default class RankedChoiceVoting {
|
|
|
148
187
|
);
|
|
149
188
|
}
|
|
150
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Returns the total voting power from all submitted votes, including invalid ones.
|
|
192
|
+
*
|
|
193
|
+
* This method sums the balance (voting power) from ALL votes submitted to the proposal,
|
|
194
|
+
* regardless of whether they have valid choice arrays. This is useful for calculating
|
|
195
|
+
* total participation, quorum requirements, and percentage of total voting power.
|
|
196
|
+
*
|
|
197
|
+
* Note: This differs from IRV final results which only include valid votes. Invalid votes
|
|
198
|
+
* are excluded from IRV calculations but their voting power is still counted here for
|
|
199
|
+
* participation metrics.
|
|
200
|
+
*
|
|
201
|
+
* @returns Total voting power from all votes (valid + invalid)
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* // votes = [
|
|
205
|
+
* // { choice: [1,2,3,4], balance: 1000 }, // Valid
|
|
206
|
+
* // { choice: [1,5,2], balance: 500 }, // Invalid (index 5)
|
|
207
|
+
* // { choice: [2,1,4,3], balance: 750 } // Valid
|
|
208
|
+
* // ]
|
|
209
|
+
* // Returns: 2250 (includes invalid vote's 500 balance)
|
|
210
|
+
*/
|
|
151
211
|
getScoresTotal(): number {
|
|
152
212
|
return this.votes.reduce((a, b: any) => a + b.balance, 0);
|
|
153
213
|
}
|
|
154
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Converts the selected choice indices into a human-readable string representation.
|
|
217
|
+
*
|
|
218
|
+
* Note: This method supports partial ranking where not all available choices
|
|
219
|
+
* need to be selected. The ordinal positions (1st, 2nd, etc.) reflect the
|
|
220
|
+
* order of valid selections only. Invalid choice indices are filtered out.
|
|
221
|
+
*
|
|
222
|
+
* @returns A formatted string showing the ranked choices with ordinal positions.
|
|
223
|
+
* Only valid choices are included, invalid indices are silently ignored.
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* // With choices ['Alice', 'Bob', 'Carol', 'David'] and selected [1, 3, 2]
|
|
227
|
+
* // Returns: "(1st) Alice, (2nd) Carol, (3rd) Bob"
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* // Partial ranking with choices ['Alice', 'Bob', 'Carol', 'David'] and selected [4, 1]
|
|
231
|
+
* // Returns: "(1st) David, (2nd) Alice"
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* // With invalid choice index 5 in selected [1, 5]
|
|
235
|
+
* // Returns: "(1st) Alice" - invalid choice 5 is filtered out
|
|
236
|
+
*/
|
|
155
237
|
getChoiceString(): string {
|
|
156
238
|
return this.selected
|
|
157
|
-
.map((choice) =>
|
|
158
|
-
|
|
159
|
-
return this.proposal.choices[choice - 1];
|
|
160
|
-
})
|
|
239
|
+
.map((choice) => this.proposal.choices[choice - 1])
|
|
240
|
+
.filter(Boolean)
|
|
161
241
|
.map((el, i) => `(${getNumberWithOrdinal(i + 1)}) ${el}`)
|
|
162
242
|
.join(', ');
|
|
163
243
|
}
|