Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
73b739b
fix(links): use dynamic person entity key instead of hardcoded 'person'
benjello Mar 5, 2026
af716a8
test(links): add regression tests for non-default person entity key
benjello Mar 5, 2026
9eeaff0
lint: isort openfisca_core/links/many2one.py, black test_many2one.py;…
benjello Mar 5, 2026
cc98140
tests(links): mirror simulation/projector ADD-DIVIDE tests; use tools…
benjello Mar 5, 2026
a6ddf47
style: black openfisca_core/links/many2one.py
benjello Mar 5, 2026
6b31fdb
fix(links): project only when result is entity-sized in ImplicitMany2…
benjello Mar 5, 2026
3f9819c
test(links): ensure projectors unchanged, add regression for members-…
benjello Mar 5, 2026
d6b4b32
lint: black simulation.py, codespell ignore activite
benjello Mar 5, 2026
8d23961
fix(links): preserve projector attributes (e.g. has_role) when wrappi…
benjello Mar 5, 2026
433b849
lint: black openfisca_core/links (many2one, test_implicit)
benjello Mar 5, 2026
584c0da
tests(links): add regression tests for person-sized sum and chained g…
benjello Mar 5, 2026
0aa5f04
lint: black openfisca_core/links/tests/test_implicit.py
benjello Mar 5, 2026
be045db
feat(builder): group_members + GroupPopulation.set_members_entity_id …
benjello Mar 5, 2026
7aab3b7
chore: AGENTS.md pre-push lint, fix builder type hint (Mapping -> dict)
benjello Mar 5, 2026
79fda15
test(links): add test_many2one_get_uses_id_to_rownum to verify link r…
benjello Mar 5, 2026
395952f
fix(audit): implement post-44.2.2 hardening and tests
benjello Mar 19, 2026
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
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Agent instructions (OpenFisca-Core)

This file is read by Cursor, Claude, Antigravity, and other agentic tools. Follow it when working in this repo.

## Pre-push checklist

**Before every `git push`**, run the full lint and fix any issues, then push.

1. **Lint** (from repo root):
```bash
make clean check-syntax-errors check-style lint-doc PYTHON=.venv/bin/python
```
- Fix any failures (e.g. `black`, `isort`, `flake8`, `codespell`). Use `make format-style` or run the formatter on the reported files if needed.
2. **Then** stage, commit (if there are new changes from fixes), and push.

Do not push without having run lint successfully unless the user explicitly asks to skip it.
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@
- Fix false `SpiralError` when a `transition_formula` reads its own variable at the previous period.
- The existing spiral detector raised `SpiralError` immediately when the same variable appeared in the call stack at any different period, which always triggers for temporal recursion (`V@P` → `V@P-1` → `V@P-2`).
- Fix: in `_calculate_transition`, the cycle check is replaced by `_check_for_strict_cycle`, which only raises `CycleError` for the exact same `(variable, period)` pair. Termination is guaranteed by `_as_of_transition_computed`.
- Post-44.2.2 audit hardening for links, as_of holders, and group population shape handling.
- `GroupPopulation.set_members_entity_id` now raises a clear `ValueError` when called with an empty array.
- `Many2OneLink._get_target_ids` now preserves original exception type and adds link context (`link` name + `link_field`) to the raised message.
- Role matching logic used by `Many2OneLink` and implicit links is now centralized in `links._role_matches` to avoid divergence.
- Chained many-to-one getters now support multi-hop composition (e.g. `person.mother.mother.household`).

#### Technical changes

- Clarify as_of snapshot-cache semantics: FIFO eviction (oldest inserted), not LRU.
- Add regression tests for:
- `link_field` not found errors and partial `_id_to_rownum` mappings.
- enum variable projection through `Many2OneLink`.
- three-level chained links.
- FIFO snapshot eviction, retroactive patch invalidation, backward reads after forward reads, and `set_input_sparse` error messaging.
- non-contiguous and empty `members_entity_id` handling.

## 44.4.1

Expand Down
4 changes: 4 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ We've added guides to help framework users model new relationships:
- `docs/implementation/links-api.md`: Reference for creating and querying `Many2OneLink` and `One2ManyLink`.
- `docs/implementation/transition-guide.md`: Migration guide demonstrating how to gradually adopt Links over Legacy Projectors.

## Builder & test clarity
- **`build_default_simulation(..., group_members=...)`**: Optional `group_members` dict (e.g. `{"household": [0,0,1,1]}`) sets group structure at build time so tests no longer patch private attributes.
- **`GroupPopulation.set_members_entity_id(array)`**: Public API to set group structure and clear internal caches; tests use this instead of touching `_members_position` / `_ordered_members_map`.

## Testing
- 12 new, comprehensive tests covering unit mechanics, system integrations, filtering, chaining, and OpenFisca core lifecycle (`_resolve_links`).
- All 158 core tests and existing Country Template tests continue to pass locally (`make test-code`).
17 changes: 10 additions & 7 deletions openfisca_core/holders/holder.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def __init__(self, variable, population) -> None:
# _as_of_base_instant : Instant at which the base was established.
# _as_of_patches : sorted list of (Instant, idx_array, val_array).
# _as_of_patch_instants : parallel list of Instants for bisect.
# _as_of_snapshots : LRU OrderedDict instant → (array, patch_idx).
# _as_of_snapshots : FIFO OrderedDict instant → (array, patch_idx).
# _as_of_max_snapshots : maximum number of snapshots to keep.
self._as_of_base = None
self._as_of_base_instant = None
Expand Down Expand Up @@ -151,17 +151,21 @@ def _get_as_of(self, period):
return self._reconstruct_at(target)

