From 2ba7f3919db22e2179442b680882f7a5ed073822 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Mon, 31 Aug 2026 23:07:12 +0200 Subject: [PATCH 1/3] Allow DREAM/HERMES to run without a diversify field LEGACY genuinely needs at least one Diversify-mode field, but DREAM and HERMES's swap machinery degrades gracefully to optimising unique meetings only when no diversity fields are configured -- so drop the same upfront guard for those two algorithms. Doing so surfaces two latent crashes in the now-reachable degenerate path, both fixed here: - HERMES's pareto_prob_for() called max() on the empty pareto_probs dict whenever a candidate improved no diversity field, which is every candidate once there are no diversity fields at all -- a guaranteed ValueError on the first swap. Falls back to 0.0 instead, which keeps select_key() on its meeting-uniqueness branch, DREAM's own natural behaviour in this case. - (Investigated, not a real bug: DREAM/HERMES's shared select_key() divides by the summed pareto score of the tied candidates, which looked like a possible zero-division once no diversity fields contribute a nonzero score -- but pareto_swaps()'s own distinct-value collapsing always leaves exactly one candidate whenever every candidate's score is 0, so that branch's division is never reached with >1 candidates. Verified empirically across many seeds/configs.) Confirmed byte-identical DREAM/HERMES output before/after this patch whenever a diversify field is present -- this only changes behaviour in what used to be exception paths. Adds regression tests for both algorithms running without a diversify field, and for LEGACY still requiring one. --- groupselect/algorithms/algorithm_dream.py | 11 ++++---- groupselect/algorithms/algorithm_hermes.py | 21 ++++++++------ tests/test_pandas.py | 32 ++++++++++++++++++++++ 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/groupselect/algorithms/algorithm_dream.py b/groupselect/algorithms/algorithm_dream.py index 523d18d..f08aae9 100644 --- a/groupselect/algorithms/algorithm_dream.py +++ b/groupselect/algorithms/algorithm_dream.py @@ -92,12 +92,11 @@ def algorithm_dream( for j in order_cluster + order_diverse: peopledata_vals_used[i][j] = int(participants[i][j]) - if not order_diverse_dict: - raise Exception( - "Error: One diversification field required!", - "You have to set at least one field that is used to " - "diversify people across groups.", - ) + # No diversification field is required: with `order_diverse_dict` + # empty, `cats_diverse` is empty throughout the swap machinery below, + # so every table is reported as perfectly balanced and swaps are + # chosen on unique-meeting count alone -- DREAM then behaves as a + # meeting-optimisation-only allocator. if len(order_cluster_dict) > 1: raise Exception( diff --git a/groupselect/algorithms/algorithm_hermes.py b/groupselect/algorithms/algorithm_hermes.py index 05e089f..fa7651a 100644 --- a/groupselect/algorithms/algorithm_hermes.py +++ b/groupselect/algorithms/algorithm_hermes.py @@ -106,12 +106,10 @@ def algorithm_hermes( for j in order_cluster + order_diverse: peopledata_vals_used[i][j] = int(participants[i][j]) - if not order_diverse_dict: - raise Exception( - "Error: One diversification field required!", - "You have to set at least one field that is used to " - "diversify people across groups.", - ) + # No diversification field is required: with `order_diverse_dict` + # empty, `cats_diverse` is empty throughout the swap machinery below + # (see `pareto_swaps` in this module), so HERMES then behaves as a + # meeting-optimisation-only allocator, same as DREAM in that case. if len(order_cluster_dict) > 1: raise Exception( @@ -380,12 +378,19 @@ def pareto_swaps( # Use the highest configured probability among the diversity # fields the chosen candidate actually pareto-improves (falling # back to the overall maximum if it improves none, e.g. it was - # accepted purely for its meeting-uniqueness score). + # accepted purely for its meeting-uniqueness score). With no + # diversity fields configured at all, `pareto_probs` is empty and + # no candidate ever pareto-improves a field, so fall back to 0.0 + # instead of `max()`-ing an empty sequence -- `select_key` then + # always takes its meeting-uniqueness branch, matching DREAM's + # own degenerate no-diversify-field behaviour. def pareto_prob_for(k): relevant = [ pareto_probs[field_id] for field_id in demog_scores.get(k, ()) ] - return max(relevant) if relevant else max(pareto_probs.values()) + if relevant: + return max(relevant) + return max(pareto_probs.values()) if pareto_probs else 0.0 final_swap = select_key( final_candidates, final_meetings, pareto_prob_for, random diff --git a/tests/test_pandas.py b/tests/test_pandas.py index e443344..886b441 100644 --- a/tests/test_pandas.py +++ b/tests/test_pandas.py @@ -29,3 +29,35 @@ def test_philipps_example_data(): "display.max_rows", None, "display.max_columns", None ): print(res) + + +@pytest.mark.parametrize("algorithm", ["DREAM", "HERMES"]) +def test_dream_hermes_without_diversify_field(algorithm): + """DREAM/HERMES must allocate even with no diversify field defined. + + Unlike LEGACY (see ``test_legacy_requires_diversify_field`` below), + DREAM and HERMES do not need a diversify field: their swap machinery + degrades to optimising only for unique meetings when no diversity + fields are configured -- see `algorithm_dream.py`/`algorithm_hermes.py` + for how this is handled. + """ + df = example_data_pd["default"] + settings = {"pareto_prob": 0.4} if algorithm == "DREAM" else {} + res = df.groupselect.allocate( + fields={"photo consent": "cluster"}, + n_part_per_group=6, + algorithm=algorithm, + settings=settings, + ) + assert len(res) == len(df) + + +def test_legacy_requires_diversify_field(): + """LEGACY, unlike DREAM/HERMES, still requires a diversify field.""" + df = example_data_pd["default"] + with pytest.raises(Exception, match="diversification field required"): + df.groupselect.allocate( + fields={"photo consent": "cluster"}, + n_part_per_group=3 * [6], + algorithm="Legacy", + ) From d84367492b165c80c74690c2f67e4d5518809e38 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Mon, 31 Aug 2026 23:14:40 +0200 Subject: [PATCH 2/3] Fix dtype crash in allocate_pandas() with an empty fields dict `participants[list(fields.keys())].apply(...)` on a zero-column selection (fields={}) returns an empty DataFrame with no columns to infer a dtype from, so pandas defaults it to float64. to_numpy() then carries that float64 dtype through to allocate_numpy(), which rejects it with "Argument participants must only contain integers." -- masking whatever the algorithm would otherwise have said (including LEGACY's own, more meaningful "One diversification field required!" for this same input). Build the (n_participants, 0) array directly, with an explicit integer dtype, when fields is empty, instead of going through to_numpy() on the empty-column selection. Confirmed byte-identical allocate_pandas() output before/after this patch whenever fields is non-empty -- the new branch only fires for the previously-broken fields={} case. Adds regression tests for DREAM/HERMES allocating with fields={} (needs the DREAM/HERMES-without-diversify-field patch stacked below this one, since fields={} implies no diversify field either), and for LEGACY now raising its own diversification-field error for fields={} instead of the dtype error. --- groupselect/allocate_pandas.py | 15 ++++++++++++++- tests/test_pandas.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/groupselect/allocate_pandas.py b/groupselect/allocate_pandas.py index fe4216e..0e5a477 100644 --- a/groupselect/allocate_pandas.py +++ b/groupselect/allocate_pandas.py @@ -3,6 +3,8 @@ import warnings from typing import Callable, Iterable, Hashable +import numpy as np + from groupselect import FieldMode, Algorithm, allocate_numpy, AllocatorResult @@ -57,7 +59,18 @@ def allocate_pandas( participants_codes = participants[list(fields.keys())].apply( lambda col: col.cat.codes ) - participants_numpy = participants_codes.to_numpy() + if fields: + participants_numpy = participants_codes.to_numpy() + else: + # `.apply()` on a zero-column selection returns an empty + # DataFrame with no columns to infer a dtype from, so pandas + # defaults it to float64 -- `to_numpy()` would then fail + # `allocate_numpy`'s integer-dtype check below. Build the + # (n_participants, 0) array directly instead, with an explicit + # integer dtype. + participants_numpy = np.empty( + (len(participants_codes), 0), dtype=int + ) fields_numpy = { participants_codes.columns.tolist().index(k): v for k, v in fields.items() diff --git a/tests/test_pandas.py b/tests/test_pandas.py index 886b441..f9cc4a9 100644 --- a/tests/test_pandas.py +++ b/tests/test_pandas.py @@ -61,3 +61,34 @@ def test_legacy_requires_diversify_field(): n_part_per_group=3 * [6], algorithm="Legacy", ) + + +@pytest.mark.parametrize("algorithm", ["DREAM", "HERMES"]) +def test_dream_hermes_with_no_fields_at_all(algorithm): + """DREAM/HERMES must allocate with an entirely empty `fields` dict. + + `participants[[]].apply(...)` returns a zero-column DataFrame that + pandas defaults to dtype float64 (there are no columns to infer a + dtype from), which used to fail `allocate_numpy`'s integer-dtype + check -- see `allocate_pandas()`'s explicit `fields`-empty branch. + """ + df = example_data_pd["default"] + settings = {"pareto_prob": 0.4} if algorithm == "DREAM" else {} + res = df.groupselect.allocate( + fields={}, + n_part_per_group=6, + algorithm=algorithm, + settings=settings, + ) + assert len(res) == len(df) + + +def test_legacy_requires_diversify_field_with_no_fields_at_all(): + """LEGACY must still reject an entirely empty `fields` dict too.""" + df = example_data_pd["default"] + with pytest.raises(Exception, match="diversification field required"): + df.groupselect.allocate( + fields={}, + n_part_per_group=6, + algorithm="Legacy", + ) From 53b1d61b312b3aa25f1969ee4c6a7ae2856767d6 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Mon, 31 Aug 2026 23:28:30 +0200 Subject: [PATCH 3/3] Fixed formatting --- groupselect/allocate_pandas.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/groupselect/allocate_pandas.py b/groupselect/allocate_pandas.py index 0e5a477..b6a6188 100644 --- a/groupselect/allocate_pandas.py +++ b/groupselect/allocate_pandas.py @@ -68,9 +68,7 @@ def allocate_pandas( # `allocate_numpy`'s integer-dtype check below. Build the # (n_participants, 0) array directly instead, with an explicit # integer dtype. - participants_numpy = np.empty( - (len(participants_codes), 0), dtype=int - ) + participants_numpy = np.empty((len(participants_codes), 0), dtype=int) fields_numpy = { participants_codes.columns.tolist().index(k): v for k, v in fields.items()