From e43b3617170bc24ab003830d00e8082a3a880b37 Mon Sep 17 00:00:00 2001 From: Matthew Brett Date: Wed, 8 Oct 2025 13:31:10 +0100 Subject: [PATCH 1/6] Refactor for clarity, add comments. We were confused by the algorithm, and this seemed generally undesirable, so we have attempted a refactor for greater clarity. We've also used the more standard RNG instance pattern for the random number generation. The tests are failing, but this seems also to be true of `main`. --- requirements.txt | 3 ++ scripts/project_selection.py | 77 +++++++++++++++++++------------ scripts/test_project_selection.py | 54 ++++++++++++++-------- test-requirements.txt | 2 + 4 files changed, 88 insertions(+), 48 deletions(-) create mode 100644 requirements.txt create mode 100644 test-requirements.txt 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..9fbd6ff 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,54 @@ 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) + # `seed` can be a seed (integer) or a random number generator. + rng = np.random.default_rng(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.' - ) + proposals = [Proposal(*p) for p in proposals] + n_proposals = len(proposals) - names = [str(p[0]) for p in proposals] - weights = [(funding_limit - p[2]) / p[1] for p in proposals] - - for w in weights: - assert w >= 1 # this should be redundant with the validation check above. - - 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. + 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) print("Inputs:") print(f"Budget: ${budget}") @@ -59,7 +74,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 +83,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..5755954 100644 --- a/scripts/test_project_selection.py +++ b/scripts/test_project_selection.py @@ -1,5 +1,4 @@ import pytest -import numpy as np from project_selection import select_proposals_to_fund @@ -7,28 +6,34 @@ @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)] @@ -55,7 +60,10 @@ def test_select_proposals_all_funds(capfd): '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' ) - 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 @@ -63,7 +71,6 @@ def test_select_proposals_all_funds(capfd): def test_select_proposals_more_than_funds(capfd): - np.random.seed(2025) budget = 5 funding_limit = 2 proposals = [ @@ -103,7 +110,10 @@ def test_select_proposals_more_than_funds(capfd): '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) + result = select_proposals_to_fund(budget, + funding_limit, + proposals, + seed=2025) captured = capfd.readouterr() assert set(result) == expected_result @@ -111,7 +121,6 @@ def test_select_proposals_more_than_funds(capfd): def test_select_proposals_more_than_funds_under(capfd): - np.random.seed(2025) budget = 4.1 funding_limit = 2 proposals = [ @@ -150,7 +159,10 @@ def test_select_proposals_more_than_funds_under(capfd): '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) + result = select_proposals_to_fund(budget, + funding_limit, + proposals, + seed=2025) captured = capfd.readouterr() assert set(result) == expected_result @@ -158,7 +170,6 @@ def test_select_proposals_more_than_funds_under(capfd): def test_select_proposals_more_than_funds_eqweight_zero(capfd): - np.random.seed(2025) budget = 6 funding_limit = 2 proposals = [ @@ -198,7 +209,10 @@ def test_select_proposals_more_than_funds_eqweight_zero(capfd): '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) + result = select_proposals_to_fund(budget, + funding_limit, + proposals, + seed=2025) captured = capfd.readouterr() assert set(result) == expected_result @@ -206,7 +220,6 @@ def test_select_proposals_more_than_funds_eqweight_zero(capfd): def test_select_proposals_more_than_funds_eqweight_under(capfd): - np.random.seed(2025) budget = 5.4 funding_limit = 2 proposals = [ @@ -245,7 +258,10 @@ def test_select_proposals_more_than_funds_eqweight_under(capfd): '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) + result = select_proposals_to_fund(budget, + funding_limit, + proposals, + seed=2025) captured = capfd.readouterr() assert set(result) == expected_result @@ -253,7 +269,6 @@ def test_select_proposals_more_than_funds_eqweight_under(capfd): def test_select_proposals_more_than_funds_eqweight_over(capfd): - np.random.seed(2025) budget = 6.6 funding_limit = 2 proposals = [ @@ -294,7 +309,10 @@ def test_select_proposals_more_than_funds_eqweight_over(capfd): '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 diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..26b77f6 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest From 958a14e45c8e5fa2721f09e45e936e47b9eb9b5b Mon Sep 17 00:00:00 2001 From: Matthew Brett Date: Fri, 17 Oct 2025 12:17:06 +0100 Subject: [PATCH 2/6] Show funded projects in proposal order This makes it easier to compare expected to actual test output. --- scripts/project_selection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/project_selection.py b/scripts/project_selection.py index 9fbd6ff..028c6db 100644 --- a/scripts/project_selection.py +++ b/scripts/project_selection.py @@ -87,7 +87,7 @@ def select_proposals_to_fund(budget, funding_limit, proposals, seed=None): print() print("Funded the following projects") - for p in funded: + for p in sorted(funded, key=lambda v : proposals.index(v)): print(f'Fund "{p.name}" for ${p.requested_amount} bringing its ' f'project\'s annual total to ${p.requested_amount + p.previous_funding}.') From 1dc973b752737a7c7647201070aef772a40a0dba Mon Sep 17 00:00:00 2001 From: Matthew Brett Date: Fri, 17 Oct 2025 17:50:14 +0100 Subject: [PATCH 3/6] Further refactoring, fix tests. --- scripts/project_selection.py | 27 +++++--- scripts/test_project_selection.py | 108 ++++++++++++++++++++---------- 2 files changed, 90 insertions(+), 45 deletions(-) diff --git a/scripts/project_selection.py b/scripts/project_selection.py index 028c6db..fe6b421 100644 --- a/scripts/project_selection.py +++ b/scripts/project_selection.py @@ -46,18 +46,15 @@ def select_proposals_to_fund(budget, funding_limit, proposals, seed=None): weights[i] = remaining_limit / p.requested_amount assert weights[i] >= 1 # this should be redundant with the validation check above. + fund_ordering = rng.choice(proposals, + p=weights / np.sum(weights), + size=n_proposals, + replace=False) + + # Check / filter proposals to those within budget deficit limit. funded = [] budget_remaining = budget - 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 - # 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] + for proposal in fund_ordering: budget_remaining -= proposal.requested_amount deficit = -budget_remaining if budget_remaining < 0 else 0 if deficit > proposal.requested_amount / 2: @@ -67,6 +64,14 @@ def select_proposals_to_fund(budget, funding_limit, proposals, seed=None): 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}") @@ -87,7 +92,7 @@ def select_proposals_to_fund(budget, funding_limit, proposals, seed=None): print() print("Funded the following projects") - for p in sorted(funded, key=lambda v : proposals.index(v)): + for p in funded: print(f'Fund "{p.name}" for ${p.requested_amount} bringing its ' f'project\'s annual total to ${p.requested_amount + p.previous_funding}.') diff --git a/scripts/test_project_selection.py b/scripts/test_project_selection.py index 5755954..705d430 100644 --- a/scripts/test_project_selection.py +++ b/scripts/test_project_selection.py @@ -1,3 +1,7 @@ +from collections import Counter + +import numpy as np + import pytest from project_selection import select_proposals_to_fund @@ -38,6 +42,7 @@ def test_select_proposals_all_funds(capfd): 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" @@ -54,11 +59,11 @@ 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, @@ -70,9 +75,28 @@ def test_select_proposals_all_funds(capfd): 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): budget = 5 funding_limit = 2 + # Weights inverse of requested funds. proposals = [ ("A", 2, 0), ("B", 1, 0), @@ -84,7 +108,11 @@ def test_select_proposals_more_than_funds(capfd): ("H", 0.7, 0), ] - expected_result = {"A", "B", "C", "D", "H", "F"} + check_proportions(proposals, budget, funding_limit, 2025) + # Reset output. + capfd.readouterr() + + expected_result = {'B', 'C', 'D', 'F', 'G', 'H'} expected_captured = ( "Inputs:\n" "Budget: $5\n" @@ -100,15 +128,15 @@ def test_select_proposals_more_than_funds(capfd): '"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" + 'Allocated: $5.35 ($0.35 over 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 "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' + 'Fund "F" for $0.25 bringing its project\'s annual total to $0.25.\n' + 'Fund "G" for $1.9 bringing its project\'s annual total to $1.9.\n' + 'Fund "H" for $0.7 bringing its project\'s annual total to $0.7.\n' ) result = select_proposals_to_fund(budget, funding_limit, @@ -134,7 +162,11 @@ def test_select_proposals_more_than_funds_under(capfd): ("H", 0.7, 0), # *2.85 ] - expected_result = {"B", "C", "D", "H", "F"} + check_proportions(proposals, budget, funding_limit, rng=2025) + # Reset output. + capfd.readouterr() + + expected_result = {'C', 'D', 'E', 'F', 'H'} expected_captured = ( "Inputs:\n" "Budget: $4.1\n" @@ -150,19 +182,19 @@ def test_select_proposals_more_than_funds_under(capfd): '"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" + 'Allocated: $3.95 ($0.15 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' + 'Fund "E" for $1.5 bringing its project\'s annual total to $1.5.\n' + 'Fund "F" for $0.25 bringing its project\'s annual total to $0.25.\n' + 'Fund "H" for $0.7 bringing its project\'s annual total to $0.7.\n' ) result = select_proposals_to_fund(budget, funding_limit, proposals, - seed=2025) + seed=2026) captured = capfd.readouterr() assert set(result) == expected_result @@ -183,7 +215,11 @@ def test_select_proposals_more_than_funds_eqweight_zero(capfd): ("H", 1, 0), ] - expected_result = {"A", "B", "C", "D", "H", "G"} + check_proportions(proposals, budget, funding_limit, rng=2025) + # Reset output. + capfd.readouterr() + + expected_result = {'A', 'C', 'D', 'F', 'G', 'H'} expected_captured = ( "Inputs:\n" "Budget: $6\n" @@ -202,12 +238,12 @@ def test_select_proposals_more_than_funds_eqweight_zero(capfd): "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' + 'Fund "C" 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 "F" 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 "H" for $1 bringing its project\'s annual total to $1.\n' ) result = select_proposals_to_fund(budget, funding_limit, @@ -233,7 +269,11 @@ def test_select_proposals_more_than_funds_eqweight_under(capfd): ("H", 1, 0), ] - expected_result = {"B", "C", "D", "H", "G"} + check_proportions(proposals, budget, funding_limit, rng=2025) + # Reset output. + capfd.readouterr() + + expected_result = {'A', 'C', 'D', 'G', 'H'} expected_captured = ( "Inputs:\n" "Budget: $5.4\n" @@ -252,11 +292,11 @@ def test_select_proposals_more_than_funds_eqweight_under(capfd): "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 "A" 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 "D" 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 "H" for $1 bringing its project\'s annual total to $1.\n' ) result = select_proposals_to_fund(budget, funding_limit, @@ -301,13 +341,13 @@ def test_select_proposals_more_than_funds_eqweight_over(capfd): "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 "A" for $1 bringing its project\'s annual total to $1.\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 "D" for $1 bringing its project\'s annual total to $1.\n' 'Fund "F" 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 "H" for $1 bringing its project\'s annual total to $1.\n' ) result = select_proposals_to_fund(budget, funding_limit, From 5de75b695b917b267de1f0b0e9b5d13cbcd52bb2 Mon Sep 17 00:00:00 2001 From: Matthew Brett Date: Fri, 17 Oct 2025 17:50:34 +0100 Subject: [PATCH 4/6] Add test CI - disabled for now. --- .github/test.pending | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/test.pending 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 . From 7ac5d85c0cb51c3ec81f68d05af3261fd1a47217 Mon Sep 17 00:00:00 2001 From: Matthew Brett Date: Fri, 24 Oct 2025 12:13:31 +0100 Subject: [PATCH 5/6] Go back to stepwise selection Although doing it in one go is neater, perhaps it will delay review by forcing reviewers to confirm it gives the same output. --- scripts/project_selection.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/scripts/project_selection.py b/scripts/project_selection.py index fe6b421..5082bd7 100644 --- a/scripts/project_selection.py +++ b/scripts/project_selection.py @@ -46,15 +46,19 @@ def select_proposals_to_fund(budget, funding_limit, proposals, seed=None): weights[i] = remaining_limit / p.requested_amount assert weights[i] >= 1 # this should be redundant with the validation check above. - fund_ordering = rng.choice(proposals, - p=weights / np.sum(weights), - size=n_proposals, - replace=False) - # Check / filter proposals to those within budget deficit limit. funded = [] budget_remaining = budget - for proposal in fund_ordering: + 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 + # 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] budget_remaining -= proposal.requested_amount deficit = -budget_remaining if budget_remaining < 0 else 0 if deficit > proposal.requested_amount / 2: From 848c0a6df12e7ab8a498026420cf3c45e5de968e Mon Sep 17 00:00:00 2001 From: Matthew Brett Date: Fri, 24 Oct 2025 12:14:23 +0100 Subject: [PATCH 6/6] Add / confirm snapshot tests --- pyproject.toml | 2 + scripts/test_project_selection.py | 273 +++++++++++++++--------------- test-requirements.txt | 1 + 3 files changed, 137 insertions(+), 139 deletions(-) create mode 100644 pyproject.toml 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/scripts/test_project_selection.py b/scripts/test_project_selection.py index 705d430..37a3515 100644 --- a/scripts/test_project_selection.py +++ b/scripts/test_project_selection.py @@ -3,6 +3,7 @@ import numpy as np import pytest +from inline_snapshot import snapshot from project_selection import select_proposals_to_fund @@ -112,40 +113,38 @@ def test_select_proposals_more_than_funds(capfd): # Reset output. capfd.readouterr() - expected_result = {'B', 'C', 'D', 'F', 'G', 'H'} - 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.35 ($0.35 over 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 "C" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "D" for $0.5 bringing its project\'s annual total to $0.5.\n' - 'Fund "F" for $0.25 bringing its project\'s annual total to $0.25.\n' - 'Fund "G" for $1.9 bringing its project\'s annual total to $1.9.\n' - 'Fund "H" for $0.7 bringing its project\'s annual total to $0.7.\n' - ) 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): @@ -166,39 +165,38 @@ def test_select_proposals_more_than_funds_under(capfd): # Reset output. capfd.readouterr() - expected_result = {'C', 'D', 'E', 'F', 'H'} - 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.95 ($0.15 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 "D" for $0.5 bringing its project\'s annual total to $0.5.\n' - 'Fund "E" for $1.5 bringing its project\'s annual total to $1.5.\n' - 'Fund "F" for $0.25 bringing its project\'s annual total to $0.25.\n' - 'Fund "H" for $0.7 bringing its project\'s annual total to $0.7.\n' - ) 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): @@ -219,40 +217,39 @@ def test_select_proposals_more_than_funds_eqweight_zero(capfd): # Reset output. capfd.readouterr() - expected_result = {'A', 'C', 'D', 'F', 'G', 'H'} - 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 "A" 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 "D" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "F" 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 "H" for $1 bringing its project\'s annual total to $1.\n' - ) 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): @@ -273,39 +270,38 @@ def test_select_proposals_more_than_funds_eqweight_under(capfd): # Reset output. capfd.readouterr() - expected_result = {'A', 'C', 'D', 'G', 'H'} - 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 "A" 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 "D" 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 "H" for $1 bringing its project\'s annual total to $1.\n' - ) 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): @@ -322,38 +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 "A" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "B" 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 "D" for $1 bringing its project\'s annual total to $1.\n' - 'Fund "F" 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 "H" for $1 bringing its project\'s annual total to $1.\n' - ) 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 index 26b77f6..f68060c 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,2 +1,3 @@ -r requirements.txt pytest +inline_snapshot