def _cache_snapshot(self, instant, array, patch_idx) -> None:
"""Insert (or refresh) a snapshot in the LRU cache, evicting the least
recently used entry if the cache is full."""
"""Insert (or refresh) a snapshot in the FIFO cache, evicting the oldest
entry if the cache is full.

Note: eviction is FIFO (oldest inserted), not LRU. This is optimal for
forward-sequential simulations where older snapshots are never reused.
For backward-access patterns the cache will be less effective.
"""
self._as_of_snapshots[instant] = (array, patch_idx)
self._as_of_snapshots.move_to_end(instant)
if len(self._as_of_snapshots) > self._as_of_max_snapshots:
self._as_of_snapshots.popitem(last=False) # evict LRU
self._as_of_snapshots.popitem(last=False) # evict oldest (FIFO)

def _reconstruct_at(self, target_instant):
"""Reconstruct the dense array at target_instant from base + patches.

Uses a multi-snapshot LRU cache for O(k) incremental cost.
Uses a multi-snapshot FIFO snapshot cache for O(k) incremental cost.
Falls back to O(N + k*P) full reconstruction when no usable snapshot
exists (e.g. backward jump past all cached snapshots).

Expand All @@ -178,7 +182,6 @@ def _reconstruct_at(self, target_instant):
# Exact cache hit — O(1).
if target_instant in self._as_of_snapshots:
array, _ = self._as_of_snapshots[target_instant]
self._as_of_snapshots.move_to_end(target_instant)
return array

# Find best starting snapshot: latest snap_instant < target_instant.
Expand Down
51 changes: 34 additions & 17 deletions openfisca_core/links/implicit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import numpy

from .link import _role_matches
from .many2one import Many2OneLink
from .one2many import One2ManyLink

Expand Down Expand Up @@ -31,18 +32,46 @@ def role(self) -> numpy.ndarray | None:
return self._target_population.members_role

def _project_implicit(self, result: numpy.ndarray) -> numpy.ndarray:
# Fully compatible with old Projector logic
return self._target_population.project(result)
"""Project or pass through result so it matches source (person) count.

- Entity-sized (result.size == target.count): same as old logic — project
to source so each person gets their entity's value (e.g. first_person).
- Members-sized (result.size == target.members.count): return as-is;
result is already one value per person (e.g. members('activite')).
"""
target = self._target_population
if result.size == target.count:
return target.project(result)
if result.size == target.members.count:
return result
raise ValueError(
f"Implicit link projection: result size {result.size} does not match "
f"target entity count ({target.count}) nor target members count ({target.members.count})."
)

# Explicit aggregation methods so person.famille.sum(...) always returns person-sized
# (found by normal attribute lookup before __getattr__ delegates to target).
def sum(self, array, role=None, condition=None):
result = self._target_population.sum(array, role=role, condition=condition)
return self._project_implicit(result)

def any(self, array, role=None, condition=None):
result = self._target_population.any(array, role=role, condition=condition)
return self._project_implicit(result)

def all(self, array, role=None, condition=None):
result = self._target_population.all(array, role=role, condition=condition)
return self._project_implicit(result)


class ImplicitOne2ManyLink(One2ManyLink):
"""A group → person link using GroupPopulation's internal arrays."""

def __init__(self, name: str, group_entity_key: str):
def __init__(self, name: str, group_entity_key: str, person_entity_key: str):
super().__init__(
name=name,
link_field="", # Not used
target_entity_key="person", # The target of the O2M is persons
target_entity_key=person_entity_key,
)
self._group_entity_key = group_entity_key

Expand All @@ -58,19 +87,7 @@ def _apply_filters(self, period, values, role, condition):

if role is not None:
roles = self._source_population.members_role
# roles may be an object array of Role instances, so compare by key
if roles.dtype == object:
try:
keys = numpy.fromiter(
(getattr(x, "key", x) for x in roles),
dtype=object,
)
except Exception:
mask &= roles == role
else:
mask &= keys == role
else:
mask &= roles == role
mask &= _role_matches(roles, role)

if condition is not None:
mask &= condition
Expand Down
29 changes: 27 additions & 2 deletions openfisca_core/links/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,33 @@

from typing import TYPE_CHECKING

import numpy

if TYPE_CHECKING:
import numpy
import numpy as np


def _role_matches(role_array: np.ndarray, role_value) -> np.ndarray:
"""Return a boolean mask where role_array matches role_value.

Supports Role objects (compared by .key) and raw values (compared by ==).
"""
if hasattr(role_value, "key"):
role_key = role_value.key
return numpy.array(
[
(r.key == role_key if hasattr(r, "key") else r == role_key)
for r in role_array
]
)
if getattr(role_array, "dtype", None) == object:
return numpy.array(
[
(r.key == role_value if hasattr(r, "key") else r == role_value)
for r in role_array
]
)
return role_array == role_value


class Link:
Expand Down Expand Up @@ -106,4 +131,4 @@ def __repr__(self) -> str:
)


__all__ = ["Link"]
__all__ = ["Link", "_role_matches"]
Loading