From f1bdf75ae257195ad1dfa6c0420d1c72bbfc0b77 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 16:07:30 -0400 Subject: [PATCH 1/3] Give each person, not each group, the dataset default role build_from_dataset sized the default-role fallback by the group IDs, so a dataset without role columns got one members_role entry per group. Role queries on such a population then raised (nb_persons, sum with role=) or broadcast one group's entry over everyone (project, max with role=). Size the fallback by the membership array, and resolve the default against each group entity: a role it names, the first subrole of a role with subroles it names, or else the entity's first role, as people given no role in a situation get. join_with_persons now rejects a role array whose length differs from the persons'. Co-Authored-By: Claude Opus 5.5 --- .../fix-default-role-person-length.fixed.md | 1 + policyengine_core/simulations/simulation.py | 31 ++- .../simulations/simulation_builder.py | 8 + tests/core/test_dataset_default_role.py | 206 ++++++++++++++++++ .../test_dataset_default_role_property.py | 190 ++++++++++++++++ 5 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fix-default-role-person-length.fixed.md create mode 100644 tests/core/test_dataset_default_role.py create mode 100644 tests/core/test_dataset_default_role_property.py diff --git a/changelog.d/fix-default-role-person-length.fixed.md b/changelog.d/fix-default-role-person-length.fixed.md new file mode 100644 index 000000000..113c646ee --- /dev/null +++ b/changelog.d/fix-default-role-person-length.fixed.md @@ -0,0 +1 @@ +A dataset without role columns now gives every person, not every group, the default role, so role queries such as `nb_persons(role=...)` and `sum(..., role=...)` work on it; a `default_role` that names no role of a group entity gives that entity's first role instead of no role, and `join_with_persons` now rejects a role array whose length differs from the persons'. diff --git a/policyengine_core/simulations/simulation.py b/policyengine_core/simulations/simulation.py index 1efe0c6b5..7816ef991 100644 --- a/policyengine_core/simulations/simulation.py +++ b/policyengine_core/simulations/simulation.py @@ -162,7 +162,31 @@ def _latest_input_key(period: Period, definition_period: str) -> tuple: ) +def _default_role_key(entity: "GroupEntity", default_role: str) -> str: + """The role key a dataset with no role column gives each member of ``entity``. + + ``default_role`` may name a role that members hold directly, or a role + with subroles, whose first subrole they get. ``Simulation.default_role`` + is one key for every group entity, so it can name no role of ``entity`` + (``"member"`` in a household of parents and children); then members get + the entity's first role, as people given no role in a situation do. A key + that names no role would otherwise leave members holding no role at all, + so every role query would find nobody. + """ + roles = entity.flattened_roles + if not roles: + return default_role + for role in roles: + if role.key == default_role: + return role.key + for role in entity.roles: + if role.key == default_role and role.subroles: + return role.subroles[0].key + return roles[0].key + + if TYPE_CHECKING: + from policyengine_core.entities import GroupEntity from policyengine_core.taxbenefitsystems import TaxBenefitSystem from policyengine_core.experimental import MemoryConfig @@ -605,7 +629,12 @@ def get_eternity_array(name): elif "role" in data: person_roles = get_eternity_array("role") elif self.default_role is not None: - person_roles = np.full(len(entity_ids), self.default_role) + # One role per person, like the membership IDs: ``entity_ids`` + # holds one entry per group, not per person. + person_roles = np.full( + len(person_membership_ids), + _default_role_key(group_entity, self.default_role), + ) else: raise ValueError( f"Missing {person_role_field} column in the dataset. Each group entity must have a person role array defined for ETERNITY." diff --git a/policyengine_core/simulations/simulation_builder.py b/policyengine_core/simulations/simulation_builder.py index e3dee0cc5..9ba5e1ec8 100644 --- a/policyengine_core/simulations/simulation_builder.py +++ b/policyengine_core/simulations/simulation_builder.py @@ -291,6 +291,14 @@ def join_with_persons( flattened_roles = group_population.entity.flattened_roles roles_array = np.array(roles) + if roles_array.ndim != 1 or len(roles_array) != len(persons_group_assignment): + # ``members_role`` is indexed like ``members_entity_id``: one entry + # per person. Any other length breaks every role query later. + raise ValueError( + f"Got {roles_array.size} {group_population.entity.key} role(s) " + f"for {len(persons_group_assignment)} person(s); give one role " + "per person." + ) if np.issubdtype(roles_array.dtype, np.integer): group_population.members_role = np.array(flattened_roles)[roles_array] else: diff --git a/tests/core/test_dataset_default_role.py b/tests/core/test_dataset_default_role.py new file mode 100644 index 000000000..295db47e1 --- /dev/null +++ b/tests/core/test_dataset_default_role.py @@ -0,0 +1,206 @@ +"""A dataset without role columns gives every person the default role. + +``Simulation.build_from_dataset`` falls back to ``Simulation.default_role`` +when a dataset has no ``person__role`` or ``role`` column. It used to +size that fallback by the number of groups, so ``members_role`` had one entry +per group instead of one per person, and role queries (``nb_persons``, +``sum``, ``project``, ``has_role`` with a role) raised or broadcast a +single group's entry over everyone. A default that named no role of the +entity (``"member"`` in a household of parents and children) also left +everyone holding no role at all. + +``test_dataset_default_role_property.py`` checks the same invariants for +random datasets, group entities and default roles. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from policyengine_core.country_template import entities +from policyengine_core.data import Dataset +from policyengine_core.entities import build_entity +from policyengine_core.periods import ETERNITY +from policyengine_core.simulations import Simulation, SimulationBuilder +from policyengine_core.simulations.simulation import _default_role_key + +Household = entities.Household + + +def in_memory_dataset(data, data_format=Dataset.ARRAYS, time_period="2024"): + """A dataset whose ``load`` returns ``data``, like ``Dataset.from_dataframe``.""" + return type( + "InMemoryDataset", + (Dataset,), + { + "name": "in_memory", + "label": "In-memory dataset", + "data_format": data_format, + "file_path": "in_memory", + "time_period": time_period, + "load": lambda self: data, + }, + )() + + +def simulate(tax_benefit_system, dataset, default_role=Simulation.default_role): + simulation_class = type( + "DefaultRoleSimulation", (Simulation,), {"default_role": default_role} + ) + return simulation_class(tax_benefit_system=tax_benefit_system, dataset=dataset) + + +def test_one_household_of_three_gives_three_roles(tax_benefit_system): + # The audit witness: three persons in one household, no role column. + simulation = simulate( + tax_benefit_system, + in_memory_dataset( + { + "person_id": np.array([1, 2, 3]), + "household_id": np.array([10]), + "person_household_id": np.array([10, 10, 10]), + } + ), + ) + household = simulation.populations["household"] + + assert len(household.members_role) == 3 + # "member" names no household role, so persons take the first one. + assert list(household.members_role) == [Household.FIRST_PARENT] * 3 + assert household.nb_persons(role=Household.PARENT).tolist() == [3] + assert household.nb_persons(role=Household.CHILD).tolist() == [0] + assert household.sum(np.array([1.0, 2.0, 4.0]), role=Household.PARENT).tolist() == [ + 7.0 + ] + assert simulation.persons.has_role(Household.FIRST_PARENT).tolist() == [True] * 3 + + +def test_saved_dataset_gives_one_role_per_person(tax_benefit_system, tmp_path): + # The same failure through an HDF5 file, with two households of + # different sizes so no shape broadcasts by accident. + class TwoHouseholds(Dataset): + name = "two_households" + label = "Two households" + file_path = tmp_path / "two_households.h5" + data_format = Dataset.TIME_PERIOD_ARRAYS + + TwoHouseholds().save_dataset( + { + "person_id": {ETERNITY: np.array([1, 2, 3, 4, 5])}, + "household_id": {ETERNITY: np.array([10, 20])}, + "person_household_id": {ETERNITY: np.array([20, 10, 20, 20, 10])}, + } + ) + simulation = simulate(tax_benefit_system, TwoHouseholds(), default_role="child") + household = simulation.populations["household"] + + assert list(household.members_role) == [Household.CHILD] * 5 + assert household.nb_persons(role=Household.CHILD).tolist() == [2, 3] + assert household.nb_persons(role=Household.PARENT).tolist() == [0, 0] + assert household.project(np.array([7.0, 9.0]), role=Household.CHILD).tolist() == [ + 9.0, + 7.0, + 9.0, + 9.0, + 7.0, + ] + assert household.max(np.arange(5.0), role=Household.CHILD).tolist() == [4.0, 3.0] + + +def test_flat_file_gives_one_role_per_person(tax_benefit_system): + dataframe = pd.DataFrame( + { + "person_id": [10, 11, 12, 13], + "person_household_id": [700, 500, 700, 700], + } + ) + simulation = simulate(tax_benefit_system, Dataset.from_dataframe(dataframe, "2024")) + household = simulation.populations["household"] + + assert len(household.members_role) == 4 + assert household.nb_persons(role=Household.PARENT).tolist() == [1, 3] + + +@pytest.mark.parametrize( + "default_role, expected", + [ + ("child", "child"), + ("second_parent", "second_parent"), + # A role with subroles is held through its first subrole. + ("parent", "first_parent"), + # A key that names no household role falls back to the first role. + ("member", "first_parent"), + ], +) +def test_default_role_key_names_a_role_of_the_entity(default_role, expected): + assert _default_role_key(Household, default_role) == expected + + +def test_default_role_key_resolves_against_each_entity(): + # One ``default_role`` serves every group entity; a role with subroles is + # matched even when it is not the entity's first role. + family = build_entity( + key="family", + plural="families", + label="Family", + roles=[ + {"key": "child", "plural": "children"}, + {"key": "parent", "plural": "parents", "subroles": ["mother", "father"]}, + ], + ) + unit = build_entity( + key="unit", + plural="units", + label="Unit", + roles=[{"key": "member", "plural": "members"}], + ) + + assert _default_role_key(family, "parent") == "mother" + assert _default_role_key(family, "member") == "child" + assert _default_role_key(unit, "parent") == "member" + + +def test_no_default_role_still_requires_a_role_column(tax_benefit_system): + with pytest.raises(ValueError, match="person_household_role"): + simulate( + tax_benefit_system, + in_memory_dataset( + { + "person_id": np.array([1, 2]), + "household_id": np.array([10]), + "person_household_id": np.array([10, 10]), + } + ), + default_role=None, + ) + + +def test_role_column_overrides_the_default(tax_benefit_system): + simulation = simulate( + tax_benefit_system, + in_memory_dataset( + { + "person_id": np.array([1, 2, 3]), + "household_id": np.array([10]), + "person_household_id": np.array([10, 10, 10]), + # Indices into the flattened roles: first_parent, child, child. + "person_household_role": np.array([0, 2, 2]), + } + ), + ) + household = simulation.populations["household"] + + assert household.nb_persons(role=Household.PARENT).tolist() == [1] + assert household.nb_persons(role=Household.CHILD).tolist() == [2] + + +def test_join_rejects_one_role_per_group(tax_benefit_system): + builder = SimulationBuilder() + builder.create_entities(tax_benefit_system) + builder.declare_person_entity("person", [1, 2, 3]) + household = builder.declare_entity("household", [10]) + + with pytest.raises(ValueError, match="Got 1 household role\\(s\\) for 3 person"): + builder.join_with_persons(household, [10, 10, 10], ["first_parent"]) diff --git a/tests/core/test_dataset_default_role_property.py b/tests/core/test_dataset_default_role_property.py new file mode 100644 index 000000000..7de4f43c8 --- /dev/null +++ b/tests/core/test_dataset_default_role_property.py @@ -0,0 +1,190 @@ +"""Property: a dataset without role columns gives every person one role. + +For any persons, any assignment of them to the groups of several group +entities (empty groups included), any of the three dataset formats and any +``Simulation.default_role``, building the simulation must give, for every +group entity: + +* ``members_role`` with one entry per person, like ``members_entity_id``; +* every member holding the same role of that entity: the role the default + names, the first subrole of a role with subroles it names, or else the + entity's first role; +* role queries shaped like their non-role counterparts (one cell per group + for ``nb_persons``, ``sum`` and ``max``; one per person for ``project`` and + ``has_role``), equal to them for every role that contains the default role + and to the empty result for every other role. + +Examples, including the audit witness, are in ``test_dataset_default_role.py``. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +# The smoke job installs Core without the dev extra but collects every module. +pytest.importorskip("hypothesis") + +from hypothesis import given, settings # noqa: E402 +from hypothesis import strategies as st # noqa: E402 + +from policyengine_core.data import Dataset # noqa: E402 +from policyengine_core.entities import build_entity # noqa: E402 +from policyengine_core.periods import ETERNITY # noqa: E402 +from policyengine_core.taxbenefitsystems import TaxBenefitSystem # noqa: E402 +from tests.core.test_dataset_default_role import ( # noqa: E402 + in_memory_dataset, + simulate, +) + +PERSON = build_entity(key="person", plural="persons", label="Person", is_person=True) +GROUP_ENTITIES = [ + # Shaped like the country template's household. + build_entity( + key="household", + plural="households", + label="Household", + roles=[ + { + "key": "parent", + "plural": "parents", + "subroles": ["first_parent", "second_parent"], + }, + {"key": "child", "plural": "children"}, + ], + ), + # A role with subroles that is not the first role. + build_entity( + key="family", + plural="families", + label="Family", + roles=[ + {"key": "child", "plural": "children"}, + {"key": "parent", "plural": "parents", "subroles": ["mother", "father"]}, + ], + ), + # Shaped like the country models' groups: one role, "member". + build_entity( + key="unit", + plural="units", + label="Unit", + roles=[{"key": "member", "plural": "members"}], + ), +] +SYSTEM = TaxBenefitSystem([PERSON, *GROUP_ENTITIES]) +DEFAULT_ROLES = [ + "member", + "parent", + "child", + "first_parent", + "second_parent", + "mother", + "father", + "nobody", +] + + +def expected_default_role(entity, default_role): + for role in entity.flattened_roles: + if role.key == default_role: + return role + for role in entity.roles: + if role.key == default_role and role.subroles: + return role.subroles[0] + return entity.flattened_roles[0] + + +@st.composite +def _datasets(draw): + persons = draw(st.integers(min_value=0, max_value=12)) + data_format = draw( + st.sampled_from([Dataset.ARRAYS, Dataset.TIME_PERIOD_ARRAYS, Dataset.FLAT_FILE]) + ) + columns = {"person_id": np.arange(100, 100 + persons)} + for entity in GROUP_ENTITIES: + if data_format == Dataset.FLAT_FILE: + # A flat file declares groups only through their members. + group_ids = list(range(max(persons, 1))) + else: + group_ids = draw( + st.lists( + st.integers(min_value=-50, max_value=50), + min_size=1 if persons else 0, + max_size=6, + unique=True, + ) + ) + columns[f"{entity.key}_id"] = np.array(group_ids, dtype=int) + columns[f"person_{entity.key}_id"] = np.array( + draw( + st.lists( + st.sampled_from(group_ids) if group_ids else st.nothing(), + min_size=persons, + max_size=persons, + ) + ), + dtype=int, + ) + if data_format == Dataset.FLAT_FILE: + dataset = Dataset.from_dataframe(pd.DataFrame(columns), "2024") + elif data_format == Dataset.TIME_PERIOD_ARRAYS: + dataset = in_memory_dataset( + {name: {ETERNITY: values} for name, values in columns.items()}, + data_format, + ) + else: + dataset = in_memory_dataset(columns, data_format) + values = draw( + st.lists( + st.integers(min_value=-1000, max_value=1000), + min_size=persons, + max_size=persons, + ) + ) + return dataset, persons, np.array(values, dtype=float) + + +@settings(max_examples=200, deadline=None) +@given(_datasets(), st.sampled_from(DEFAULT_ROLES)) +def test_every_person_holds_one_default_role(case, default_role): + dataset, persons, values = case + simulation = simulate(SYSTEM, dataset, default_role=default_role) + + for entity in GROUP_ENTITIES: + group = simulation.populations[entity.key] + role = expected_default_role(entity, default_role) + + # One role per person, and it is a role of this entity. + assert len(group.members_role) == persons == len(group.members_entity_id) + assert all(member_role is role for member_role in group.members_role) + + group_values = np.arange(group.count, dtype=float) + for query_role in [*entity.roles, *entity.flattened_roles]: + holds = query_role is role or role in (query_role.subroles or []) + has_role = simulation.persons.has_role(query_role) + nb_persons = group.nb_persons(role=query_role) + total = group.sum(values, role=query_role) + projected = group.project(group_values, role=query_role) + + assert has_role.shape == (persons,) + assert nb_persons.shape == total.shape == (group.count,) + assert projected.shape == (persons,) + if holds: + assert has_role.all() + assert np.array_equal(nb_persons, group.nb_persons()) + assert np.array_equal(total, group.sum(values)) + assert np.array_equal(projected, group.project(group_values)) + else: + assert not has_role.any() + assert not nb_persons.any() + assert not total.any() + assert not projected.any() + if persons: + # ``reduce`` needs at least one person to size its loop. + maximum = group.max(values, role=query_role) + assert maximum.shape == (group.count,) + if holds: + assert np.array_equal(maximum, group.max(values)) + else: + assert (maximum == -np.inf).all() From 9ba5d19d6a4a0713a026d93a5dab696696f76cef Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 13:41:55 -0400 Subject: [PATCH 2/3] Preserve literal dataset defaults and enforce role capacity --- .../fix-default-role-person-length.fixed.md | 2 +- policyengine_core/simulations/simulation.py | 63 ++-- .../simulations/simulation_builder.py | 7 + tests/core/test_dataset_default_role.py | 233 ++++++++++-- .../test_dataset_default_role_property.py | 341 ++++++++++++------ 5 files changed, 481 insertions(+), 165 deletions(-) diff --git a/changelog.d/fix-default-role-person-length.fixed.md b/changelog.d/fix-default-role-person-length.fixed.md index 113c646ee..c143d511e 100644 --- a/changelog.d/fix-default-role-person-length.fixed.md +++ b/changelog.d/fix-default-role-person-length.fixed.md @@ -1 +1 @@ -A dataset without role columns now gives every person, not every group, the default role, so role queries such as `nb_persons(role=...)` and `sum(..., role=...)` work on it; a `default_role` that names no role of a group entity gives that entity's first role instead of no role, and `join_with_persons` now rejects a role array whose length differs from the persons'. +Give each person, not each group, the literal dataset default role, preserve unmatched defaults as unrecognized, require explicit roles when a recognized default would exceed its per-group capacity, and reject role arrays with invalid dimensions or membership lengths. diff --git a/policyengine_core/simulations/simulation.py b/policyengine_core/simulations/simulation.py index 7816ef991..c9629106a 100644 --- a/policyengine_core/simulations/simulation.py +++ b/policyengine_core/simulations/simulation.py @@ -162,31 +162,7 @@ def _latest_input_key(period: Period, definition_period: str) -> tuple: ) -def _default_role_key(entity: "GroupEntity", default_role: str) -> str: - """The role key a dataset with no role column gives each member of ``entity``. - - ``default_role`` may name a role that members hold directly, or a role - with subroles, whose first subrole they get. ``Simulation.default_role`` - is one key for every group entity, so it can name no role of ``entity`` - (``"member"`` in a household of parents and children); then members get - the entity's first role, as people given no role in a situation do. A key - that names no role would otherwise leave members holding no role at all, - so every role query would find nobody. - """ - roles = entity.flattened_roles - if not roles: - return default_role - for role in roles: - if role.key == default_role: - return role.key - for role in entity.roles: - if role.key == default_role and role.subroles: - return role.subroles[0].key - return roles[0].key - - if TYPE_CHECKING: - from policyengine_core.entities import GroupEntity from policyengine_core.taxbenefitsystems import TaxBenefitSystem from policyengine_core.experimental import MemoryConfig @@ -552,7 +528,18 @@ def build_from_populations(self, populations: Dict[str, Population]) -> None: self.create_shortcuts() def build_from_dataset(self) -> None: - """Build a simulation from a dataset.""" + """Build a simulation from a dataset. + + Entity-specific role columns take precedence over a generic ``role`` + column. Without either, each person receives the literal ``default_role`` + key. Only flattened-role keys match: unmatched and compound keys remain + unrecognized rather than selecting a first role or subrole. + + A recognized implicit default with a finite role maximum must fit every + group's membership count. Otherwise an explicit role column is required. + Explicit dataset role columns do not validate role capacity. + ``default_role=None`` requires a role column. + """ self.build_from_populations(self.tax_benefit_system.instantiate_entities()) from policyengine_core.simulations.simulation_builder import ( SimulationBuilder, @@ -629,12 +616,28 @@ def get_eternity_array(name): elif "role" in data: person_roles = get_eternity_array("role") elif self.default_role is not None: - # One role per person, like the membership IDs: ``entity_ids`` - # holds one entry per group, not per person. - person_roles = np.full( - len(person_membership_ids), - _default_role_key(group_entity, self.default_role), + default_role = next( + ( + role + for role in group_entity.flattened_roles + if role.key == self.default_role + ), + None, ) + if default_role is not None and default_role.max is not None: + membership_counts = np.unique( + person_membership_ids, return_counts=True + )[1] + if np.any(membership_counts > default_role.max): + raise ValueError( + f"Cannot assign default role {self.default_role!r} " + f"to every person in {group_entity.key}: at most " + f"{default_role.max} member(s) per group may hold it; " + f"provide an explicit {person_role_field} column." + ) + # One role per person, like the membership IDs: ``entity_ids`` + # holds one entry per group. Keep literal matching as on master. + person_roles = np.full(len(person_membership_ids), self.default_role) else: raise ValueError( f"Missing {person_role_field} column in the dataset. Each group entity must have a person role array defined for ETERNITY." diff --git a/policyengine_core/simulations/simulation_builder.py b/policyengine_core/simulations/simulation_builder.py index 9ba5e1ec8..8310379c7 100644 --- a/policyengine_core/simulations/simulation_builder.py +++ b/policyengine_core/simulations/simulation_builder.py @@ -281,6 +281,13 @@ def join_with_persons( persons_group_assignment: ArrayLike, roles: typing.Iterable[str], ) -> None: + """Join persons to groups with one role per membership. + + ``roles`` must be one dimensional and match the membership array's length. + Integers index the entity's flattened roles; strings match flattened-role + keys literally, with unmatched keys represented as unrecognized roles. + This method does not validate role capacity. + """ # Maps group's identifiers to a 0-based integer range, for indexing into members_roles (see PR#876) group_sorted_indices = np.unique(persons_group_assignment, return_inverse=True)[ 1 diff --git a/tests/core/test_dataset_default_role.py b/tests/core/test_dataset_default_role.py index 295db47e1..3191aac84 100644 --- a/tests/core/test_dataset_default_role.py +++ b/tests/core/test_dataset_default_role.py @@ -5,9 +5,9 @@ size that fallback by the number of groups, so ``members_role`` had one entry per group instead of one per person, and role queries (``nb_persons``, ``sum``, ``project``, ``has_role`` with a role) raised or broadcast a -single group's entry over everyone. A default that named no role of the -entity (``"member"`` in a household of parents and children) also left -everyone holding no role at all. +single group's entry over everyone. Literal default keys keep their existing +meaning: unmatched and compound keys leave members holding no recognized role. +Recognized defaults must fit the role's per-group capacity. ``test_dataset_default_role_property.py`` checks the same invariants for random datasets, group entities and default roles. @@ -24,7 +24,8 @@ from policyengine_core.entities import build_entity from policyengine_core.periods import ETERNITY from policyengine_core.simulations import Simulation, SimulationBuilder -from policyengine_core.simulations.simulation import _default_role_key +from policyengine_core.taxbenefitsystems import TaxBenefitSystem +from policyengine_core.tools import simulation_dumper Household = entities.Household @@ -52,6 +53,144 @@ def simulate(tax_benefit_system, dataset, default_role=Simulation.default_role): return simulation_class(tax_benefit_system=tax_benefit_system, dataset=dataset) +@pytest.mark.parametrize("default_role", ["member", "parent"]) +@pytest.mark.parametrize( + "query", ["value_from_person", "unique_role_projector", "partner_projection"] +) +def test_unmatched_default_preserves_unique_role_queries( + tax_benefit_system, default_role, query +): + # Review witness: both people belong to 10; household 20 is empty. + simulation = simulate( + tax_benefit_system, + in_memory_dataset( + { + "person_id": np.array([1, 2]), + "household_id": np.array([10, 20]), + "person_household_id": np.array([10, 10]), + } + ), + default_role=default_role, + ) + household = simulation.populations["household"] + values = np.array([100, 200]) + if query == "value_from_person": + result = household.value_from_person(values, Household.FIRST_PARENT) + elif query == "unique_role_projector": + result = household.first_parent.transform(values) + else: + result = simulation.persons.value_from_partner( + values, simulation.persons.household, Household.PARENT + ) + # Neither literal key is a flattened role: no parent contributes a value. + assert result.tolist() == [0, 0] + + +@pytest.mark.parametrize("default_role", ["first_parent", "second_parent"]) +def test_unique_default_requires_explicit_roles_when_capacity_exceeded( + tax_benefit_system, default_role +): + with pytest.raises( + ValueError, match="default role.*at most 1.*person_household_role" + ): + simulate( + tax_benefit_system, + in_memory_dataset( + { + "person_id": np.array([1, 2]), + "household_id": np.array([10, 20]), + "person_household_id": np.array([10, 10]), + } + ), + default_role=default_role, + ) + + +def test_unique_default_with_one_person_per_group_supports_projection( + tax_benefit_system, +): + simulation = simulate( + tax_benefit_system, + in_memory_dataset( + { + "person_id": np.array([1, 2]), + "household_id": np.array([10, 20, 30]), + "person_household_id": np.array([20, 10]), + } + ), + default_role="first_parent", + ) + household = simulation.household + values = np.array([100, 200]) + # ID 10 has value 200; ID 20 has value 100; ID 30 has no parent (0). + assert household.value_from_person(values, Household.FIRST_PARENT).tolist() == [ + 200, + 100, + 0, + ] + assert household.first_parent.transform(values).tolist() == [200, 100, 0] + assert simulation.persons.value_from_partner( + values, simulation.persons.household, Household.PARENT + ).tolist() == [0, 0] + + +@pytest.mark.parametrize("capacity, persons", [(0, 0), (0, 1), (2, 2), (2, 3)]) +def test_default_respects_noncompound_role_capacity(capacity, persons): + unit = build_entity( + key="unit", + plural="units", + label="Unit", + roles=[{"key": "member", "max": capacity}], + ) + dataset = in_memory_dataset( + { + "person_id": np.arange(persons), + "unit_id": np.array([10, 20]), + "person_unit_id": np.full(persons, 10, dtype=int), + } + ) + system = TaxBenefitSystem([entities.Person, unit]) + if persons > capacity: + with pytest.raises(ValueError, match=f"default role.*at most {capacity}"): + simulate(system, dataset) + else: + simulation = simulate(system, dataset) + assert list(simulation.unit.members_role) == [unit.MEMBER] * persons + + +@pytest.mark.parametrize( + "default_role, encoded", [("child", "child"), ("member", "unknown")] +) +def test_dataset_default_roles_survive_dump_restore( + tax_benefit_system, tmp_path, default_role, encoded +): + simulation = simulate( + tax_benefit_system, + in_memory_dataset( + { + "person_id": np.array([1, 2, 3]), + "household_id": np.array([10, 20]), + "person_household_id": np.array([10, 10, 20]), + } + ), + default_role=default_role, + ) + directory = tmp_path / "dump" + simulation_dumper.dump_simulation(simulation, str(directory)) + stored_roles = np.load( + directory / "__entities__" / "household" / "members_role.npy" + ) + assert stored_roles.tolist() == [encoded] * 3 + restored = simulation_dumper.restore_simulation(str(directory), tax_benefit_system) + np.testing.assert_array_equal( + restored.household.members_role, simulation.household.members_role + ) + np.testing.assert_array_equal( + restored.household.nb_persons(role=Household.CHILD), + simulation.household.nb_persons(role=Household.CHILD), + ) + + def test_one_household_of_three_gives_three_roles(tax_benefit_system): # The audit witness: three persons in one household, no role column. simulation = simulate( @@ -67,14 +206,15 @@ def test_one_household_of_three_gives_three_roles(tax_benefit_system): household = simulation.populations["household"] assert len(household.members_role) == 3 - # "member" names no household role, so persons take the first one. - assert list(household.members_role) == [Household.FIRST_PARENT] * 3 - assert household.nb_persons(role=Household.PARENT).tolist() == [3] + # As on master, "member" names no flattened household role. + assert list(household.members_role) == [0] * 3 + assert household.nb_persons(role=Household.PARENT).tolist() == [0] assert household.nb_persons(role=Household.CHILD).tolist() == [0] assert household.sum(np.array([1.0, 2.0, 4.0]), role=Household.PARENT).tolist() == [ - 7.0 + # No recognized parents: the filtered sum is 0 + 0 + 0 = 0. + 0.0 ] - assert simulation.persons.has_role(Household.FIRST_PARENT).tolist() == [True] * 3 + assert simulation.persons.has_role(Household.FIRST_PARENT).tolist() == [False] * 3 def test_saved_dataset_gives_one_role_per_person(tax_benefit_system, tmp_path): @@ -97,6 +237,7 @@ class TwoHouseholds(Dataset): household = simulation.populations["household"] assert list(household.members_role) == [Household.CHILD] * 5 + # ID 10 has persons 1 and 4 (2); ID 20 has persons 0, 2 and 3 (3). assert household.nb_persons(role=Household.CHILD).tolist() == [2, 3] assert household.nb_persons(role=Household.PARENT).tolist() == [0, 0] assert household.project(np.array([7.0, 9.0]), role=Household.CHILD).tolist() == [ @@ -106,6 +247,7 @@ class TwoHouseholds(Dataset): 9.0, 7.0, ] + # max(1, 4) = 4 for ID 10; max(0, 2, 3) = 3 for ID 20. assert household.max(np.arange(5.0), role=Household.CHILD).tolist() == [4.0, 3.0] @@ -116,31 +258,44 @@ def test_flat_file_gives_one_role_per_person(tax_benefit_system): "person_household_id": [700, 500, 700, 700], } ) - simulation = simulate(tax_benefit_system, Dataset.from_dataframe(dataframe, "2024")) + simulation = simulate( + tax_benefit_system, + Dataset.from_dataframe(dataframe, "2024"), + default_role="child", + ) household = simulation.populations["household"] assert len(household.members_role) == 4 - assert household.nb_persons(role=Household.PARENT).tolist() == [1, 3] + assert household.nb_persons(role=Household.CHILD).tolist() == [1, 3] @pytest.mark.parametrize( - "default_role, expected", + "default_role, expected_role", [ - ("child", "child"), - ("second_parent", "second_parent"), - # A role with subroles is held through its first subrole. - ("parent", "first_parent"), - # A key that names no household role falls back to the first role. - ("member", "first_parent"), + ("child", Household.CHILD), + ("second_parent", Household.SECOND_PARENT), + ("parent", 0), + ("member", 0), ], ) -def test_default_role_key_names_a_role_of_the_entity(default_role, expected): - assert _default_role_key(Household, default_role) == expected +def test_default_role_keeps_literal_join_semantics( + tax_benefit_system, default_role, expected_role +): + simulation = simulate( + tax_benefit_system, + in_memory_dataset( + { + "person_id": np.array([1, 2]), + "household_id": np.array([10, 20]), + "person_household_id": np.array([20, 10]), + } + ), + default_role=default_role, + ) + assert list(simulation.household.members_role) == [expected_role] * 2 -def test_default_role_key_resolves_against_each_entity(): - # One ``default_role`` serves every group entity; a role with subroles is - # matched even when it is not the entity's first role. +def test_default_role_keeps_literal_semantics_for_each_entity(): family = build_entity( key="family", plural="families", @@ -157,9 +312,21 @@ def test_default_role_key_resolves_against_each_entity(): roles=[{"key": "member", "plural": "members"}], ) - assert _default_role_key(family, "parent") == "mother" - assert _default_role_key(family, "member") == "child" - assert _default_role_key(unit, "parent") == "member" + simulation = simulate( + TaxBenefitSystem([entities.Person, family, unit]), + in_memory_dataset( + { + "person_id": np.array([1, 2]), + "family_id": np.array([10]), + "person_family_id": np.array([10, 10]), + "unit_id": np.array([20]), + "person_unit_id": np.array([20, 20]), + } + ), + default_role="member", + ) + assert list(simulation.family.members_role) == [0, 0] + assert list(simulation.unit.members_role) == [unit.MEMBER] * 2 def test_no_default_role_still_requires_a_role_column(tax_benefit_system): @@ -177,7 +344,8 @@ def test_no_default_role_still_requires_a_role_column(tax_benefit_system): ) -def test_role_column_overrides_the_default(tax_benefit_system): +@pytest.mark.parametrize("role_column", ["person_household_role", "role"]) +def test_role_column_overrides_the_default(tax_benefit_system, role_column): simulation = simulate( tax_benefit_system, in_memory_dataset( @@ -186,9 +354,11 @@ def test_role_column_overrides_the_default(tax_benefit_system): "household_id": np.array([10]), "person_household_id": np.array([10, 10, 10]), # Indices into the flattened roles: first_parent, child, child. - "person_household_role": np.array([0, 2, 2]), + role_column: np.array([0, 2, 2]), } ), + # Three people exceed this default's capacity; explicit roles override it. + default_role="first_parent", ) household = simulation.populations["household"] @@ -196,11 +366,12 @@ def test_role_column_overrides_the_default(tax_benefit_system): assert household.nb_persons(role=Household.CHILD).tolist() == [2] -def test_join_rejects_one_role_per_group(tax_benefit_system): +@pytest.mark.parametrize("roles", [["first_parent"], [["first_parent"]] * 3, "child"]) +def test_join_rejects_invalid_role_shapes(tax_benefit_system, roles): builder = SimulationBuilder() builder.create_entities(tax_benefit_system) builder.declare_person_entity("person", [1, 2, 3]) household = builder.declare_entity("household", [10]) - with pytest.raises(ValueError, match="Got 1 household role\\(s\\) for 3 person"): - builder.join_with_persons(household, [10, 10, 10], ["first_parent"]) + with pytest.raises(ValueError, match="household role\\(s\\) for 3 person"): + builder.join_with_persons(household, [10, 10, 10], roles) diff --git a/tests/core/test_dataset_default_role_property.py b/tests/core/test_dataset_default_role_property.py index 7de4f43c8..acb0bfab4 100644 --- a/tests/core/test_dataset_default_role_property.py +++ b/tests/core/test_dataset_default_role_property.py @@ -1,24 +1,17 @@ -"""Property: a dataset without role columns gives every person one role. - -For any persons, any assignment of them to the groups of several group -entities (empty groups included), any of the three dataset formats and any -``Simulation.default_role``, building the simulation must give, for every -group entity: - -* ``members_role`` with one entry per person, like ``members_entity_id``; -* every member holding the same role of that entity: the role the default - names, the first subrole of a role with subroles it names, or else the - entity's first role; -* role queries shaped like their non-role counterparts (one cell per group - for ``nb_persons``, ``sum`` and ``max``; one per person for ``project`` and - ``has_role``), equal to them for every role that contains the default role - and to the empty result for every other role. - -Examples, including the audit witness, are in ``test_dataset_default_role.py``. +"""Dataset defaults preserve literal role matching and person-level cardinality. + +The role table below specifies expected assignments independently of the loader: +only a literal flattened-role key matches; unknown and compound keys remain 0. +For accepted defaults, role counts, aggregates, projections and unique lookups +are computed from the original dataset memberships. A capacity-limited default +must be rejected if assigning it uniformly would exceed its per-group maximum. """ from __future__ import annotations +from collections import Counter +from typing import NamedTuple + import numpy as np import pandas as pd import pytest @@ -26,12 +19,14 @@ # The smoke job installs Core without the dev extra but collects every module. pytest.importorskip("hypothesis") -from hypothesis import given, settings # noqa: E402 +from hypothesis import example, given, settings # noqa: E402 from hypothesis import strategies as st # noqa: E402 from policyengine_core.data import Dataset # noqa: E402 from policyengine_core.entities import build_entity # noqa: E402 from policyengine_core.periods import ETERNITY # noqa: E402 +from policyengine_core.projectors import UniqueRoleToEntityProjector # noqa: E402 +from policyengine_core.simulations import SimulationBuilder # noqa: E402 from policyengine_core.taxbenefitsystems import TaxBenefitSystem # noqa: E402 from tests.core.test_dataset_default_role import ( # noqa: E402 in_memory_dataset, @@ -39,40 +34,54 @@ ) PERSON = build_entity(key="person", plural="persons", label="Person", is_person=True) -GROUP_ENTITIES = [ - # Shaped like the country template's household. - build_entity( - key="household", - plural="households", - label="Household", - roles=[ - { - "key": "parent", - "plural": "parents", - "subroles": ["first_parent", "second_parent"], - }, - {"key": "child", "plural": "children"}, - ], - ), - # A role with subroles that is not the first role. - build_entity( - key="family", - plural="families", - label="Family", - roles=[ - {"key": "child", "plural": "children"}, - {"key": "parent", "plural": "parents", "subroles": ["mother", "father"]}, - ], - ), - # Shaped like the country models' groups: one role, "member". - build_entity( - key="unit", - plural="units", - label="Unit", - roles=[{"key": "member", "plural": "members"}], - ), -] +HOUSEHOLD = build_entity( + key="household", + plural="households", + label="Household", + roles=[ + { + "key": "parent", + "plural": "parents", + "subroles": ["first_parent", "second_parent"], + }, + {"key": "child", "plural": "children"}, + ], +) +FAMILY = build_entity( + key="family", + plural="families", + label="Family", + roles=[ + {"key": "child", "plural": "children"}, + {"key": "parent", "plural": "parents", "subroles": ["mother", "father"]}, + ], +) +UNIT = build_entity( + key="unit", + plural="units", + label="Unit", + roles=[{"key": "member", "plural": "members"}], +) +COMMITTEE = build_entity( + key="committee", + plural="committees", + label="Committee", + roles=[{"key": "delegate", "plural": "delegates", "max": 2}], +) +GROUP_ENTITIES = [HOUSEHOLD, FAMILY, UNIT, COMMITTEE] SYSTEM = TaxBenefitSystem([PERSON, *GROUP_ENTITIES]) + +# Deliberately explicit: this oracle does not reproduce a resolution algorithm. +EXPECTED_DEFAULT_ROLES = { + "household": { + "child": HOUSEHOLD.CHILD, + "first_parent": HOUSEHOLD.FIRST_PARENT, + "second_parent": HOUSEHOLD.SECOND_PARENT, + }, + "family": {"child": FAMILY.CHILD, "mother": FAMILY.MOTHER, "father": FAMILY.FATHER}, + "unit": {"member": UNIT.MEMBER}, + "committee": {"delegate": COMMITTEE.DELEGATE}, +} DEFAULT_ROLES = [ "member", "parent", @@ -81,18 +90,39 @@ "second_parent", "mother", "father", + "delegate", "nobody", ] -def expected_default_role(entity, default_role): - for role in entity.flattened_roles: - if role.key == default_role: - return role - for role in entity.roles: - if role.key == default_role and role.subroles: - return role.subroles[0] - return entity.flattened_roles[0] +class DatasetCase(NamedTuple): + dataset: Dataset + memberships: dict[str, np.ndarray] + values: np.ndarray + + +def _case(columns, data_format, values): + memberships = { + entity.key: columns[f"person_{entity.key}_id"].copy() + for entity in GROUP_ENTITIES + } + if data_format == Dataset.FLAT_FILE: + dataset = Dataset.from_dataframe(pd.DataFrame(columns), "2024") + elif data_format == Dataset.TIME_PERIOD_ARRAYS: + dataset = in_memory_dataset( + {name: {ETERNITY: array} for name, array in columns.items()}, data_format + ) + else: + dataset = in_memory_dataset(columns, data_format) + return DatasetCase(dataset, memberships, np.array(values, dtype=float)) + + +def _uniform_case(persons, group_ids, memberships): + columns = {"person_id": np.arange(1, persons + 1)} + for entity in GROUP_ENTITIES: + columns[f"{entity.key}_id"] = np.array(group_ids, dtype=int) + columns[f"person_{entity.key}_id"] = np.array(memberships, dtype=int) + return _case(columns, Dataset.ARRAYS, np.arange(1, persons + 1) * 100) @st.composite @@ -104,7 +134,8 @@ def _datasets(draw): columns = {"person_id": np.arange(100, 100 + persons)} for entity in GROUP_ENTITIES: if data_format == Dataset.FLAT_FILE: - # A flat file declares groups only through their members. + # The loader synthesizes group IDs 0..n-1 for a flat file. Choose + # consecutive occupied IDs to isolate roles from ID renumbering. group_ids = list(range(max(persons, 1))) else: group_ids = draw( @@ -115,26 +146,27 @@ def _datasets(draw): unique=True, ) ) - columns[f"{entity.key}_id"] = np.array(group_ids, dtype=int) - columns[f"person_{entity.key}_id"] = np.array( - draw( + if persons: + occupied = draw( + st.integers(min_value=1, max_value=min(persons, len(group_ids))) + ) + # Keep empty groups after occupied ones in sorted ID order. The + # existing join's treatment of other empty-group positions is + # separate from default-role assignment. + occupied_ids = sorted(group_ids)[:occupied] + memberships = occupied_ids + draw( st.lists( - st.sampled_from(group_ids) if group_ids else st.nothing(), - min_size=persons, - max_size=persons, + st.sampled_from(occupied_ids), + min_size=persons - occupied, + max_size=persons - occupied, ) - ), - dtype=int, - ) - if data_format == Dataset.FLAT_FILE: - dataset = Dataset.from_dataframe(pd.DataFrame(columns), "2024") - elif data_format == Dataset.TIME_PERIOD_ARRAYS: - dataset = in_memory_dataset( - {name: {ETERNITY: values} for name, values in columns.items()}, - data_format, - ) - else: - dataset = in_memory_dataset(columns, data_format) + ) + memberships = draw(st.permutations(memberships)) + else: + memberships = [] + if data_format != Dataset.FLAT_FILE: + columns[f"{entity.key}_id"] = np.array(group_ids, dtype=int) + columns[f"person_{entity.key}_id"] = np.array(memberships, dtype=int) values = draw( st.lists( st.integers(min_value=-1000, max_value=1000), @@ -142,49 +174,152 @@ def _datasets(draw): max_size=persons, ) ) - return dataset, persons, np.array(values, dtype=float) + return _case(columns, data_format, values) @settings(max_examples=200, deadline=None) @given(_datasets(), st.sampled_from(DEFAULT_ROLES)) -def test_every_person_holds_one_default_role(case, default_role): - dataset, persons, values = case +@example(_uniform_case(2, [10, 20], [10, 10]), "member") +@example(_uniform_case(3, [10, 20], [10, 10, 10]), "first_parent") +@example(_uniform_case(3, [10, 20], [10, 10, 10]), "delegate") +@example(_uniform_case(2, [10, 20], [10, 10]), "delegate") +@example(_uniform_case(3, [20, 10, 30], [20, 10, 30]), "first_parent") +def test_default_roles_preserve_literal_matching_and_capacity(case, default_role): + dataset, memberships, values = case + persons = len(values) + violations = [] + for entity in GROUP_ENTITIES: + role = EXPECTED_DEFAULT_ROLES[entity.key].get(default_role) + if role is not None and role.max is not None: + if any( + count > role.max for count in Counter(memberships[entity.key]).values() + ): + violations.append(entity) + if violations: + with pytest.raises(ValueError, match="default role.*at most") as error: + simulate(SYSTEM, dataset, default_role=default_role) + assert f"person_{violations[0].key}_role" in str(error.value) + return + simulation = simulate(SYSTEM, dataset, default_role=default_role) for entity in GROUP_ENTITIES: group = simulation.populations[entity.key] - role = expected_default_role(entity, default_role) + role = EXPECTED_DEFAULT_ROLES[entity.key].get(default_role) + raw_memberships = memberships[entity.key] - # One role per person, and it is a role of this entity. - assert len(group.members_role) == persons == len(group.members_entity_id) - assert all(member_role is role for member_role in group.members_role) + assert group.members_role.shape == (persons,) + assert len(group.members_entity_id) == persons + if role is None: + assert (group.members_role == 0).all() + else: + assert all(member_role is role for member_role in group.members_role) + if role.max is not None: + assert all( + count <= role.max for count in Counter(raw_memberships).values() + ) group_values = np.arange(group.count, dtype=float) + group_indices = {group_id: index for index, group_id in enumerate(group.ids)} + counts = np.array( + [(raw_memberships == group_id).sum() for group_id in group.ids] + ) + totals = np.array( + [values[raw_memberships == group_id].sum() for group_id in group.ids] + ) + projected_values = np.array( + [group_values[group_indices[group_id]] for group_id in raw_memberships] + ) + for query_role in [*entity.roles, *entity.flattened_roles]: - holds = query_role is role or role in (query_role.subroles or []) + holds = role is not None and ( + query_role is role or role in (query_role.subroles or []) + ) has_role = simulation.persons.has_role(query_role) nb_persons = group.nb_persons(role=query_role) total = group.sum(values, role=query_role) projected = group.project(group_values, role=query_role) - assert has_role.shape == (persons,) + assert has_role.shape == projected.shape == (persons,) assert nb_persons.shape == total.shape == (group.count,) - assert projected.shape == (persons,) - if holds: - assert has_role.all() - assert np.array_equal(nb_persons, group.nb_persons()) - assert np.array_equal(total, group.sum(values)) - assert np.array_equal(projected, group.project(group_values)) - else: - assert not has_role.any() - assert not nb_persons.any() - assert not total.any() - assert not projected.any() + assert np.array_equal(has_role, np.full(persons, holds)) + assert np.array_equal( + nb_persons, counts if holds else np.zeros(group.count) + ) + if query_role.max is not None: + assert (nb_persons <= query_role.max).all() + assert np.array_equal(total, totals if holds else np.zeros(group.count)) + assert np.array_equal( + projected, projected_values if holds else np.zeros(persons) + ) if persons: - # ``reduce`` needs at least one person to size its loop. maximum = group.max(values, role=query_role) + expected_maximum = np.array( + [ + max(values[raw_memberships == group_id], default=-np.inf) + if holds + else -np.inf + for group_id in group.ids + ] + ) assert maximum.shape == (group.count,) - if holds: - assert np.array_equal(maximum, group.max(values)) - else: - assert (maximum == -np.inf).all() + assert np.array_equal(maximum, expected_maximum) + + for unique_role in entity.flattened_roles: + if unique_role.max != 1: + continue + # The accepted-capacity invariant guarantees zero or one selected + # person per raw group ID. Unmatched roles select nobody. + expected_lookup = np.array( + [ + next(iter(values[raw_memberships == group_id]), -123) + if role is unique_role + else -123 + for group_id in group.ids + ] + ) + lookup = group.value_from_person(values, unique_role, default=-123) + assert lookup.shape == (group.count,) + assert np.array_equal(lookup, expected_lookup) + + expected_projector = np.array( + [ + next(iter(values[raw_memberships == group_id]), 0) + if role is unique_role + else 0 + for group_id in group.ids + ] + ) + projector = UniqueRoleToEntityProjector(group, unique_role) + assert np.array_equal(projector.transform(values), expected_projector) + + for compound_role in entity.roles: + if not compound_role.subroles or len(compound_role.subroles) != 2: + continue + # Uniform defaults cannot supply both partner subroles. Pass the + # public person-to-group projector so group values project back to + # person shape, as in existing partner contract tests. + partners = simulation.persons.value_from_partner( + values, getattr(simulation.persons, entity.key), compound_role + ) + assert partners.shape == (persons,) + assert not partners.any() + + +@settings(max_examples=50, deadline=None) +@given( + st.integers(min_value=1, max_value=50), st.sampled_from(["short", "long", "matrix"]) +) +@example(0, "matrix") +def test_join_requires_a_one_dimensional_role_per_person(persons, shape): + builder = SimulationBuilder() + builder.create_entities(SYSTEM) + builder.declare_person_entity("person", np.arange(persons)) + household = builder.declare_entity("household", [10]) + if shape == "matrix": + roles = np.zeros((persons, 1), dtype=int) + else: + roles = np.zeros(persons + (1 if shape == "long" else -1), dtype=int) + + with pytest.raises(ValueError, match="give one role per person"): + builder.join_with_persons(household, np.repeat(10, persons), roles) From ef0f9d5e2c59c791c33ed24adacbb0971a42824b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 15:40:11 -0400 Subject: [PATCH 3/3] Clarify dataset capacity and role shape behavior --- changelog.d/fix-default-role-person-length.fixed.md | 2 +- policyengine_core/simulations/simulation_builder.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/fix-default-role-person-length.fixed.md b/changelog.d/fix-default-role-person-length.fixed.md index c143d511e..5a7c7973b 100644 --- a/changelog.d/fix-default-role-person-length.fixed.md +++ b/changelog.d/fix-default-role-person-length.fixed.md @@ -1 +1 @@ -Give each person, not each group, the literal dataset default role, preserve unmatched defaults as unrecognized, require explicit roles when a recognized default would exceed its per-group capacity, and reject role arrays with invalid dimensions or membership lengths. +Reject implicit dataset defaults that exceed their recognized role's capacity per group and role arrays that are not one dimensional or do not match the membership count. diff --git a/policyengine_core/simulations/simulation_builder.py b/policyengine_core/simulations/simulation_builder.py index 8310379c7..7480fa9c0 100644 --- a/policyengine_core/simulations/simulation_builder.py +++ b/policyengine_core/simulations/simulation_builder.py @@ -300,7 +300,7 @@ def join_with_persons( roles_array = np.array(roles) if roles_array.ndim != 1 or len(roles_array) != len(persons_group_assignment): # ``members_role`` is indexed like ``members_entity_id``: one entry - # per person. Any other length breaks every role query later. + # per membership. Other shapes can make queries fail or broadcast incorrectly. raise ValueError( f"Got {roles_array.size} {group_population.entity.key} role(s) " f"for {len(persons_group_assignment)} person(s); give one role "