From 32d1d2b50740d3b83c6228d94c3213cc8844f611 Mon Sep 17 00:00:00 2001 From: Phil Bajsicki Date: Sat, 20 Apr 2024 09:21:06 +0200 Subject: [PATCH] Unit tests, and API implemented. --- README.org | 337 ++++++++++++++++++++++++++++++++++++++++++++------ pydiceprob.py | 39 +++--- 2 files changed, 321 insertions(+), 55 deletions(-) diff --git a/README.org b/README.org index e5b552c..74ab762 100644 --- a/README.org +++ b/README.org @@ -6,6 +6,8 @@ #+PROPERTY: header-args :tangle pydiceprob.py * Introduction :LOGBOOK: +CLOCK: [2024-04-20 Sat 09:05] +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: @@ -28,23 +30,40 @@ op8onal Header for “k”, and return: - A single probability in JSON format if a “k” is provided in the Header #+end_quote -* TODOS: -- Unit tests -- REST API +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. -* Thinking -Given the problem's nature, we need the following functionality: -1. Output the probability for Bob to win. -2. Create an array of probabilities that contains the data for the output. -3. Calculate the probabilities based on the value of =k=. -4. Define the value of =k=, between 6 and 99 inclusive. - -For the bonus secion: -1. Create a FastAPI endpoint that takes an optional Header for =k=. -2. Return either the full array of probabilities (no =k= provided), or return the - specific probability if =k= is provided. * Code The following is python code blocks, with the documentation attached. Using =org-babel=, these are tangled into =pydiceprob.py=, @@ -54,6 +73,8 @@ which is the final python script. import sys import json import pprint +from fastapi import FastAPI, Header +from pydantic import BaseModel #+end_src #+RESULTS: @@ -67,7 +88,7 @@ And a usage printe #+begin_src python def usagequit(): print("""Usage: python pydiceprob.py [k] [mode] - k between 6 and 99 + 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. @@ -75,6 +96,29 @@ def usagequit(): 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 +:LOGBOOK: +CLOCK: [2024-04-20 Sat 09:04]--[2024-04-20 Sat 09:05] => 0:01 +:END: +#+begin_src python +app = FastAPI() + +class APIget(BaseModel): + result: dict +@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 @@ -102,37 +146,16 @@ def dispatch(mode, k): if mode not in modes: usagequit() if mode == "single": - print(first_turn_probability(k)) + print(one_turn_single(k)) elif mode == "multi" : print(multi_turn_single(k)) elif mode == "single-table": - print(first_turn_protatbilities(k)) + print(one_turn_table(k)) elif mode == "multi-table": print(multi_turn_table(k)) #+end_src -** Array of dice and players -Second, we need to grab the value of =k=, the size of the dice we're calculating -the probability for. Depending on the implementation we choose to use, we'll be either -grabbing it from the CLI, or the REST API. For now, this will remain in the CLI. - -Then for the bonus code, we need several steps. First, we need to generate a list -such that we have numbers between 6 and 99 in it. -#+begin_src python -def dice(k): - if k < 6: - print("Dice must be at least 6-sided") - elif k == 6: - dice_array = [6] - else: - dice_array = list(range(6, k)) -#+end_src - -#+RESULTS: -: None - - ** Single turn win probability Then, we have to calculate the probability of a win on each throw given a dice of size =k=. @@ -147,14 +170,14 @@ for a = 6 and b = 10, it'll give an array of 4 items (10-6 = 4) as such: Using =k+1= we can get the correct, inclusive array that we desire: ~[6, 7, 8, 9, 10]~. #+begin_src python -def first_turn_probabilities(k): +def one_turn_table(k): result = {} for k in range(6, int(k)+1): result[k] = 1/k return result -def first_turn_probability(k): - return first_turn_probabilities(k)[k] +def one_turn_single(k): + return one_turn_table(k)[k] #+end_src @@ -197,3 +220,235 @@ To run as a script. 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 diff --git a/pydiceprob.py b/pydiceprob.py index ed11dd1..5cc9d41 100644 --- a/pydiceprob.py +++ b/pydiceprob.py @@ -1,12 +1,14 @@ import sys import json import pprint +from fastapi import FastAPI, Header +from pydantic import BaseModel pp = pp.Printer = pprint.PrettyPrinter(indent=2, compact=True) def usagequit(): print("""Usage: python pydiceprob.py [k] [mode] - k between 6 and 99 + 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. @@ -14,6 +16,23 @@ def usagequit(): multi-table prints out the probability of winning for a range between 6 and k if you have the first throw.""") quit() +app = FastAPI() + +class APIget(BaseModel): + result: dict +@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."} + def main(): global k try: @@ -35,30 +54,22 @@ def dispatch(mode, k): if mode not in modes: usagequit() if mode == "single": - print(first_turn_probability(k)) + print(one_turn_single(k)) elif mode == "multi" : print(multi_turn_single(k)) elif mode == "single-table": - print(first_turn_protatbilities(k)) + print(one_turn_table(k)) elif mode == "multi-table": print(multi_turn_table(k)) -def dice(k): - if k < 6: - print("Dice must be at least 6-sided") - elif k == 6: - dice_array = [6] - else: - dice_array = list(range(6, k)) - -def first_turn_probabilities(k): +def one_turn_table(k): result = {} for k in range(6, int(k)+1): result[k] = 1/k return result -def first_turn_probability(k): - return first_turn_probabilities(k)[k] +def one_turn_single(k): + return one_turn_table(k)[k] def multi_turn_single(k): p_win = 1 / k # Winning probability on any given throw