#+title: Python Programming Dice Probabilities #+auto_tangle: t #+PROPERTY: session *python* #+PROPERTY: cache yes #+PROPERTY: exports both #+PROPERTY: header-args :tangle pydiceprob.py * Introduction :LOGBOOK: CLOCK: [2024-04-20 Sat 09:05]--[2024-04-20 Sat 09:22] => 0:17 CLOCK: [2024-04-20 Sat 07:57]--[2024-04-20 Sat 09:04] => 1:07 CLOCK: [2024-04-19 Fri 10:33]--[2024-04-19 Fri 12:50] => 2:17 CLOCK: [2024-04-17 Wed 13:40]--[2024-04-17 Wed 14:14] => 0:34 :END: I was given a problem as such: #+begin_quote You have two players, Bob and Alice, that take turns in rolling a fair k-sided die. Whoever rolls a k first wins the game. The Python program should output the probability that Bob wins the game for k = 6 thru 99. That is, the output will be an array of probabilities where index 0 is the probability when k = 6; index 1 when k = 7; etc. #+end_quote Bonus points: #+begin_quote If you have 8me and interest, create a REST server rather than a console program. Flask or FastAPI can be used. The REST endpoint should be a GET, accept no Request Body, accept an op8onal Header for “k”, and return: - The array of probabili8es in JSON format if no “k” is provided in the Header - A single probability in JSON format if a “k” is provided in the Header #+end_quote I appear to have completed the task, at a comfortable pace, in about 4 hours. I have omitted the unit tests for the API portion of this, as it would have been overkill. I did choose to include 100 in the calculations, as I find that just a little bit more elegant. There is something special about ending a list on a nice round number. * Usage This repository contains multiple files. 1. The pdf with the problem I was given. 2. README.org - an Emacs org-mode file, which you're reading right now. 3. pydiceprob.py - the main python file, which works in CLI as such: - ~python pydiceprob.py [dice_size] [mode]~ - ~dice_size~ can be anything in the CLI, but note that excessively large numbers are pointless, as the probability of a single throw will always be 1/dice_size, while the probability of winning in the game will always be approaching 50%. - The modes are: - ~single~ (for single throw) - ~single-table~ (for a table of probabilities to win on a single throw across a variety of dice sizes) - ~multi~ (winning probability in the game) - ~multi-table~ (winning probabilities in the game across a variety of dice sizes) - *Alternatively*, you can start a REST API (implemented using FastAPI) using uvicorn. Run ~uvicorn pydiceprob:app~ from the same directory as the =pydiceprob.py= file. - You can then see the outputs using =curl=, such as: - ~curl 127.0.0.1:8000~ or - ~curl 127.0.0.1:8000 -H "k: [dice_size]"~ - Please note that I have chosen to limit the dice size to be between 6 and 100 inclusive on the API. 4. tests.py - simple tests for the probability calculations. * Code The following is python code blocks, with the documentation attached. Using =org-babel=, these are tangled into =pydiceprob.py=, which is the final python script. ** Imports #+begin_src python import sys import json import pprint from fastapi import FastAPI, Header #+end_src ** Pretty Print #+begin_src python pp = pp.Printer = pprint.PrettyPrinter(indent=2, compact=True) #+end_src And a usage printe #+begin_src python def usagequit(): print("""Usage: python pydiceprob.py [k] [mode] k between 6 and 99 (higher numbers are supported, up to however much your memory can handle; given that win probabilities approach 50%, this isn't very useful past about 100.) modes: single, single-table, multi, multi-table single prints out the probability of a single throw with a k-sided dice yielding k. single-table prints out a table between 6 and k with the probabilities of a single throw yielding k. multi prints out the probability of winning (assuming we're going first) in a game where two players take turns and the person who throws k first wins. multi-table prints out the probability of winning for a range between 6 and k if you have the first throw.""") quit() #+end_src ** FastAPI #+begin_src python app = FastAPI() @app.get("/") async def api_get(k: int = Header(default=None)): if not isinstance(k, int) and k is not None: return {"message": "Header k must be an integer between 6 and 100 inclusive or not present."} if k is None: result = one_turn_table(100) return {"Probabilities of throwing the highest number on dice of size": result} elif k >= 6 and k <= 100: result = one_turn_single(k) return {f"Probability of throwing the highest number of dice of size {k}": result} else: return {"message": "Usage: no request body, use header k to specify what probability you want, if no k, then a table is given between dice sizes 6 and 100 inclusive."} #+end_src ** Main Main function for managing CLI interactions, and dispatch #+begin_src python def main(): global k try: if int(sys.argv[1]) >= 6: k = int(sys.argv[1]) else: usagequit() except IndexError: usagequit() global mode try: mode = sys.argv[2] except IndexError: usagequit() dispatch(mode, k) #+end_src #+begin_src python def dispatch(mode, k): modes = ["single", "multi", "single-table", "multi-table"] if mode not in modes: usagequit() if mode == "single": print(one_turn_single(k)) elif mode == "multi" : print(multi_turn_single(k)) elif mode == "single-table": print(one_turn_table(k)) elif mode == "multi-table": print(multi_turn_table(k)) #+end_src ** Single turn win probability Then, we have to calculate the probability of a win on each throw given a dice of size =k=. Both players (Alice and Bob) throw dice, alternating, and the person who throws the top number first (=k=) wins. Bob always goes first for simplicity. A dice of size =k= has a 1/k probability of winning each throw. =k+1= here, because python calculates range(a, b) indices in the way of b-a, so for a = 6 and b = 10, it'll give an array of 4 items (10-6 = 4) as such: ~[6, 7, 8, 9]~, as it counts the first item as 1. Using =k+1= we can get the correct, inclusive array that we desire: ~[6, 7, 8, 9, 10]~. #+begin_src python def one_turn_table(k): result = {} for k in range(6, int(k)+1): result[k] = 1/k return result def one_turn_single(k): return one_turn_table(k)[k] #+end_src #+RESULTS: ** Multi-turn win probability Over many throws, the advantage of winning will decrease, approaching 50%. We're assuming Bob goes first, and then is followed by Alice. For each throw, the probability of winning is the same. #+begin_src python def multi_turn_single(k): p_win = 1 / k # Winning probability on any given throw p_lose = (k-1) / k # Losing probability on any given throw bob_wins_prob_sum = 0 r = p_lose**2 probability_win = p_win / (1 - r) return probability_win #+end_src And then we want to generate a table of all the probabilities up to =k=. #+begin_src python def multi_turn_table(k): result = {} for i in range(6, int(k+1)): result[i] = multi_turn_single(i) return result #+end_src ** Print #+begin_src python def print(stuff): pp.pprint(stuff) #+end_src ** Script To run as a script. #+begin_src python if __name__ == "__main__": main() #+end_src * Unit testing ** Imports #+begin_src python :tangle test.py import unittest import pydiceprob #+end_src ** Single-turn win probabilities Then we test the range from 6 to 100. The script supports more, but this is the part that /must/ be correct. Because the function =one_turn_single= pulls directly from =one_turn_table=, we can simply iterate over =one_turn_single=, ensuring that both functions work as desired at the same time. This, however, is only because we're dealing with exceedingly trivial code, where the singular function merely narrows down the data from the plural. #+begin_src python :tangle test.py class TestSingle(unittest.TestCase): def test_single(self): data = {6: 0.16666666666666666, 7: 0.14285714285714285, 8: 0.125, 9: 0.1111111111111111, 10: 0.1, 11: 0.09090909090909091, 12: 0.08333333333333333, 13: 0.07692307692307693, 14: 0.07142857142857142, 15: 0.06666666666666667, 16: 0.0625, 17: 0.058823529411764705, 18: 0.05555555555555555, 19: 0.05263157894736842, 20: 0.05, 21: 0.047619047619047616, 22: 0.045454545454545456, 23: 0.043478260869565216, 24: 0.041666666666666664, 25: 0.04, 26: 0.038461538461538464, 27: 0.037037037037037035, 28: 0.03571428571428571, 29: 0.034482758620689655, 30: 0.03333333333333333, 31: 0.03225806451612903, 32: 0.03125, 33: 0.030303030303030304, 34: 0.029411764705882353, 35: 0.02857142857142857, 36: 0.027777777777777776, 37: 0.02702702702702703, 38: 0.02631578947368421, 39: 0.02564102564102564, 40: 0.025, 41: 0.024390243902439025, 42: 0.023809523809523808, 43: 0.023255813953488372, 44: 0.022727272727272728, 45: 0.022222222222222223, 46: 0.021739130434782608, 47: 0.02127659574468085, 48: 0.020833333333333332, 49: 0.02040816326530612, 50: 0.02, 51: 0.0196078431372549, 52: 0.019230769230769232, 53: 0.018867924528301886, 54: 0.018518518518518517, 55: 0.01818181818181818, 56: 0.017857142857142856, 57: 0.017543859649122806, 58: 0.017241379310344827, 59: 0.01694915254237288, 60: 0.016666666666666666, 61: 0.01639344262295082, 62: 0.016129032258064516, 63: 0.015873015873015872, 64: 0.015625, 65: 0.015384615384615385, 66: 0.015151515151515152, 67: 0.014925373134328358, 68: 0.014705882352941176, 69: 0.014492753623188406, 70: 0.014285714285714285, 71: 0.014084507042253521, 72: 0.013888888888888888, 73: 0.0136986301369863, 74: 0.013513513513513514, 75: 0.013333333333333334, 76: 0.013157894736842105, 77: 0.012987012987012988, 78: 0.01282051282051282, 79: 0.012658227848101266, 80: 0.0125, 81: 0.012345679012345678, 82: 0.012195121951219513, 83: 0.012048192771084338, 84: 0.011904761904761904, 85: 0.011764705882352941, 86: 0.011627906976744186, 87: 0.011494252873563218, 88: 0.011363636363636364, 89: 0.011235955056179775, 90: 0.011111111111111112, 91: 0.01098901098901099, 92: 0.010869565217391304, 93: 0.010752688172043012, 94: 0.010638297872340425, 95: 0.010526315789473684, 96: 0.010416666666666666, 97: 0.010309278350515464, 98: 0.01020408163265306, 99: 0.010101010101010102, 100: 0.01} for t in range(6, 100): result = pydiceprob.one_turn_single(t) self.assertEqual(result, data[t], f"Should be {data[t]} for dice size {t}.") #+end_src ** Multi-turn test As with the other test, we're feeding the correct values, iterating over the possible outputs of the =multi_turn_single= function. #+begin_src python :tangle test.py def test_multi(self): data = { 6: 0.5454545454545455, 7: 0.5384615384615383, 8: 0.5333333333333333, 9: 0.5294117647058822, 10: 0.5263157894736844, 11: 0.5238095238095235, 12: 0.5217391304347823, 13: 0.5200000000000005, 14: 0.5185185185185185, 15: 0.5172413793103451, 16: 0.5161290322580645, 17: 0.5151515151515152, 18: 0.5142857142857141, 19: 0.5135135135135128, 20: 0.5128205128205127, 21: 0.5121951219512191, 22: 0.511627906976745, 23: 0.5111111111111115, 24: 0.5106382978723412, 25: 0.510204081632653, 26: 0.5098039215686275, 27: 0.5094339622641498, 28: 0.5090909090909096, 29: 0.5087719298245623, 30: 0.5084745762711862, 31: 0.5081967213114762, 32: 0.5079365079365079, 33: 0.5076923076923086, 34: 0.5074626865671636, 35: 0.5072463768115941, 36: 0.507042253521126, 37: 0.506849315068494, 38: 0.5066666666666677, 39: 0.5064935064935064, 40: 0.5063291139240501, 41: 0.506172839506172, 42: 0.5060240963855414, 43: 0.5058823529411758, 44: 0.5057471264367819, 45: 0.5056179775280893, 46: 0.5054945054945053, 47: 0.505376344086021, 48: 0.5052631578947361, 49: 0.5051546391752573, 50: 0.5050505050505041, 51: 0.5049504950495041, 52: 0.504854368932038, 53: 0.5047619047619043, 54: 0.5046728971962623, 55: 0.5045871559633027, 56: 0.5045045045045037, 57: 0.5044247787610603, 58: 0.5043478260869556, 59: 0.5042735042735057, 60: 0.504201680672268, 61: 0.5041322314049588, 62: 0.5040650406504076, 63: 0.5039999999999981, 64: 0.5039370078740157, 65: 0.5038759689922497, 66: 0.5038167938931295, 67: 0.5037593984962382, 68: 0.5037037037037063, 69: 0.5036496350364972, 70: 0.5035971223021601, 71: 0.5035460992907805, 72: 0.5034965034965061, 73: 0.5034482758620673, 74: 0.5034013605442197, 75: 0.5033557046979886, 76: 0.5033112582781448, 77: 0.5032679738562092, 78: 0.5032258064516143, 79: 0.5031847133757983, 80: 0.5031446540880515, 81: 0.5031055900621104, 82: 0.503067484662576, 83: 0.5030303030303014, 84: 0.502994011976049, 85: 0.5029585798816592, 86: 0.5029239766081863, 87: 0.5028901734104042, 88: 0.5028571428571439, 89: 0.5028248587570601, 90: 0.502793296089388, 91: 0.5027624309392276, 92: 0.5027322404371553, 93: 0.5027027027027039, 94: 0.5026737967914459, 95: 0.5026455026455015, 96: 0.5026178010471216, 97: 0.5025906735751302, 98: 0.502564102564102, 99: 0.5025380710659929, 100: 0.5025125628140696} for t in range(6, 100): result = pydiceprob.multi_turn_single(t) self.assertEqual(result, data[t], f"Should be {data[t]} for dice size {t}.") #+end_src ** Test main entry #+begin_src python :tangle test.py if __name__ == '__main__': unittest.main() #+end_src