From 6a48adeee0de7a0710b0edbf1ec61e8f929ac201 Mon Sep 17 00:00:00 2001 From: Dominic Ricottone Date: Sat, 9 Dec 2023 12:52:02 -0600 Subject: [PATCH] Initial commit --- .gitignore | 3 + LICENSE.md | 31 +++++++++++ Makefile | 3 + README.md | 77 +++++++++++++++++++++++++ pyproject.toml | 17 ++++++ vote/__init__.py | 0 vote/__main__.py | 37 ++++++++++++ vote/cli.py | 142 +++++++++++++++++++++++++++++++++++++++++++++++ vote/cli.toml | 19 +++++++ vote/vote.py | 54 ++++++++++++++++++ 10 files changed, 383 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 vote/__init__.py create mode 100644 vote/__main__.py create mode 100644 vote/cli.py create mode 100644 vote/cli.toml create mode 100644 vote/vote.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa5f67e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +**/__pycache__ +**/__mypycache__ +**/*.pyc diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..98fa305 --- /dev/null +++ b/LICENSE.md @@ -0,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. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..87c4203 --- /dev/null +++ b/Makefile @@ -0,0 +1,3 @@ +vote/cli.py: vote/cli.toml + gap vote/cli.toml --no-debug-mode --output=vote/cli.py + diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6bec63 --- /dev/null +++ b/README.md @@ -0,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. + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..705a913 --- /dev/null +++ b/pyproject.toml @@ -0,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" + diff --git a/vote/__init__.py b/vote/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vote/__main__.py b/vote/__main__.py new file mode 100644 index 0000000..a371cf1 --- /dev/null +++ b/vote/__main__.py @@ -0,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() + diff --git a/vote/cli.py b/vote/cli.py new file mode 100644 index 0000000..f4fa204 --- /dev/null +++ b/vote/cli.py @@ -0,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)) diff --git a/vote/cli.toml b/vote/cli.toml new file mode 100644 index 0000000..3cb06b2 --- /dev/null +++ b/vote/cli.toml @@ -0,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'] diff --git a/vote/vote.py b/vote/vote.py new file mode 100644 index 0000000..e875fa6 --- /dev/null +++ b/vote/vote.py @@ -0,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") + -- 2.45.2