Skip to content

Commit c1f28ee

Browse files
committed
Add CHANGELOG entry for 44.4.0 (entity links accessors, dynamic population helpers)
Made-with: Cursor
1 parent cb829ac commit c1f28ee

10 files changed

Lines changed: 514 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## 44.4.0 [#1364](https://github.com/openfisca/openfisca-core/pull/1364)
4+
5+
#### New features
6+
7+
- **Entity links**: role-based and positional accessors, and dynamic population period-index helpers.
8+
- `Many2OneLink.get_by_role(variable_name, period, role_value=...)`, `One2ManyLink.get_by_role(...)` and `ImplicitOne2ManyLink.get_by_role(...)`.
9+
- `Many2OneLink.rank(variable_name, period)` (and on chained getter, e.g. `person.links["mother"].household.rank("age", period)`).
10+
- `One2ManyLink.nth(n, variable_name, period, role=..., condition=...)` for the n-th target member per source.
11+
- `has_role(role_value)` now supports `Role` objects (comparison by `.key`) in addition to raw values.
12+
- `CorePopulation.snapshot_period(period)` and `get_period_id_to_rownum(period)` for optional dynamic-population period indexing.
13+
14+
#### Technical changes
15+
16+
- Removed unused `openfisca_core.model_api` import in `tests/core/parameters_date_indexing/test_date_indexing.py`.
17+
318
## 44.3.0
419

520
#### New Features

