A programming exercise given to me as part of a job application.
Go to file
2024-04-20 15:26:56 +02:00
LICENSE First commit. 2024-04-19 12:57:47 +02:00
pydiceprob.py Removed unnecessary variable 2024-04-20 15:26:56 +02:00
Python_Programming_Interview_Problem_1.pdf First commit. 2024-04-19 12:57:47 +02:00
README.org Removed unnecessary variable 2024-04-20 15:26:56 +02:00

Python Programming Dice Probabilities

Introduction

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

I was given a problem as such:

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.

Bonus points:

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

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

import sys
import json
import pprint
from fastapi import FastAPI, Header

Pretty print and usage printer

Pretty print so output is legible.

pp = pp.Printer = pprint.PrettyPrinter(indent=2, compact=True)

def print(stuff):
    pp.pprint(stuff)

And a usage printer:

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()

FastAPI

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-dice": result}
    elif k >= 6 and k <= 100:
        result = one_turn_single(k)
        return {f"Probability-dice-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."}

Main

Main function for managing CLI interactions, and dispatch

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

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].

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]

Multi-turn win probability

Over many throws, the advantage of going first will decrease, approaching 50%.

We're assuming Bob goes first, and then Alice. For each throw, the probability of winning is the same.

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
    r = p_lose**2
    probability_win = p_win / (1 - r)
    return probability_win

And then we want to generate a table of all the probabilities up to k.

def multi_turn_table(k):
    result = {}
    for i in range(6, int(k+1)):
        result[i] = multi_turn_single(i)
    return result

Script

To run as a script.

if __name__ == "__main__":
    main()

Unit testing

The following code blocks are tangled into test.py.

Imports

import unittest
import pydiceprob

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.

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}.")

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.

    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}.")

Test main entry

if __name__ == '__main__':
    unittest.main()