Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fix-default-role-person-length.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
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.
34 changes: 33 additions & 1 deletion policyengine_core/simulations/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1078,7 +1078,19 @@ 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, including period-suffixed entity role columns in flat files.
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,
Expand Down Expand Up @@ -1155,6 +1167,26 @@ def get_eternity_array(name):
elif "role" in data:
person_roles = get_eternity_array("role")
elif self.default_role is not None:
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."
)
# Preserve master's one literal default key per declared person.
person_roles = np.full(self.persons.count, self.default_role)
else:
raise ValueError(
Expand Down
15 changes: 15 additions & 0 deletions policyengine_core/simulations/simulation_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -291,6 +298,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 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 "
"per person."
)
if np.issubdtype(roles_array.dtype, np.integer):
group_population.members_role = np.array(flattened_roles)[roles_array]
else:
Expand Down
Loading
Loading