docs/implementation/links-api.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Entity Links API
2+
3+
OpenFisca Core now includes a generic Entity Link system. Links allow variables computed on one entity to be queried and aggregated from another, or even within the same entity.
4+
5+
## Declaring Links
6+
7+
Links are declared on `Entity` objects, typically when building the `TaxBenefitSystem`.
8+
9+
### 1. Many-to-One Links
10+
A `Many2OneLink` resolves many source members (e.g., persons) to one target entity (e.g., a household, an employer, or another person).
11+
12+
```python
13+
from openfisca_core.links import Many2OneLink
14+
15+
# Example: Intra-entity link (person to mother)
16+
# The `mother_id` variable must be defined on `person` and contain the ID of the mother.
17+
mother_link = Many2OneLink(
18+
name="mother",
19+
link_field="mother_id",
20+
target_entity_key="person",
21+
)
22+
person_entity.add_link(mother_link)
23+
24+
# Usage in a variable formula:
25+
# persons.mother.get("age", period)
26+
# or chained: persons.mother.household.get("rent", period)
27+
```
28+
29+
### 2. One-to-Many Links
30+
A `One2ManyLink` resolves one source entity to many target members. By default, OpenFisca implicitly creates a `One2ManyLink` for every GroupEntity pointing to its members (e.g., `household.persons`).
31+
32+
```python
33+
from openfisca_core.links import One2ManyLink
34+
35+
# Example: Inter-entity link (employer to employees)
36+
# The `employer_id` variable must be defined on `person` and contain the employer ID.
37+
employees_link = One2ManyLink(
38+
name="employees",
39+
link_field="employer_id",
40+
target_entity_key="person", # the target returned
41+
)
42+
employer_entity.add_link(employees_link)
43+
44+
# Usage in a variable formula:
45+
# employers.employees.sum("salary", period)
46+
```
47+
48+
## Using Links in Formulas
49+
50+
When a link is declared on a population, it is exposed as an attribute matching the link's `name`.
51+
52+
### Many2One Methods
53+
54+
* **`link.get(variable_name, period)`**: Returns the target variable values mapped to each source member. Unmapped members receive the default value of the variable.
55+
* **Syntactic sugar**: `link(variable_name, period)` is equivalent to `link.get(variable_name, period)`.
56+
* **Chaining**: `<source>.link1.link2` returns an intermediate chained getter, so `.link1.link2.get(variable, period)` fetches the target variable across two link jumps.
57+
58+
### One2Many Methods
59+
60+
All One2Many aggregation methods return an array sized to the **source** entity. They all take `(variable_name, period)` + optional keyword arguments `role` and `condition` to filter the targets before aggregation.
61+
62+
* `link.sum(...)`
63+
* `link.count(...)`
64+
* `link.any(...)`
65+
* `link.all(...)`
66+
* `link.min(...)`
67+
* `link.max(...)`
68+
* `link.avg(...)`
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Transition Guide: Moving to the New Entity Links
2+
3+
With the release of the **Generic Entity Links** API, OpenFisca-core gains the ability to map complex, graph-like relational structures natively.
4+
5+
This guide explains the primary differences between the legacy `GroupEntity` + `Projectors` approach and the flexible, modern `Many2OneLink` and `One2ManyLink` models, and how you should think about migration.
6+
7+
---
8+
9+
## 1. Why Transition? The "Strict Hierarchy" Problem
10+
11+
Historically, OpenFisca rigidly structured populations into two classes: `SingleEntity` (Persons) and `GroupEntity` (Households, Families, Tax Units).
12+
13+
In this model, **every person must belong to exactly one entity of each group type.**
14+
This handles standard socio-tax models efficiently, but prohibits features like:
15+
- **Intra-entity (horizontal) relations**: Modeling a mother/child bond, marriages, or kinship networks. *Persons couldn't map to other Persons.*
16+
- **Unbounded inter-entity relations**: Employment networks where one `company` controls multiple `persons`, or geographical relations (people living in specific arbitrary administrative districts).
17+
18+
**The Solution:** The new Entity Links system is purely arbitrary and structural. You can declare `Many2OneLink` (N source members to 1 target entity) or `One2ManyLink` (aggregating 1 target back to N source members) linking *any population type to any other population type.*
19+
20+
---
21+
22+
## 2. You don’t *have* to migrate existing simple groups.
23+
24+
**Backward Compatibility is 100% Guaranteed.**
25+
26+
If you have a traditional `GroupEntity` defined for households, those work exactly as they always have. In fact, OpenFisca now silently powers them using the new Linking engine gracefully:
27+
- The legacy `person.household(...)` projector maps to a new automatically injected `ImplicitMany2OneLink`.
28+
- The legacy `household.sum(person_salaries)` maps logically to `household.persons.sum()`.
29+
30+
No code change is required in any existing variable formulas!
31+
32+
---
33+
34+
## 3. From Projectors to Links: The New Syntax
35+
36+
If you previously dealt with `Projectors`, you may have found chaining difficult or buggy. The new system standardizes data lookup through `link.get()` and properties filtering.
37+
38+
### Before: Projectors
39+
If you wanted the value of `rent` for the household of a person:
40+
```python
41+
# Projector syntax
42+
rents = person.household("rent", period)
43+
```
44+
45+
### After: Link Syntax
46+
The same syntax continues to work (it actually calls `.get()` internally now on the implicitly generated link!), but you can explicitly specify `.get()`:
47+
```python
48+
# New link syntax
49+
rents = person.household.get("rent", period)
50+
```
51+
52+
**Where the new syntax shines:** Deep chaining.
53+
You can now continuously resolve attributes down a deep relationship chain effortlessly:
54+
```python
55+
# Imagine a link: `person -> mother_person -> mother_household -> region`
56+
chain = person.mother.household.get("region", period)
57+
```
58+
59+
---
60+
61+
## 4. Transitioning Aggregations: `sum`, `count`, `min`, `max`
62+
63+
Previously, aggregating members relied rigidly on passing entire pre-computed arrays to a heavy `GroupPopulation.sum()` handler.
64+
65+
### Before: Legacy GroupPopulation
66+
```python
67+
# Fetch array of all persons in simulation
68+
salaries = persons("salary", period)
69+
# Pass to the group entity (e.g. household) to aggregate and collapse
70+
total_household_incomes = households.sum(salaries, role=Household.PARENT)
71+
```
72+
73+
### After: Declarative Links
74+
```python
75+
# The logic operates directly on the `One2ManyLink` bridging the two entities.
76+
total_household_incomes = households.persons.sum("salary", period, role=Household.PARENT)
77+
```
78+
Notice how declarative and explicit this is. `persons` is the plural of `person`, which the new system automatically exposed as a `One2ManyLink` on your household.
79+
80+
### Conditional Aggregations
81+
A newly-available feature explicitly unlocked by the Link system is masking by arbitrary properties! You are no longer restricted strictly to OpenFisca Roles:
82+
```python
83+
is_female = persons("is_female", period)
84+
# Sum salaries, but only for members who are `is_female`
85+
female_incomes = households.persons.sum("salary", period, condition=is_female)
86+
```
87+
88+
---
89+
90+
## 5. Summary Checklist for Country Packages
91+
- [ ] You **do not** need to rewrite `GroupEntity` logic for entities whose only purpose is traditional demographic grouping (like core households).
92+
- [ ] You **can** start using `households.persons.sum()`, `households.persons.any()`, `households.persons.avg()` for highly readable aggregations in new variables.
93+
- [ ] You **should** use `Many2OneLink` immediately if your simulation model attempts to relate `persons` to specific entities beyond openfisca-standard hierarchical groups (like a `mother_id` linking to another row in the `persons` dataframe).
94+
95+
Please see the full `links-api.md` file in this directory to see exactly how to declare explicit `Many2OneLink` models inside your `TaxBenefitSystem`.

openfisca_core/links/implicit.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,19 @@ def _apply_filters(self, period, values, role, condition):
5858

5959
if role is not None:
6060
roles = self._source_population.members_role
61-
mask &= roles == role
61+
# roles may be an object array of Role instances, so compare by key
62+
if roles.dtype == object:
63+
try:
64+
keys = numpy.fromiter(
65+
(getattr(x, "key", x) for x in roles),
66+
dtype=object,
67+
)
68+
except Exception:
69+
mask &= roles == role
70+
else:
71+
mask &= keys == role
72+
else:
73+
mask &= roles == role
6274

6375
if condition is not None:
6476
mask &= condition
@@ -69,5 +81,31 @@ def _apply_filters(self, period, values, role, condition):
6981
valid = source_rows >= 0
7082
return source_rows[valid], values[valid]
7183

