diff --git a/.github/test.pending b/.github/test.pending new file mode 100644 index 0000000..abbd178 --- /dev/null +++ b/.github/test.pending @@ -0,0 +1,19 @@ +name: test + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.13 + + - name: Install dependencies + run: pip install -r test-requirements.txt + + - name: Run tests + run: pytest . diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..824c563 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.inline-snapshot] +test-dir = "scripts" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..597d116 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +numpy +requests +pyaml diff --git a/scripts/project_selection.py b/scripts/project_selection.py index 07083f2..5082bd7 100644 --- a/scripts/project_selection.py +++ b/scripts/project_selection.py @@ -1,6 +1,14 @@ import numpy as np +class Proposal: + + def __init__(self, name, requested_amount, previous_funding): + self.name = str(name) + self.requested_amount = abs(requested_amount) + self.previous_funding = previous_funding + + def select_proposals_to_fund(budget, funding_limit, proposals, seed=None): """ Randomly selects which proposals to fund in a round. @@ -11,47 +19,63 @@ def select_proposals_to_fund(budget, funding_limit, proposals, seed=None): `proposals` is a list of tuples of the form `(name, requested_amount, previous_funding)` where `name` is the name of the proposal, `requested_amount` is the amount of funding - that proposal requests and `previos_funding` is the amount of funding the project - associated with that proposal has received that year. + that proposal requests and `previous_funding` is the amount of funding the + project associated with that proposal has received that year. This function will throw and not produce any results if any of its inputs are invalid. """ - np.random.seed(seed) - - for p in proposals: - if len(p) != 3: - raise ValueError("Malformed proposal") - if p[1] + p[2] > funding_limit: - raise ValueError( - f'If proposal "{p[0]}" were funded it would receive more than the funding limit this year.' - ) - - names = [str(p[0]) for p in proposals] - weights = [(funding_limit - p[2]) / p[1] for p in proposals] + # `seed` can be a seed (integer) or a random number generator. + rng = np.random.default_rng(seed) - for w in weights: - assert w >= 1 # this should be redundant with the validation check above. + proposals = [Proposal(*p) for p in proposals] + n_proposals = len(proposals) - if len(set(names)) != len(names): + if len(set(p.name for p in proposals)) != n_proposals: raise ValueError("Proposal names are not unique") + weights = np.zeros(n_proposals) + for i, p in enumerate(proposals): + remaining_limit = funding_limit - p.previous_funding + if p.requested_amount > remaining_limit: + raise ValueError( + f'If proposal "{p.name}" were funded it would receive more ' + 'than the per-project funding limit this year.' + ) + # Decrease weight for projects that have already had previous funding. + weights[i] = remaining_limit / p.requested_amount + assert weights[i] >= 1 # this should be redundant with the validation check above. + + # Check / filter proposals to those within budget deficit limit. funded = [] budget_remaining = budget - temp_budget_remaining = budget + 0 - while budget_remaining > 0 and len(funded) < len(proposals): - total_weight = sum(weights) + while budget_remaining > 0 and len(funded) < n_proposals: + total_weight = np.sum(weights) if total_weight == 0: # When all the proposals have been evaluated and there's still budget break - i = np.random.choice(range(len(weights)), p=[w / total_weight for w in weights]) + # Select one project using weights. + i = rng.choice(n_proposals, p=weights / total_weight) + # Implement selection (but dependent on deficit check below). weights[i] = 0 proposal = proposals[i] - proposal_budget = proposal[1] - temp_budget_remaining -= proposal_budget - if temp_budget_remaining > -proposal_budget / 2: - funded.append(proposal) - budget_remaining = temp_budget_remaining + budget_remaining -= proposal.requested_amount + deficit = -budget_remaining if budget_remaining < 0 else 0 + if deficit > proposal.requested_amount / 2: + # Deficit is too great, add back project budget, and find + # another project to try. + budget_remaining += proposal.requested_amount + continue + # Confirm selection. + funded.append(proposal) + if budget_remaining <= 0: + break + + # Final check for spending. + assert budget_remaining >= -remaining_limit / 2 + + # Sort funded proposals in proposal order. + funded = sorted(funded, key=lambda v : proposals.index(v)) print("Inputs:") print(f"Budget: ${budget}") @@ -59,7 +83,8 @@ def select_proposals_to_fund(budget, funding_limit, proposals, seed=None): print("Proposals in the drawing:") for p in proposals: print( - f'"{p[0]}" requests ${p[1]} and is proposed by a project that has previously received ${p[2]} this year.' + f'"{p.name}" requests ${p.requested_amount} and is proposed by a ' + f'project that has previously received ${p.previous_funding} this year.' ) print() @@ -67,11 +92,12 @@ def select_proposals_to_fund(budget, funding_limit, proposals, seed=None): print( f"Allocated: ${round(budget - budget_remaining, 2)} (${round(abs(budget_remaining), 2)} {'over' if budget_remaining < 0 else 'under'} budget)" ) - print(f"{len(funded)} proposals funded out of {len(proposals)} total proposals in the drawing") + print(f"{len(funded)} proposals funded out of {n_proposals} total proposals in the drawing") print() print("Funded the following projects") for p in funded: - print(f'Fund "{p[0]}" for ${p[1]} bringing its project\'s annual total to ${p[1] + p[2]}.') + print(f'Fund "{p.name}" for ${p.requested_amount} bringing its ' + f'project\'s annual total to ${p.requested_amount + p.previous_funding}.') - return [f[0] for f in funded] + return [f.name for f in funded] diff --git a/scripts/test_project_selection.py b/scripts/test_project_selection.py index 1ce8161..37a3515 100644 --- a/scripts/test_project_selection.py +++ b/scripts/test_project_selection.py @@ -1,38 +1,49 @@ -import pytest +from collections import Counter + import numpy as np +import pytest +from inline_snapshot import snapshot + from project_selection import select_proposals_to_fund @pytest.mark.parametrize( "proposals, errmessage", [ - ([("A", 2, 0, 3)], r"Malformed proposal"), ( [("A", 3, 1)], - r'If proposal "A" were funded it would receive more than the funding limit this year.', + r'If proposal "A" were funded it would receive more than ' + 'the per-project funding limit this year.', ), ( [("A", 2, 1)], - r'If proposal "A" were funded it would receive more than the funding limit this year.', + r'If proposal "A" were funded it would receive more than ' + 'the per-project funding limit this year.', ), ([("A", 0.5, 1), ("A", 0.5, 1)], r"Proposal names are not unique"), ], ) def test_select_proposals_wrong_input(proposals, errmessage): - np.random.seed(2025) budget = 5 funding_limit = 2 with pytest.raises(ValueError, match=errmessage): - select_proposals_to_fund(budget, funding_limit, proposals) + select_proposals_to_fund(budget, funding_limit, proposals, seed=2025) + + +def test_malformed(): + with pytest.raises(TypeError): + select_proposals_to_fund(5, 2, [("A", 2, 0, 3)]) + with pytest.raises(TypeError): + select_proposals_to_fund(5, 2, [("A", 2)]) def test_select_proposals_all_funds(capfd): - np.random.seed(2025) budget = 5 funding_limit = 2 proposals = [("A", 2, 0), ("B", 1, 0), ("C", 1, 0), ("D", 0.5, 0), ("E", 0.5, 0)] + # Enough budget for all proposals. expected_result = {"A", "B", "C", "D", "E"} expected_captured = ( "Inputs:\n" @@ -49,23 +60,44 @@ def test_select_proposals_all_funds(capfd): "Allocated: $5.0 ($0.0 under budget)\n" "5 proposals funded out of 5 total proposals in the drawing\n\n" "Funded the following projects\n" + 'Fund "A" for $2 bringing its project\'s annual total to $2.\n' 'Fund "B" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "E" for $0.5 bringing its project\'s annual total to $0.5.\n' - 'Fund "D" for $0.5 bringing its project\'s annual total to $0.5.\n' 'Fund "C" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "A" for $2 bringing its project\'s annual total to $2.\n' + 'Fund "D" for $0.5 bringing its project\'s annual total to $0.5.\n' + 'Fund "E" for $0.5 bringing its project\'s annual total to $0.5.\n' ) - result = select_proposals_to_fund(budget, funding_limit, proposals) + result = select_proposals_to_fund(budget, + funding_limit, + proposals, + seed=2025) captured = capfd.readouterr() assert set(result) == expected_result assert captured.out == expected_captured +def check_proportions(proposals, budget, limit, rng=None): + # Check proportions (roughly) as expected. + rng = np.random.default_rng(rng) + results = [] + n_iters = 1000 + for i in range(n_iters): + results += select_proposals_to_fund(budget, limit, proposals, seed=rng) + # Proportion of time each proposal was funded. + prop_funded = {n: c / n_iters for n, c in Counter(results).items()} + # Replicate weight algorithm. + weights = {n: (limit - previous) / ask for n, ask, previous in proposals} + # Assert that higher weights give higher proportions. + for n, w in weights.items(): + for test_n, test_prop in prop_funded.items(): + if w > weights[test_n]: + assert prop_funded[n] > test_prop + + def test_select_proposals_more_than_funds(capfd): - np.random.seed(2025) budget = 5 funding_limit = 2 + # Weights inverse of requested funds. proposals = [ ("A", 2, 0), ("B", 1, 0), @@ -77,41 +109,45 @@ def test_select_proposals_more_than_funds(capfd): ("H", 0.7, 0), ] - expected_result = {"A", "B", "C", "D", "H", "F"} - expected_captured = ( - "Inputs:\n" - "Budget: $5\n" - "Per-project funding limit: $2\n" - "Proposals in the drawing:\n" - '"A" requests $2 and is proposed by a project that has previously received $0 this year.\n' - '"B" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"C" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"D" requests $0.5 and is proposed by a project that has previously received $0 this year.\n' - '"E" requests $1.5 and is proposed by a project that has previously received $0 this year.\n' - '"F" requests $0.25 and is proposed by a project that has previously received $0 this year.\n' - '"G" requests $1.9 and is proposed by a project that has previously received $0 this year.\n' - '"H" requests $0.7 and is proposed by a project that has previously received $0 this year.\n' - "\n" - "Random Outputs:\n" - "Allocated: $5.45 ($0.45 over budget)\n" - "6 proposals funded out of 8 total proposals in the drawing\n\n" - "Funded the following projects\n" - 'Fund "C" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "H" for $0.7 bringing its project\'s annual total to $0.7.\n' - 'Fund "F" for $0.25 bringing its project\'s annual total to $0.25.\n' - 'Fund "D" for $0.5 bringing its project\'s annual total to $0.5.\n' - 'Fund "B" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "A" for $2 bringing its project\'s annual total to $2.\n' - ) - result = select_proposals_to_fund(budget, funding_limit, proposals) + check_proportions(proposals, budget, funding_limit, 2025) + # Reset output. + capfd.readouterr() + + result = select_proposals_to_fund(budget, + funding_limit, + proposals, + seed=2025) captured = capfd.readouterr() - assert set(result) == expected_result - assert captured.out == expected_captured + assert set(result) == snapshot({'D','E','F','G','H'}) + assert captured.out == snapshot("""\ +Inputs: +Budget: $5 +Per-project funding limit: $2 +Proposals in the drawing: +"A" requests $2 and is proposed by a project that has previously received $0 this year. +"B" requests $1 and is proposed by a project that has previously received $0 this year. +"C" requests $1 and is proposed by a project that has previously received $0 this year. +"D" requests $0.5 and is proposed by a project that has previously received $0 this year. +"E" requests $1.5 and is proposed by a project that has previously received $0 this year. +"F" requests $0.25 and is proposed by a project that has previously received $0 this year. +"G" requests $1.9 and is proposed by a project that has previously received $0 this year. +"H" requests $0.7 and is proposed by a project that has previously received $0 this year. + +Random Outputs: +Allocated: $4.85 ($0.15 under budget) +5 proposals funded out of 8 total proposals in the drawing + +Funded the following projects +Fund "D" for $0.5 bringing its project's annual total to $0.5. +Fund "E" for $1.5 bringing its project's annual total to $1.5. +Fund "F" for $0.25 bringing its project's annual total to $0.25. +Fund "G" for $1.9 bringing its project's annual total to $1.9. +Fund "H" for $0.7 bringing its project's annual total to $0.7. +""") def test_select_proposals_more_than_funds_under(capfd): - np.random.seed(2025) budget = 4.1 funding_limit = 2 proposals = [ @@ -125,40 +161,45 @@ def test_select_proposals_more_than_funds_under(capfd): ("H", 0.7, 0), # *2.85 ] - expected_result = {"B", "C", "D", "H", "F"} - expected_captured = ( - "Inputs:\n" - "Budget: $4.1\n" - "Per-project funding limit: $2\n" - "Proposals in the drawing:\n" - '"A" requests $2 and is proposed by a project that has previously received $0 this year.\n' - '"B" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"C" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"D" requests $0.5 and is proposed by a project that has previously received $0 this year.\n' - '"E" requests $1.5 and is proposed by a project that has previously received $0 this year.\n' - '"F" requests $0.25 and is proposed by a project that has previously received $0 this year.\n' - '"G" requests $1.9 and is proposed by a project that has previously received $0 this year.\n' - '"H" requests $0.7 and is proposed by a project that has previously received $0 this year.\n' - "\n" - "Random Outputs:\n" - "Allocated: $3.45 ($0.65 under budget)\n" - "5 proposals funded out of 8 total proposals in the drawing\n\n" - "Funded the following projects\n" - 'Fund "C" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "H" for $0.7 bringing its project\'s annual total to $0.7.\n' - 'Fund "F" for $0.25 bringing its project\'s annual total to $0.25.\n' - 'Fund "D" for $0.5 bringing its project\'s annual total to $0.5.\n' - 'Fund "B" for $1 bringing its project\'s annual total to $1.\n' - ) - result = select_proposals_to_fund(budget, funding_limit, proposals) + check_proportions(proposals, budget, funding_limit, rng=2025) + # Reset output. + capfd.readouterr() + + result = select_proposals_to_fund(budget, + funding_limit, + proposals, + seed=2026) captured = capfd.readouterr() - assert set(result) == expected_result - assert captured.out == expected_captured + assert set(result) == snapshot({'B','C','D','E','F'}) + assert captured.out == snapshot("""\ +Inputs: +Budget: $4.1 +Per-project funding limit: $2 +Proposals in the drawing: +"A" requests $2 and is proposed by a project that has previously received $0 this year. +"B" requests $1 and is proposed by a project that has previously received $0 this year. +"C" requests $1 and is proposed by a project that has previously received $0 this year. +"D" requests $0.5 and is proposed by a project that has previously received $0 this year. +"E" requests $1.5 and is proposed by a project that has previously received $0 this year. +"F" requests $0.25 and is proposed by a project that has previously received $0 this year. +"G" requests $1.9 and is proposed by a project that has previously received $0 this year. +"H" requests $0.7 and is proposed by a project that has previously received $0 this year. + +Random Outputs: +Allocated: $4.25 ($0.15 over budget) +5 proposals funded out of 8 total proposals in the drawing + +Funded the following projects +Fund "B" for $1 bringing its project's annual total to $1. +Fund "C" for $1 bringing its project's annual total to $1. +Fund "D" for $0.5 bringing its project's annual total to $0.5. +Fund "E" for $1.5 bringing its project's annual total to $1.5. +Fund "F" for $0.25 bringing its project's annual total to $0.25. +""") def test_select_proposals_more_than_funds_eqweight_zero(capfd): - np.random.seed(2025) budget = 6 funding_limit = 2 proposals = [ @@ -172,41 +213,46 @@ def test_select_proposals_more_than_funds_eqweight_zero(capfd): ("H", 1, 0), ] - expected_result = {"A", "B", "C", "D", "H", "G"} - expected_captured = ( - "Inputs:\n" - "Budget: $6\n" - "Per-project funding limit: $2\n" - "Proposals in the drawing:\n" - '"A" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"B" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"C" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"D" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"E" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"F" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"G" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"H" requests $1 and is proposed by a project that has previously received $0 this year.\n' - "\n" - "Random Outputs:\n" - "Allocated: $6 ($0 under budget)\n" - "6 proposals funded out of 8 total proposals in the drawing\n\n" - "Funded the following projects\n" - 'Fund "B" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "H" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "G" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "D" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "C" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "A" for $1 bringing its project\'s annual total to $1.\n' - ) - result = select_proposals_to_fund(budget, funding_limit, proposals) + check_proportions(proposals, budget, funding_limit, rng=2025) + # Reset output. + capfd.readouterr() + + result = select_proposals_to_fund(budget, + funding_limit, + proposals, + seed=2025) captured = capfd.readouterr() - assert set(result) == expected_result - assert captured.out == expected_captured + assert set(result) == snapshot({'A','C','E','F','G','H'}) + assert captured.out == snapshot("""\ +Inputs: +Budget: $6 +Per-project funding limit: $2 +Proposals in the drawing: +"A" requests $1 and is proposed by a project that has previously received $0 this year. +"B" requests $1 and is proposed by a project that has previously received $0 this year. +"C" requests $1 and is proposed by a project that has previously received $0 this year. +"D" requests $1 and is proposed by a project that has previously received $0 this year. +"E" requests $1 and is proposed by a project that has previously received $0 this year. +"F" requests $1 and is proposed by a project that has previously received $0 this year. +"G" requests $1 and is proposed by a project that has previously received $0 this year. +"H" requests $1 and is proposed by a project that has previously received $0 this year. + +Random Outputs: +Allocated: $6 ($0 under budget) +6 proposals funded out of 8 total proposals in the drawing + +Funded the following projects +Fund "A" for $1 bringing its project's annual total to $1. +Fund "C" for $1 bringing its project's annual total to $1. +Fund "E" for $1 bringing its project's annual total to $1. +Fund "F" for $1 bringing its project's annual total to $1. +Fund "G" for $1 bringing its project's annual total to $1. +Fund "H" for $1 bringing its project's annual total to $1. +""") def test_select_proposals_more_than_funds_eqweight_under(capfd): - np.random.seed(2025) budget = 5.4 funding_limit = 2 proposals = [ @@ -220,40 +266,45 @@ def test_select_proposals_more_than_funds_eqweight_under(capfd): ("H", 1, 0), ] - expected_result = {"B", "C", "D", "H", "G"} - expected_captured = ( - "Inputs:\n" - "Budget: $5.4\n" - "Per-project funding limit: $2\n" - "Proposals in the drawing:\n" - '"A" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"B" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"C" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"D" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"E" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"F" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"G" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"H" requests $1 and is proposed by a project that has previously received $0 this year.\n' - "\n" - "Random Outputs:\n" - "Allocated: $5.0 ($0.4 under budget)\n" - "5 proposals funded out of 8 total proposals in the drawing\n\n" - "Funded the following projects\n" - 'Fund "B" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "H" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "G" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "D" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "C" for $1 bringing its project\'s annual total to $1.\n' - ) - result = select_proposals_to_fund(budget, funding_limit, proposals) + check_proportions(proposals, budget, funding_limit, rng=2025) + # Reset output. + capfd.readouterr() + + result = select_proposals_to_fund(budget, + funding_limit, + proposals, + seed=2025) captured = capfd.readouterr() - assert set(result) == expected_result - assert captured.out == expected_captured + assert set(result) == snapshot({'C','E','F','G','H'}) + assert captured.out == snapshot("""\ +Inputs: +Budget: $5.4 +Per-project funding limit: $2 +Proposals in the drawing: +"A" requests $1 and is proposed by a project that has previously received $0 this year. +"B" requests $1 and is proposed by a project that has previously received $0 this year. +"C" requests $1 and is proposed by a project that has previously received $0 this year. +"D" requests $1 and is proposed by a project that has previously received $0 this year. +"E" requests $1 and is proposed by a project that has previously received $0 this year. +"F" requests $1 and is proposed by a project that has previously received $0 this year. +"G" requests $1 and is proposed by a project that has previously received $0 this year. +"H" requests $1 and is proposed by a project that has previously received $0 this year. + +Random Outputs: +Allocated: $5.0 ($0.4 under budget) +5 proposals funded out of 8 total proposals in the drawing + +Funded the following projects +Fund "C" for $1 bringing its project's annual total to $1. +Fund "E" for $1 bringing its project's annual total to $1. +Fund "F" for $1 bringing its project's annual total to $1. +Fund "G" for $1 bringing its project's annual total to $1. +Fund "H" for $1 bringing its project's annual total to $1. +""") def test_select_proposals_more_than_funds_eqweight_over(capfd): - np.random.seed(2025) budget = 6.6 funding_limit = 2 proposals = [ @@ -267,35 +318,37 @@ def test_select_proposals_more_than_funds_eqweight_over(capfd): ("H", 1, 0), ] - expected_result = {"A", "B", "C", "D", "F", "H", "G"} - expected_captured = ( - "Inputs:\n" - "Budget: $6.6\n" - "Per-project funding limit: $2\n" - "Proposals in the drawing:\n" - '"A" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"B" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"C" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"D" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"E" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"F" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"G" requests $1 and is proposed by a project that has previously received $0 this year.\n' - '"H" requests $1 and is proposed by a project that has previously received $0 this year.\n' - "\n" - "Random Outputs:\n" - "Allocated: $7.0 ($0.4 over budget)\n" - "7 proposals funded out of 8 total proposals in the drawing\n\n" - "Funded the following projects\n" - 'Fund "B" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "H" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "G" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "D" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "C" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "A" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "F" for $1 bringing its project\'s annual total to $1.\n' - ) - result = select_proposals_to_fund(budget, funding_limit, proposals) + result = select_proposals_to_fund(budget, + funding_limit, + proposals, + seed=2025) captured = capfd.readouterr() - assert set(result) == expected_result - assert captured.out == expected_captured + assert set(result) == snapshot({'A','B','C','E','F','G','H'}) + assert captured.out == snapshot("""\ +Inputs: +Budget: $6.6 +Per-project funding limit: $2 +Proposals in the drawing: +"A" requests $1 and is proposed by a project that has previously received $0 this year. +"B" requests $1 and is proposed by a project that has previously received $0 this year. +"C" requests $1 and is proposed by a project that has previously received $0 this year. +"D" requests $1 and is proposed by a project that has previously received $0 this year. +"E" requests $1 and is proposed by a project that has previously received $0 this year. +"F" requests $1 and is proposed by a project that has previously received $0 this year. +"G" requests $1 and is proposed by a project that has previously received $0 this year. +"H" requests $1 and is proposed by a project that has previously received $0 this year. + +Random Outputs: +Allocated: $7.0 ($0.4 over budget) +7 proposals funded out of 8 total proposals in the drawing + +Funded the following projects +Fund "A" for $1 bringing its project's annual total to $1. +Fund "B" for $1 bringing its project's annual total to $1. +Fund "C" for $1 bringing its project's annual total to $1. +Fund "E" for $1 bringing its project's annual total to $1. +Fund "F" for $1 bringing its project's annual total to $1. +Fund "G" for $1 bringing its project's annual total to $1. +Fund "H" for $1 bringing its project's annual total to $1. +""") diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..f68060c --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest +inline_snapshot