~dricottone/fiddler-231027

6a48adeee0de7a0710b0edbf1ec61e8f929ac201 — Dominic Ricottone 1 year, 16 days ago dev
Initial commit
10 files changed, 383 insertions(+), 0 deletions(-)

A .gitignore
A LICENSE.md
A Makefile
A README.md
A pyproject.toml
A vote/__init__.py
A vote/__main__.py
A vote/cli.py
A vote/cli.toml
A vote/vote.py
A  => .gitignore +3 -0
@@ 1,3 @@
**/__pycache__
**/__mypycache__
**/*.pyc

A  => LICENSE.md +31 -0
@@ 1,31 @@
BSD 3-Clause License
====================

_Copyright (c) 2023, Dominic Ricottone_  
_All rights reserved._

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
   list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
   this list of conditions and the following disclaimer in the documentation
   and/or other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its
   contributors may be used to endorse or promote products derived from
   this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


A  => Makefile +3 -0
@@ 1,3 @@
vote/cli.py: vote/cli.toml
	gap vote/cli.toml --no-debug-mode --output=vote/cli.py


A  => README.md +77 -0
@@ 1,77 @@
## The Problem

> The Fiddlerian government is in crisis.
> In particular, the Elephant Party, which possesses a majority of seats in the
> House (one of the two congressional chambers), can’t seem to select a new
> Speaker.
> At the moment, there are three candidates who want the job.
>
> So here’s their new plan to select a Speaker.
> All 221 members of the Elephant Party will get in a room and vote for one of
> the three candidates.
> Now, the party is known for its chaotic behavior, and sure enough, each voter
> will pick randomly from among the candidates—even if they themselves are one
> of the candidates!
>
> If one candidate earns the majority of the votes, they become the next
> Speaker!
> Otherwise, the candidate with the fewest votes is eliminated and they repeat
> the process with one less candidate.
> If two or more candidates receive the same smallest number of votes, then
> exactly one of them is eliminated at random.
>
> They might be able to select their speaker in the first round of voting.
> After a second round, if it occurs, they would definitely have a Speaker.
> That means the average number of rounds needed is somewhere between one and
> two.
> What is this average?


## My Solution

I attempted to solve this with a Monte Carlo simulation.
I quickly realized that a single-round incident was so unlikely that the
experiment has to include a ridiculous number of trials.

```
~/dev/fiddler-231027$ python -m vote -t 100
Majority in 2 rounds on average
~/dev/fiddler-231027$ python -m vote -t 10000
Majority in 2 rounds on average
~/dev/fiddler-231027$ python -m vote -t 1000000
Majority in 2 rounds on average
~/dev/fiddler-231027$ python -m vote -t 100000000
Majority in 1.9999994 rounds on average
```

The true answer is approximately 1.99999951.
So my simulated estimate was still about 0.000001 off and lacked a significant
digit.
Altogether, a great demonstration of when this approach isn't a good idea.

The advantage (I thought) to my approach is that I can easily adjust
parameters for the 'extra credit'.
Given 10 candidates instead of 3:

```
~/dev/fiddler-231027$ python -m vote -c 10 -t 100
Majority in 9 rounds on average
~/dev/fiddler-231027$ python -m vote -c 10 -t 10000
Majority in 9 rounds on average
~/dev/fiddler-231027$ python -m vote -c 10 -t 1000000
Majority in 9 rounds on average
```

So in actuality I am running into the same problem.
The true answer is approximately 8.99999951.
Of course, after 7 elimination rounds, this situation is the exact same problem
as the original 3 candidate situation.
So it's only necessary to determine the probability of the game terminating
earlier than that, and incorporate that into the original solution.
And given how small the likelihood is for forming a majority given 3
candidates, the likelihood for forming a majority given 4+ candidates is
infinitesimal.
In the end, it's the exact same solution plus 7 more elimination rounds.

Again, a great demonstration of when I should just use textbook statistics.


A  => pyproject.toml +17 -0
@@ 1,17 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[project]
name = "vote"
description = "Simulate a speaker election"
readme = "README.md"
version = "1.0.0"
authors = [ { name = "Dominic Ricottone", email = "me@dominic-ricottone.com" } ]
urls = { source = "git.dominic-ricottone.com/~dricottone/fiddler-231027" }
license = { file = "LICENSE.md" }
requires-python = ">=3.11"

[project.scripts]
vote = "vote.__main__:main"


A  => vote/__init__.py +0 -0
A  => vote/__main__.py +37 -0
@@ 1,37 @@
#!/usr/bin/env python

VERSION=(1,0,0,)

import sys

from . import cli
from . import vote

def main():
    _self = sys.argv[0]
    _config, _positionals = cli.main(sys.argv[1:])

    if "version" in _config.keys():
        sys.stderr.write(f"{_self}: {'.'.join(str(v) for v in VERSION)}\n")
        sys.exit(0)
    elif "help" in _config.keys():
        sys.stderr.write(f"Usage: {_self} [OPTIONS]\n")
        sys.stderr.write(f"Options:\n")
        sys.stderr.write(f"  -c=N, --candidates=N       number of candidates [Default: 3]\n")
        sys.stderr.write(f"  -h, -x, --help             print this message and exit\n")
        sys.stderr.write(f"  -r=N, --representatives=N  number of voting representatives [Default: 221]\n")
        sys.stderr.write(f"  -t=N, --trials=N           number of trials to run [Default: 1]\n")
        sys.stderr.write(f"  -v, -V, --version          print version and exit\n")
        sys.exit(0)

    _candidates = int(_config.get("candidates", 3))
    _representatives = int(_config.get("representatives", 221))
    _trials = int(_config.get("trials", 3))
    if _trials == 1:
        vote.run1(_candidates, _representatives)
    else:
        vote.runmany(_candidates, _representatives, _trials)

if __name__ == '__main__':
    main()


A  => vote/cli.py +142 -0
@@ 1,142 @@
#!/usr/bin/env python3

import re

def main(arguments):
	config=dict()
	positional=[]
	pattern=re.compile(r"(?:-(?:c|h|x|r|t|v|V)|--(?:candidates|help|representatives|trials|version))(?:=.*)?$")
	consuming,needing,wanting=None,0,0
	attached_value=None
	while len(arguments) and arguments[0]!="--":
		if consuming is not None:
			if config[consuming] is None:
				config[consuming]=arguments.pop(0)
			else:
				config[consuming].append(arguments.pop(0))
			needing-=1
			wanting-=1
			if wanting==0:
				consuming,needing,wanting=None,0,0
		elif pattern.match(arguments[0]):
			option = arguments.pop(0).lstrip('-')
			if '=' in option:
				option,attached_value=option.split('=',1)
			if option=="candidates":
				if attached_value is not None:
					config["candidates"]=attached_value
					attached_value=None
					consuming,needing,wanting=None,0,0
				else:
					config["candidates"]=None
					consuming,needing,wanting="candidates",1,1
			elif option=="help":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "help"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["help"]=True
			elif option=="representatives":
				if attached_value is not None:
					config["representatives"]=attached_value
					attached_value=None
					consuming,needing,wanting=None,0,0
				else:
					config["representatives"]=None
					consuming,needing,wanting="representatives",1,1
			elif option=="trials":
				if attached_value is not None:
					config["trials"]=attached_value
					attached_value=None
					consuming,needing,wanting=None,0,0
				else:
					config["trials"]=None
					consuming,needing,wanting="trials",1,1
			elif option=="version":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "version"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["version"]=True
			elif option=="c":
				if attached_value is not None:
					config["candidates"]=attached_value
					attached_value=None
					consuming,needing,wanting=None,0,0
				else:
					config["candidates"]=None
					consuming,needing,wanting="candidates",1,1
			elif option=="h":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "help"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["help"]=True
			elif option=="x":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "help"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["help"]=True
			elif option=="r":
				if attached_value is not None:
					config["representatives"]=attached_value
					attached_value=None
					consuming,needing,wanting=None,0,0
				else:
					config["representatives"]=None
					consuming,needing,wanting="representatives",1,1
			elif option=="t":
				if attached_value is not None:
					config["trials"]=attached_value
					attached_value=None
					consuming,needing,wanting=None,0,0
				else:
					config["trials"]=None
					consuming,needing,wanting="trials",1,1
			elif option=="v":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "version"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["version"]=True
			elif option=="V":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "version"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["version"]=True
		else:
			positional.append(arguments.pop(0))
	if needing>0:
		message=(
			f'unexpected end while parsing "{consuming}"'
			f' (expected {needing} values)'
		)
		raise ValueError(message) from None
	for argument in arguments[1:]:
		positional.append(argument)
	return config,positional

if __name__=="__main__":
	import sys
	cfg,pos = main(sys.argv[1:])
	cfg = {k:v for k,v in cfg.items() if v is not None}
	if len(cfg):
		print("Options:")
		for k,v in cfg.items():
			print(f"{k:20} = {v}")
	if len(pos):
		print("Positional arguments:", ", ".join(pos))

A  => vote/cli.toml +19 -0
@@ 1,19 @@
[candidates]
number = 1
alternatives = ['c']

[help]
number = 0
alternatives = ['h', 'x']

[representatives]
number = 1
alternatives = ['r']

[trials]
number = 1
alternatives = ['t']

[version]
number = 0
alternatives = ['v', 'V']

A  => vote/vote.py +54 -0
@@ 1,54 @@
#!/usr/bin/env python

import collections
import random
import statistics

def representative_vote(candidates: int) -> int:
    """Voters select at random from all available candidates."""
    return random.randint(1, candidates)

def chamber_vote(candidates: int, representatives: int) -> int:
    """All representatives of the chamber vote. A majority wins; else the
    candidate with the fewest voters is eliminated."""
    tally = collections.Counter()

    # Run vote
    for _ in range(representatives):
        tally[representative_vote(candidates)] += 1

    # Check for majority
    plurality = tally.most_common(1)[0]
    if (representatives / 2) < plurality[1]:
        return plurality[0]

    # Eliminate candidate with fewest votes; identity does not matter here
    return 0

def run1(candidates: int, representatives: int):
    majority = 0
    rounds = 0
    viable_candidates = candidates

    while not majority:
        rounds += 1
        majority = chamber_vote(viable_candidates, representatives)
        viable_candidates -= 1
    print(f"Majority in {rounds} rounds.")

def runmany(candidates: int, representatives: int, repeat: int):
    results = []
    for _ in range(repeat):
        majority = 0
        rounds = 0
        viable_candidates = candidates

        while not majority:
            rounds += 1
            majority = chamber_vote(viable_candidates, representatives)
            viable_candidates -= 1
        results.append(rounds)

    average = statistics.mean(results)
    print(f"Majority in {average} rounds on average")