Skip to content
Merged
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 @@ -1169,6 +1181,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
21 changes: 17 additions & 4 deletions policyengine_core/simulations/simulation_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,20 +385,33 @@ def join_with_persons(
must match a unique declared group ID by value; equal numeric IDs
may have different dtypes. ``members_entity_id`` indexes the declared
group order, including groups with no members. An empty assignment
is supported. Roles are interpreted as role keys or integer indices.
is supported. ``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.

Raises:
ValueError: if declared group IDs repeat or a membership names
an undeclared group.
ValueError: if roles are not one dimensional with one entry per
membership, declared group IDs repeat, or a membership cannot
be matched by value to a declared group.
"""
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."
)
group_population.members_entity_id = group_positions(
group_population.ids,
persons_group_assignment,
group_population.entity.key,
)

flattened_roles = group_population.entity.flattened_roles
roles_array = np.array(roles)
if np.issubdtype(roles_array.dtype, np.integer):
group_population.members_role = np.array(flattened_roles)[roles_array]
else:
Expand Down
Loading
Loading