84+
# override to avoid relying on ``role_field`` which is meaningless for
85+
# implicit links (the role information is stored on the source population)
86+
def get_by_role(
87+
self,
88+
variable_name: str,
89+
period,
90+
role_value,
91+
*,
92+
condition: numpy.ndarray | None = None,
93+
) -> numpy.ndarray:
94+
"""Fetch value for a specific role value on a one-to-many implicit link.
95+
96+
This mirrors :meth:`One2ManyLink.get_by_role` but uses
97+
``self._source_population.members_role`` instead of a named role field
98+
on the target population.
99+
"""
100+
values = self._target_population.simulation.calculate(variable_name, period)
101+
source_rows, values = self._apply_filters(period, values, role_value, condition)
102+
103+
result = numpy.zeros(self._source_population.count, dtype=values.dtype)
104+
# last value wins (same semantics as GroupPopulation.value_from_person)
105+
for tgt_idx, src in enumerate(source_rows):
106+
if src >= 0:
107+
result[src] = values[tgt_idx]
108+
return result
109+
72110

73111
__all__ = ["ImplicitMany2OneLink", "ImplicitOne2ManyLink"]

openfisca_core/links/many2one.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,70 @@ def role(self) -> numpy.ndarray | None:
129129
)
130130

131131
def has_role(self, role_value) -> numpy.ndarray:
132-
"""Boolean mask: does each source member have the given role?"""
132+
"""Boolean mask: does each source member have the given role?
133+
134+
The ``role`` array may contain raw values (ints, strings) or
135+
``Role`` objects depending on how the population was built. When
136+
``role_value`` is a string we compare against the ``key`` of each
137+
element to make the API ergonomic for callers such as
138+
``link.has_role("parent")`` or ``link.get_by_role(..., role_value="foo")``.
139+
"""
133140
r = self.role
134141
if r is None:
135142
msg = f"Link '{self.name}' has no role_field"
136143
raise ValueError(msg)
144+
145+
# if array holds object references, convert to their keys
146+
if r.dtype == object:
147+
try:
148+
keys = numpy.fromiter(
149+
(getattr(x, "key", x) for x in r),
150+
dtype=object,
151+
)
152+
except Exception:
153+
# fallback to direct comparison
154+
return r == role_value
155+
return keys == role_value
156+
157+
# numpy will perform elementwise comparison for numeric or string
137158
return r == role_value
138159

160+
# -- role-based access --------------------------------------------------
161+
162+
def get_by_role(
163+
self,
164+
variable_name: str,
165+
period,
166+
*,
167+
role_value,
168+
) -> numpy.ndarray:
169+
"""Fetch a variable on the target only for members with a given role.
170+
171+
Parameters
172+
----------
173+
variable_name : str
174+
Name of the variable defined on the target entity.
175+
period : Period
176+
Period for which to calculate the variable.
177+
role_value : object
178+
The role to filter on (e.g. ``"parent"``).
179+
180+
Returns
181+
-------
182+
numpy.ndarray
183+
Array of shape ``(n_source,)`` where only members whose
184+
``has_role(role_value)`` return ``True`` keep their computed
185+
value; all others receive the variable's default (usually 0).
186+
"""
187+
mask = self.has_role(role_value)
188+
result = self.get(variable_name, period)
189+
# zero out non-matching rows using dtype-preserving fill
190+
if not mask.all():
191+
# create a copy to avoid mutating cached results
192+
result = result.copy()
193+
result[~mask] = 0
194+
return result
195+
139196
# -- ID resolution ------------------------------------------------------
140197

141198
def _get_target_ids(self, period) -> numpy.ndarray:
@@ -169,6 +226,28 @@ def _resolve_ids(self, target_ids: numpy.ndarray) -> numpy.ndarray:
169226

170227
return rows
171228

229+
# -- ranking -----------------------------------------------------------
230+
231+
def rank(self, variable_name: str, period) -> numpy.ndarray:
232+
"""Rank each source member within its group by a variable value.
233+
234+
The rank is computed among all members sharing the same target
235+
entity, sorted by the value of ``variable_name`` evaluated on the
236+
*source* population. The lowest value receives rank ``0``.
237+
238+
This is essentially a thin wrapper around
239+
:meth:`~openfisca_core.populations.Population.get_rank`:
240+
241+
>>> person = simulation.persons
242+
>>> person.links['household'].rank('age', period)
243+
array([...])
244+
"""
245+
source_pop = self._source_population
246+
# criteria on source population
247+
criteria = source_pop.simulation.calculate(variable_name, period)
248+
# let Population.get_rank handle grouping and sorting
249+
return source_pop.get_rank(self, criteria)
250+
172251

173252
# ---------------------------------------------------------------------------
174253
# Chained link getter
@@ -223,5 +302,9 @@ def __getattr__(self, name: str):
223302
target_entity = target_pop.entity
224303
raise AttributeError(f"Entity '{target_entity.key}' has no link named '{name}'")
225304

305+
def rank(self, variable_name: str, period) -> numpy.ndarray:
306+
# forward to outer link so that chaining keeps semantics
307+
return self._outer.rank(variable_name, period)
308+
226309

227310
__all__ = ["Many2OneLink"]

0 commit comments

Comments
 (0)