From dc796278a6e3f2ead8d2216d4599fc6565f93b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Dachary?= Date: Tue, 16 Aug 2022 18:17:43 +1000 Subject: [PATCH] complete implementation of the revenue sharing model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See the leading comment for a detailed explanation. Signed-off-by: Loïc Dachary --- share.py | 413 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 399 insertions(+), 14 deletions(-) diff --git a/share.py b/share.py index 7ead861..c82718d 100644 --- a/share.py +++ b/share.py @@ -1,13 +1,110 @@ -# py.test-3 --log-cli-level=DEBUG -v -k test_share_income share.py +# py.test-3 --log-cli-level=DEBUG -vv share.py +import copy +import math +import logging +import itertools + +# +# Implementation of the Hostea revenue sharing model +# See https://forum.hostea.org/t/decision-revenue-sharing-model/92 +# +# The members argument is a list of Hostea members, represented as a list: +# [ +# [ +# ID, # username in the https://gitea.hostea.org/Hostea/organization repository +# EXPENSE, # integer, expense elligible for payment +# INCOME, # integer, total available income +# ], +# ... +# ] +# +# The function returns which share of the pending expenses each member +# is allowed to redeem and, if necessary, how much to invoice other +# peer members to get the rest. +# +# share_income(members) => (shares, payments) +# +# The 'shares' are as follows: +# +# [ +# [ID, EXPENSE, INCOME], +# ... +# } +# +# For instance in the if the 'members' argument is [['isabelle', 200, 10]], the +# shares will be [['isabelle', 10, 10]] where the 200 expense was reduced to a +# share of 10 because there only is 10 income available. +# +# The 'payments' maps member IDs to the amount other peer members must be invoiced: +# +# { +# ID: [(ID, integer), (ID, integer), ...], +# ... +# } +# +# For instance in the following marie will invoice isable for 200 +# and john for 50 while lucie will invoice john for 10: +# +# { +# 'marie': [('isabelle', 200), ('john', 50)], +# 'lucie': [('john', 10)], +# } +# +# If a member has both income and expenses, they are not required to invoice +# themselves, it is implicit. For instance, if the 'payable' return value is +# [['isabelle', 10, 10]], the 'payments' return value will be empty and isabelle +# will be expected to reduce both her income and her expenses by 10. +# +def share_income(members): + payable = payable_expenses(members) + payments = { + member[ID]: paying_members + for member, paying_members in distribute(copy.deepcopy(payable)) + } + return (payable, payments) + + +def test_share_income(): + members = [["a", 10, 5], ["b", 450, 25], ["c", 0, 0]] + payable, payments = share_income(members) + assert payable == [["a", 10, 5], ["b", 20, 25]] + assert payments == {"a": [("b", 5)]} + + +###################################################################### +# +# Structure layout and utilities +# +###################################################################### + +ID = 0 EXPENSE = 1 +INCOME = 2 +OPTIMUM_TRANSACTIONS = 2 + +def total(members, position): + return sum(member[position] for member in members) + + +def total_expense(members): + return total(members, EXPENSE) + + +def total_income(members): + return total(members, INCOME) + + +###################################################################### +# +# Figure out the share each member is entitled to +# +###################################################################### # # For a given income, return a list of the members who will not be paid in full # and how much will remain in their expense balance. # -# See https://forum.hostea.org/t/decision-revenue-sharing-model/92 -# # The members list given in argument is a list of pairs of # # ['member', EXPENSE] @@ -22,12 +119,12 @@ EXPENSE = 1 # argument and only contains the members that cannot be paid in full # with the income given in argument. For instance: # -# share_income(3, [['a', 2], ['b', 2]]) == [['b', 1]] +# unpaid_expenses(3, [['a', 2], ['b', 2]]) == [['b', 1]] # # Means that with an income of 3, only member 'a' can be paid in full and # the remaining expense for member 'b' is 1. # -def share_income(income, members): +def unpaid_expenses(income, members): if income <= 0: # # Recursion termination: no more income to share, none of the @@ -51,7 +148,9 @@ def share_income(income, members): # income to give each member the lowest expense, it is divided # evenly. # - share = min(int(income / len(members)), min(members, key=lambda m: m[EXPENSE])[EXPENSE]) + share = min( + int(income / len(members)), min(members, key=lambda m: m[EXPENSE])[EXPENSE] + ) count = len(members) # # remaining is the list of members that cannot be paid in full @@ -80,12 +179,298 @@ def share_income(income, members): # case above) recurse, but only with the members that expect to be # paid more. # - return share_income(income - share * count, remaining) + return unpaid_expenses(income - share * count, remaining) -def test_share_income(): - assert share_income(1, [['a', 1], ['b', 1]]) == [['b', 1]] - assert share_income(5, [['a', 10], ['b', 2]]) == [['a', 7]] - assert share_income(5, [['a', 2], ['b', 10], ['c', 1]]) == [['b', 8]] - assert share_income(5, [['a', 2], ['b', 10], ['c', 1], ['d', 40]]) == [['b', 9], ['d', 39]] - assert share_income(5, [['a', 2], ['b', 10], ['c', 1], ['d', 40], ['e', 3]]) == [['a', 1], ['b', 9], ['d', 39], ['e', 2]] - assert share_income(5, [['a', 2], ['b', 10], ['c', 1], ['d', 40], ['e', 3], ['f', 1]]) == [['a', 1], ['b', 9], ['d', 39], ['e', 2], ['f', 1]] + +def test_unpaid_expenses(): + assert unpaid_expenses(1, [["a", 1], ["b", 1]]) == [["b", 1]] + assert unpaid_expenses(5, [["a", 10], ["b", 2]]) == [["a", 7]] + assert unpaid_expenses(5, [["a", 2], ["b", 10], ["c", 1]]) == [["b", 8]] + assert unpaid_expenses(5, [["a", 2], ["b", 10], ["c", 1], ["d", 40]]) == [ + ["b", 9], + ["d", 39], + ] + assert unpaid_expenses(5, [["a", 2], ["b", 10], ["c", 1], ["d", 40], ["e", 3]]) == [ + ["a", 1], + ["b", 9], + ["d", 39], + ["e", 2], + ] + assert unpaid_expenses( + 5, [["a", 2], ["b", 10], ["c", 1], ["d", 40], ["e", 3], ["f", 1]] + ) == [["a", 1], ["b", 9], ["d", 39], ["e", 2], ["f", 1]] + + +def payable_expenses(members): + members = copy.deepcopy(members) + id2members = {m[ID]: m for m in members} + for unpaid in unpaid_expenses( + min(total_expense(members), total_income(members)), copy.deepcopy(members) + ): + id2members[unpaid[ID]][EXPENSE] -= unpaid[EXPENSE] + return [m for m in id2members.values() if m[EXPENSE] > 0 or m[INCOME] > 0] + + +def test_payable_expenses(): + members = [["a", 1, 0], ["b", 1, 1], ["c", 0, 0]] + expected = [["a", 1, 0], ["b", 0, 1]] + assert payable_expenses(members) == expected + + +###################################################################### +# +# Distribute the income from senders to receivers, nothing tricky here +# +###################################################################### + + +def distribute_to_receivers(receivers, senders): + result = [] + for receiver in receivers: + paying_members, senders = get_paid(receiver, senders) + result.append((receiver, paying_members)) + return result + + +def test_distribute_to_receivers(): + receivers = [["a", 5, 0], ["b", 10, 0]] + senders = [["c", 0, 12], ["d", 0, 3]] + assert distribute_to_receivers(receivers, senders) == [ + (["a", 0, 0], [("c", 5)]), + (["b", 0, 0], [("c", 7), ("d", 3)]), + ] + + receivers = [["b", 12, 0], ["a", 5, 0]] + senders = [["c", 0, 10], ["d", 0, 5], ["e", 0, 1], ["f", 0, 1]] + expected = [ + (["b", 0, 0], [("c", 10), ("d", 2)]), + (["a", 0, 0], [("d", 3), ("e", 1), ("f", 1)]), + ] + assert distribute_to_receivers(receivers, senders) == expected + + receivers = [["a", 5, 0], ["b", 12, 0]] + senders = [["c", 0, 10], ["d", 0, 5], ["e", 0, 1], ["f", 0, 1]] + expected = [ + (["a", 0, 0], [("c", 5)]), + (["b", 0, 0], [("c", 5), ("d", 5), ("e", 1), ("f", 1)]), + ] + assert distribute_to_receivers(receivers, senders) == expected + + +def get_paid(who, members): + paying_members = [] + result = [] + for member in sorted(members, key=lambda m: m[INCOME], reverse=True): + pay = min(member[INCOME], who[EXPENSE]) + if pay > 0: + paying_members.append((member[ID], pay)) + member[INCOME] -= pay + who[EXPENSE] -= pay + result.append(member) + assert who[EXPENSE] == 0 + return (paying_members, result) + + +def test_get_paid(): + orig = [["a", 10, 0], ["c", 0, 6], ["b", 0, 10]] + paying_members, modified = get_paid(orig[0], orig) + assert paying_members == [("b", 10)] + assert modified == [["b", 0, 0], ["c", 0, 6], ["a", 0, 0]] + + orig = [["a", 10, 0], ["c", 0, 6], ["b", 0, 4]] + paying_members, modified = get_paid(orig[0], orig) + assert paying_members == [("c", 6), ("b", 4)] + assert modified == [["c", 0, 0], ["b", 0, 0], ["a", 0, 0]] + + orig = [["a", 10, 0], ["c", 0, 8], ["b", 0, 4], ["d", 2, 0]] + paying_members, modified = get_paid(orig[0], orig) + assert paying_members == [("c", 8), ("b", 2)] + assert modified == [["c", 0, 0], ["b", 0, 2], ["a", 0, 0], ["d", 2, 0]] + paying_members, modified = get_paid(orig[3], orig) + assert paying_members == [("b", 2)] + assert modified == [["b", 0, 0], ["a", 0, 0], ["c", 0, 0], ["d", 0, 0]] + + +def self_get_paid(members): + for member in members: + pay = min(member[INCOME], member[EXPENSE]) + member[INCOME] -= pay + member[EXPENSE] -= pay + return [m for m in members if m[EXPENSE] > 0 or m[INCOME] > 0] + + +def test_self_get_paid(): + members = [["a", 5, 10], ["b", 20, 10], ["c", 0, 30], ["d", 40, 0]] + expected = [["a", 0, 5], ["b", 10, 0], ["c", 0, 30], ["d", 40, 0]] + assert self_get_paid(members) == expected + + +###################################################################### +# +# For each receiver (i.e. a member that will have expenses paid), set +# the number of transactions they would need in the ideal situation +# where they would get paid by the senders who have the most income +# before anyone else. +# +# It will be used to stop iterating as soon as an optimal situation +# is detected instead of exploring all permutations. +# +###################################################################### + + +def set_optimum_transactions(receivers, senders): + result = [] + for receiver in receivers: + expense = receiver[EXPENSE] + optimum = 0 + for sender in sorted(senders, key=lambda m: m[INCOME], reverse=True): + optimum += 1 + expense -= sender[INCOME] + if expense <= 0: + break + assert expense <= 0 + receiver[OPTIMUM_TRANSACTIONS] = optimum + result.append(receiver) + return result + + +def test_set_optimum_transactions(): + receivers = [["a", 5, 0], ["b", 12, 0]] + senders = [["c", 0, 10], ["d", 0, 5]] + expected = [["a", 5, 1], ["b", 12, 2]] + modified = set_optimum_transactions(receivers, senders) + assert modified == expected + + +def is_optimal(result): + for receiver, paying_members in result: + if receiver[OPTIMUM_TRANSACTIONS] > len(paying_members): + return False + return True + + +###################################################################### +# +# Computes which members must be invoiced to pay the expenses to other +# members, with a minimum number of transactions. The algorithm is based +# on the following assumptions: +# +# * The total of all expenses is not greater than the total of all +# incomes. +# +# * The members with are sorted with greater income first and +# permutations where a better solution could be found with a +# different ordering are ignored. +# +# * Iterating over the permutations of the list of members that are +# allowed to get a share will produce a solution that is good enough. +# +# * An optimal solution (see is_optimal) will be found before +# iterating over a significant part of the permutations when there +# is a large (>10) number of members. +# +# The members argument is a list of Hostea members, represented as a list: +# [ +# [ +# ID, # username in the https://gitea.hostea.org/Hostea/organization repository +# EXPENSE, # integer, expense elligible for payment +# INCOME, # integer, total available income +# ], +# ... +# ] +# +# +###################################################################### + + +def distribute(members): + members = self_get_paid(members) + senders = [m for m in members if m[INCOME] > 0] + receivers = [m for m in members if m[EXPENSE] > 0] + receivers = set_optimum_transactions(receivers, senders) + logging.debug( + f"distribute {receivers} to {senders} with at most {math.factorial(len(receivers))} iterations" + ) + results = [] + iter = 0 + for receivers in itertools.permutations(receivers): + iter += 1 + result = distribute_to_receivers(receivers, senders) + # + # There may be other solutions but since this one is optimal, + # no need to continue + # + if is_optimal(result): + logging.debug(f"found optimal result after {iter} iterations") + return result + results.append(result) + return best_result(results) + + +def test_distribute(): + members = [["a", 10, 5], ["b", 20, 25]] + assert distribute(members) == [(["a", 0, 1], [("b", 5)])] + + members = [["a", 10, 5], ["b", 20, 28], ["c", 3, 0]] + assert distribute(members) == [(["a", 0, 1], [("b", 5)]), (["c", 0, 1], [("b", 3)])] + + members = [["a", 10, 5], ["b", 20, 280], ["c", 3, 0]] + assert distribute(members) == [(["a", 0, 1], [("b", 5)]), (["c", 0, 1], [("b", 3)])] + + members = [ + ["a", 10, 5], + ["b", 20, 280], + ["c", 3, 0], + ["d", 3, 0], + ["e", 3, 0], + ["f", 3, 0], + ["g", 3, 0], + ["h", 3, 0], + ["i", 3, 0], + ["j", 3, 0], + ["k", 3, 0], + ] + expected = [ + (["a", 0, 1], [("b", 5)]), + (["c", 0, 1], [("b", 3)]), + (["d", 0, 1], [("b", 3)]), + (["e", 0, 1], [("b", 3)]), + (["f", 0, 1], [("b", 3)]), + (["g", 0, 1], [("b", 3)]), + (["h", 0, 1], [("b", 3)]), + (["i", 0, 1], [("b", 3)]), + (["j", 0, 1], [("b", 3)]), + (["k", 0, 1], [("b", 3)]), + ] + assert distribute(members) == expected + + +# +# The best result is the one with less transactions +# +def best_result(results): + best = None + best_transactions_count = None + for result in results: + transactions_count = sum( + [len(transactions) for receiver, transactions in result] + ) + if ( + best_transactions_count is None + or transactions_count < best_transactions_count + ): + best_transactions_count = transactions_count + best = result + return best + + +def test_best_result(): + result1 = [(["a", 0, 0], [("c", 5)]), (["b", 0, 0], [("c", 7), ("d", 3)])] + result2 = [(["a", 0, 0], [("c", 5)]), (["b", 0, 0], [("c", 7)])] + result3 = [(["a", 0, 0], [("c", 5)]), (["b", 0, 0], [("c", 6), ("d", 2), ("e", 2)])] + results = [ + result1, + result2, + result3, + ] + assert best_result(results) == result2