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")
+