diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 00000000..1313411f --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,100 @@ +# Behavior input boundary progress + +## State + +- Branch: `feat/be-behavior-input-contract` +- Starting base: `3c3b4f6442f4a5adc47274734d71a6ca10103b43` +- Local `origin/main` at start: `3c3b4f6442f4a5adc47274734d71a6ca10103b43` +- Live `origin/main` was fetched on 2026-08-31 and remains exactly + `3c3b4f6442f4a5adc47274734d71a6ca10103b43`. +- Live comparison: this worktree's common repository is shallow and therefore + counts 1,161 commits at `origin/main`. An independent non-shallow clone has + the same `origin/main` SHA with 1,168 commits, matching GitHub's live history + count. The apparent seven-commit difference was shallow history, not an + upstream advance; the exact comparison base remains + `3c3b4f6442f4a5adc47274734d71a6ca10103b43`. +- The local `main` ref is unrelated divergent work and is not a safe upstream + substitute. +- Scope: data-only behavior input configuration and ID-keyed resolution; no + simulation or country-computation integration. +- Frozen-review status: locally ready. The code, tests, documentation, type + check, Ruff, diff, and issue-bound Towncrier checks are green. Draft + publication and independent frozen-head review remain. + +## Done + +- Verified the worktree was clean and the requested branch, base, local + `origin/main`, and merge-base matched exactly. +- Read all repository instruction files, the relevant engineering skills, and + the complete defensive correctness audit. +- Completed read-only review of `YearData`, Pydantic, pandas, tests, + documentation, changelog, and package conventions. +- Added the focused behavior-input tests first. The isolated test run is red at + collection because the not-yet-implemented public models are absent, as + expected. The normal `uv run` environment could not be created offline, so + the red run used the available Python environment with repository conftests + disabled; later green validation is recorded below. +- Resumed from the TDD commit and inspected the dirty implementation, salvage + ref `refs/codex-salvage/feat-be-behavior-input-contract-20260830-212607-7535`, + complete architecture audit, and all repository instruction files. The dirty + implementation is byte-identical to the salvage snapshot. +- Confirmed the requested boundary has no dependency on `Simulation.run()`, a + legal-variable registry, positional entity mapping, or source runtimes. +- Added the frozen, extra-forbidding `BehaviorInputBinding` and `BehaviorInputs` + models, exported only those configuration models from `policyengine.core`, + and kept the pure resolver internal. +- The resolver requires non-null unique stable entity IDs, returns copied + ID-indexed series, preserves nullable values, and does not mutate source + tables or remap entities. +- Focused diagnostic validation passes all 17 contract tests under the available + Python 3.14 environment. Direct Ruff format/check and `git diff --check` pass + for the implementation slice. A syncing `uv run` remains blocked because the + sandbox cannot write the configured user cache; later validation therefore + uses `uv run --no-sync` with an existing development environment. +- Hardened the source-aliasing test against MicroDataFrame scalar-setter + differences across pandas/microdf versions and corrected the Ruff import + grouping. The 17 focused tests and direct Ruff checks remain green. +- Documented cross-system ownership, legal/behavioral vocabulary, the + stable-ID resolution contract, and the explicit execution non-goals in the + model architecture guide. +- Stronger validation through `uv run --no-sync` and the canonical clone's + existing development environment is green: 17 focused behavior tests; 36 + Belgium/labor-supply tests with 4 expected source-stack skips; and 73 + dataset, model, and extra-variable regression tests. +- Whole-repository Ruff format-check and lint pass (`197 files already + formatted`; no lint findings), and both working-tree and branch diffs pass + `git diff --check`. +- The architecture Markdown parses successfully through Quarto's Pandoc. The + full `make docs` render is environment-blocked because Quarto attempts to + open its Sass database in a non-writable user cache, not because of a + documentation diagnostic. +- GitNexus reported that this worktree was not indexed; its index attempt was + blocked by the non-writable global registry. The generated untracked index + artifacts were removed, and impact review used direct source/history instead. +- Final architecture review confirmed the source/API scope is clean and added + the audit's exact legal-operability, non-inference, and explicit non-goal + guardrails to the documentation. +- Focused mypy validation passes for `src/policyengine/core/behavior.py`, and a + runtime API probe reconfirms JSON round-trip, private resolver scope, and no + top-level `pe.be` export. +- Towncrier comparison now finds `changelog.d/510.added.md` and passes against + the freshly fetched `origin/main`. +- Independent final reviews found no blocking code or architecture defect and + assessed implementation risk as low. The previously missing issue-numbered + Towncrier fragment is now resolved by issue #510. +- Created PolicyEngine/policyengine.py issue #510, added + `changelog.d/510.added.md`, and re-fetched the live upstream base without a + branch divergence. +- Re-ran the issue-bound tree: all 17 focused contract tests pass in the + hermetic no-country-import environment; whole-repository Ruff format and lint, + focused mypy, architecture Markdown parsing, Towncrier, and diff checks pass. + The shared canonical clone environment has since picked up an uncertified US + package/data combination, so it is no longer a valid runner for this isolated + branch; the hermetic contract suite is unaffected. + +## Next + +- Push the same-repository branch, open only a draft PR beginning `Fixes #510`, + and verify its live base/head/draft state. +- Freeze the published head for independent review; keep the full docs render + as a disclosed environment-only residual check. diff --git a/changelog.d/510.added.md b/changelog.d/510.added.md new file mode 100644 index 00000000..f13d1a05 --- /dev/null +++ b/changelog.d/510.added.md @@ -0,0 +1 @@ +Add typed behavior-input bindings and stable-ID resolution for population data used by future behavioral adapters. diff --git a/docs/methodology/model-architecture.md b/docs/methodology/model-architecture.md index 91561932..e97b41f6 100644 --- a/docs/methodology/model-architecture.md +++ b/docs/methodology/model-architecture.md @@ -45,6 +45,54 @@ A complete model page should be explicit about four pieces: For example, a program page should not stop at eligibility. It should say how benefit value is represented, whether household-paid costs are modeled, what data inputs are required, and how the program enters aggregate output concepts. +## Behavior inputs and legal semantics + +Legal rules, population construction, and non-legal behavioral mechanics have +different owners: + +| Layer | Owns | +|---|---| +| Source systems and Chronicle | Documentary source facts | +| Microcosm | Population construction and measured or latent population flags | +| PolicyEngine | Explicit population-input bindings and, in later stages, non-legal take-up and labor-supply mechanics | +| Axiom and RuleSpec | Only legal rules, concepts, events, and statuses grounded in public documents | + +These terms are not interchangeable: + +- **Eligibility** is a legal qualifying predicate. +- **Entitlement** is a legal right or calculated amount. It does not establish + application, award, payment, or receipt. +- **Application or claim** is a claimant or administrative event. Microcosm may + carry measured or latent application state; Axiom may receive it only when an + exact public authority makes it legally operative. +- **Award** is an administrative determination. It must not be inferred from + eligibility or receipt. +- **Payment** is a legal amount due, issued, or disbursed. +- **Receipt** is a measured or latent population fact or a PolicyEngine + behavioral outcome. It may lag or differ from legal payment. +- **Simulated non-take-up** is a PolicyEngine-owned behavioral outcome. It is + not ineligibility, denial, loss of entitlement, or an Axiom fact. + +Eligibility or a positive static amount alone proves none of application, +award, payment, or receipt. + +`BehaviorInputBinding` gives an adapter-local role an entity and column +reference. Roles are local labels, not universal legal or benefit concepts; +bindings do not contain arrays, dataframes, simulations, callables, +probabilities, or arbitrary objects. `BehaviorInputs` is the frozen, +JSON-round-trippable collection of those bindings. + +The internal resolver reads only declared columns from loaded +`YearData.entity_data`. It requires a non-null, unique `_id`, copies +values into series indexed by that stable ID, and preserves nulls. It never +aligns rows positionally, remaps between entities, or requires a column to +appear in a legal-model variable registry. + +This boundary only validates and resolves inputs. It adds no behavior or +Belgian formula, `takes_up_*` concept, adapter registry, legal rerun, +`Simulation` field, cache behavior, public `pe.be` export, effect on +`Simulation.run()`, or country and labor-supply numerical change. + ## What belongs in generated reference Generated reference pages should include: @@ -70,4 +118,3 @@ Authored methodology pages should focus on model choices: - what current limitations users should know before interpreting outputs That is the structure used by the first new US health-cost page. - diff --git a/src/policyengine/core/__init__.py b/src/policyengine/core/__init__.py index 4f749de4..3dce5564 100644 --- a/src/policyengine/core/__init__.py +++ b/src/policyengine/core/__init__.py @@ -6,6 +6,8 @@ provenance layer. """ +from .behavior import BehaviorInputBinding as BehaviorInputBinding +from .behavior import BehaviorInputs as BehaviorInputs from .dataset import Dataset from .dataset import YearData as YearData from .dataset import map_to_entity as map_to_entity diff --git a/src/policyengine/core/behavior.py b/src/policyengine/core/behavior.py new file mode 100644 index 00000000..eb465b18 --- /dev/null +++ b/src/policyengine/core/behavior.py @@ -0,0 +1,108 @@ +"""Data-only configuration for adapter-local behavior inputs.""" + +from typing import Optional + +import pandas as pd +from pydantic import BaseModel, ConfigDict, field_validator + +from .dataset import YearData + +__all__ = ["BehaviorInputBinding", "BehaviorInputs"] + + +class BehaviorInputBinding(BaseModel): + """Bind an adapter-local role to one population entity column.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + role: str + entity: str + column: str + + +class BehaviorInputs(BaseModel): + """Immutable population-column bindings for a behavior adapter.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + bindings: tuple[BehaviorInputBinding, ...] + + @field_validator("bindings") + @classmethod + def _roles_must_be_unique( + cls, + bindings: tuple[BehaviorInputBinding, ...], + ) -> tuple[BehaviorInputBinding, ...]: + seen: set[str] = set() + duplicates: list[str] = [] + for binding in bindings: + if binding.role in seen and binding.role not in duplicates: + duplicates.append(binding.role) + seen.add(binding.role) + if duplicates: + raise ValueError( + "Behavior input roles contain duplicate adapter-local labels: " + f"{duplicates}." + ) + return bindings + + +def _resolve_behavior_inputs( + behavior_inputs: BehaviorInputs, + data: Optional[YearData], +) -> dict[str, pd.Series]: + """Resolve bindings to copied values indexed only by their entity IDs.""" + if data is None: + raise ValueError("Behavior input resolution requires loaded YearData.") + + entity_data = data.entity_data + if entity_data is None: + raise ValueError("Behavior input resolution requires loaded YearData.") + + resolved: dict[str, pd.Series] = {} + for binding in behavior_inputs.bindings: + if binding.entity not in entity_data: + raise ValueError( + f"Behavior input role {binding.role!r} references missing entity " + f"{binding.entity!r}." + ) + + entity_table = entity_data[binding.entity] + if entity_table is None: + raise ValueError( + f"Behavior input role {binding.role!r} references entity " + f"{binding.entity!r} without loaded data." + ) + table = pd.DataFrame(entity_table) + id_column = f"{binding.entity}_id" + if id_column not in table.columns: + raise ValueError( + f"Behavior input entity {binding.entity!r} is missing required ID " + f"column {id_column!r}." + ) + + ids = table[id_column].copy(deep=True) + if ids.isna().any(): + raise ValueError( + f"Behavior input entity {binding.entity!r} has null values in " + f"required ID column {id_column!r}." + ) + duplicate_ids = ids.duplicated(keep=False) + if duplicate_ids.any(): + duplicate_values = ids.loc[duplicate_ids].drop_duplicates().tolist() + raise ValueError( + f"Behavior input entity {binding.entity!r} must have unique " + f"{id_column!r} values; duplicates: {duplicate_values}." + ) + + if binding.column not in table.columns: + raise ValueError( + f"Behavior input role {binding.role!r} references missing column " + f"{binding.column!r} on entity {binding.entity!r}." + ) + + values = table[binding.column].copy(deep=True) + values.index = pd.Index(ids.array.copy(), name=id_column) + resolved[binding.role] = values + + return resolved diff --git a/tests/test_behavior_inputs.py b/tests/test_behavior_inputs.py new file mode 100644 index 00000000..335a2473 --- /dev/null +++ b/tests/test_behavior_inputs.py @@ -0,0 +1,275 @@ +"""Tests for the data-only behavior input boundary.""" + +from typing import Optional + +import pandas as pd +import pytest +from microdf import MicroDataFrame +from pydantic import ValidationError + +import policyengine.core as core +from policyengine.core import BehaviorInputBinding, BehaviorInputs +from policyengine.core.behavior import _resolve_behavior_inputs +from policyengine.tax_benefit_models.be.datasets import BEYearData + +BEHAVIOR_COLUMN = "microcosm_latent_claim_flag" + + +@pytest.fixture +def be_year_data() -> BEYearData: + """Build Belgian year data entirely in memory, without source runtimes.""" + person = pd.DataFrame( + { + "person_id": [30, 10, 20], + "person_household_id": [300, 100, 200], + "person_weight": [1.0, 2.0, 3.0], + BEHAVIOR_COLUMN: pd.Series( + [True, pd.NA, False], + dtype="boolean", + ), + } + ) + household = pd.DataFrame( + { + "household_id": [100, 200, 300], + "household_weight": [2.0, 3.0, 1.0], + } + ) + return BEYearData( + person=MicroDataFrame(person, weights="person_weight"), + household=MicroDataFrame(household, weights="household_weight"), + ) + + +def _binding( + *, + role: str = "observed_claim", + entity: str = "person", + column: str = BEHAVIOR_COLUMN, +) -> BehaviorInputBinding: + return BehaviorInputBinding(role=role, entity=entity, column=column) + + +def _inputs(binding: Optional[BehaviorInputBinding] = None) -> BehaviorInputs: + return BehaviorInputs(bindings=(_binding() if binding is None else binding,)) + + +def _replace_person( + year_data: BEYearData, + person: pd.DataFrame, +) -> BEYearData: + return BEYearData( + person=MicroDataFrame(person, weights="person_weight"), + household=year_data.household, + ) + + +def test_configuration_models_round_trip_through_json() -> None: + inputs = BehaviorInputs( + bindings=( + _binding(), + _binding( + role="household_signal", + entity="household", + column="household_weight", + ), + ) + ) + + restored = BehaviorInputs.model_validate_json(inputs.model_dump_json()) + + assert restored == inputs + assert isinstance(restored.bindings, tuple) + assert restored.model_dump(mode="json") == { + "bindings": [ + { + "role": "observed_claim", + "entity": "person", + "column": BEHAVIOR_COLUMN, + }, + { + "role": "household_signal", + "entity": "household", + "column": "household_weight", + }, + ] + } + + +@pytest.mark.parametrize( + ("model", "payload"), + [ + ( + BehaviorInputBinding, + { + "role": "observed_claim", + "entity": "person", + "column": BEHAVIOR_COLUMN, + "values": [True, False], + }, + ), + ( + BehaviorInputs, + { + "bindings": [], + "adapter": object(), + }, + ), + ], +) +def test_configuration_models_reject_unknown_fields(model, payload) -> None: + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + model.model_validate(payload) + + +def test_configuration_models_are_frozen() -> None: + binding = _binding() + inputs = _inputs(binding) + + with pytest.raises(ValidationError, match="Instance is frozen"): + binding.role = "changed" + with pytest.raises(ValidationError, match="Instance is frozen"): + inputs.bindings = () + + +def test_behavior_input_roles_must_be_unique() -> None: + with pytest.raises(ValidationError, match="duplicate.*observed_claim"): + BehaviorInputs( + bindings=( + _binding(), + _binding(column="another_population_column"), + ) + ) + + +def test_core_exports_only_behavior_configuration_models() -> None: + assert core.BehaviorInputBinding is BehaviorInputBinding + assert core.BehaviorInputs is BehaviorInputs + assert not hasattr(core, "ResolvedBehaviorInputs") + assert not hasattr(core, "resolve_behavior_inputs") + + +def test_resolution_requires_loaded_year_data() -> None: + with pytest.raises(ValueError, match="loaded YearData"): + _resolve_behavior_inputs(_inputs(), None) + + +def test_resolution_rejects_missing_entity(be_year_data: BEYearData) -> None: + binding = _binding(entity="benefit_unit") + + with pytest.raises(ValueError, match="missing entity 'benefit_unit'"): + _resolve_behavior_inputs(_inputs(binding), be_year_data) + + +def test_resolution_rejects_missing_behavior_column( + be_year_data: BEYearData, +) -> None: + binding = _binding(column="missing_population_flag") + + with pytest.raises(ValueError, match="missing column 'missing_population_flag'"): + _resolve_behavior_inputs(_inputs(binding), be_year_data) + + +def test_resolution_requires_entity_id_column(be_year_data: BEYearData) -> None: + person = pd.DataFrame(be_year_data.person).drop(columns="person_id") + year_data = _replace_person(be_year_data, person) + + with pytest.raises(ValueError, match="required ID column 'person_id'"): + _resolve_behavior_inputs(_inputs(), year_data) + + +def test_resolution_rejects_null_entity_ids(be_year_data: BEYearData) -> None: + person = pd.DataFrame(be_year_data.person).copy() + person.loc[1, "person_id"] = pd.NA + year_data = _replace_person(be_year_data, person) + + with pytest.raises(ValueError, match="null.*'person_id'"): + _resolve_behavior_inputs(_inputs(), year_data) + + +def test_resolution_rejects_duplicate_entity_ids(be_year_data: BEYearData) -> None: + person = pd.DataFrame(be_year_data.person).copy() + person.loc[1, "person_id"] = person.loc[0, "person_id"] + year_data = _replace_person(be_year_data, person) + + with pytest.raises(ValueError, match="unique.*'person_id'.*30"): + _resolve_behavior_inputs(_inputs(), year_data) + + +def test_resolution_is_id_keyed_when_source_rows_are_reordered( + be_year_data: BEYearData, +) -> None: + person = pd.DataFrame(be_year_data.person).iloc[[2, 0, 1]].reset_index(drop=True) + reordered = _replace_person(be_year_data, person) + + original_values = _resolve_behavior_inputs(_inputs(), be_year_data)[ + "observed_claim" + ] + reordered_values = _resolve_behavior_inputs(_inputs(), reordered)["observed_claim"] + + assert original_values.index.name == "person_id" + assert original_values.index.tolist() == [30, 10, 20] + assert reordered_values.index.tolist() == [20, 30, 10] + pd.testing.assert_series_equal( + original_values.sort_index(), + reordered_values.sort_index(), + ) + + +def test_resolution_keeps_each_role_on_its_declared_entity( + be_year_data: BEYearData, +) -> None: + inputs = BehaviorInputs( + bindings=( + _binding(), + _binding( + role="household_signal", + entity="household", + column="household_weight", + ), + ) + ) + + resolved = _resolve_behavior_inputs(inputs, be_year_data) + + assert resolved["observed_claim"].index.name == "person_id" + assert resolved["observed_claim"].index.tolist() == [30, 10, 20] + assert resolved["household_signal"].index.name == "household_id" + assert resolved["household_signal"].index.tolist() == [100, 200, 300] + + +def test_resolution_preserves_nullable_values_without_boolean_coercion( + be_year_data: BEYearData, +) -> None: + values = _resolve_behavior_inputs(_inputs(), be_year_data)["observed_claim"] + + assert values.dtype == pd.BooleanDtype() + assert values.loc[30] == True # noqa: E712 + assert pd.isna(values.loc[10]) + assert values.loc[20] == False # noqa: E712 + + +def test_resolution_accepts_column_absent_from_legal_registry( + be_year_data: BEYearData, +) -> None: + values = _resolve_behavior_inputs(_inputs(), be_year_data)["observed_claim"] + + assert values.name == BEHAVIOR_COLUMN + assert values.index.tolist() == [30, 10, 20] + + +def test_resolution_copies_values_without_mutating_or_aliasing_source( + be_year_data: BEYearData, +) -> None: + source_before = pd.DataFrame(be_year_data.person).copy(deep=True) + + values = _resolve_behavior_inputs(_inputs(), be_year_data)["observed_claim"] + + pd.testing.assert_frame_equal(pd.DataFrame(be_year_data.person), source_before) + values.loc[30] = False + assert be_year_data.person.loc[0, BEHAVIOR_COLUMN] == True # noqa: E712 + be_year_data.person[BEHAVIOR_COLUMN] = pd.Series( + [pd.NA, pd.NA, pd.NA], + dtype="boolean", + ) + assert values.loc[30] == False # noqa: E712