From 71433b3140551b6a17e27055452df112736151db Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 30 Aug 2026 21:16:42 -0400 Subject: [PATCH 01/12] chore: initialize behavior boundary progress --- PROGRESS.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 PROGRESS.md diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 00000000..06aa27f5 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,28 @@ +# Behavior input boundary progress + +## State + +- Branch: `feat/be-behavior-input-contract` +- Starting base: `3c3b4f6442f4a5adc47274734d71a6ca10103b43` +- Local `origin/main` at start: `3c3b4f6442f4a5adc47274734d71a6ca10103b43` +- Scope: data-only behavior input configuration and ID-keyed resolution; no + simulation or country-computation integration. +- Upstream refresh: attempted on 2026-08-30, but GitHub DNS resolution is + unavailable in the execution environment. + +## 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. +- Started read-only review of `YearData`, tests, documentation, changelog, and + package conventions. + +## Next + +- Write focused behavior-input contract tests before implementation. +- Implement the minimal frozen Pydantic models and pure resolver. +- Add ownership/vocabulary documentation and a Towncrier fragment. +- Run focused and proportional regression checks, self-review, and attempt the + required same-repository draft PR workflow. From 0fd6ff026acd84da012c372b5e8255d00aa5a249 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 30 Aug 2026 21:24:31 -0400 Subject: [PATCH 02/12] test: define behavior input contract --- PROGRESS.md | 10 +- tests/test_behavior_inputs.py | 272 ++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 tests/test_behavior_inputs.py diff --git a/PROGRESS.md b/PROGRESS.md index 06aa27f5..16278fa4 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -16,12 +16,16 @@ `origin/main`, and merge-base matched exactly. - Read all repository instruction files, the relevant engineering skills, and the complete defensive correctness audit. -- Started read-only review of `YearData`, tests, documentation, changelog, and - package conventions. +- 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; full validation remains pending. ## Next -- Write focused behavior-input contract tests before implementation. - Implement the minimal frozen Pydantic models and pure resolver. - Add ownership/vocabulary documentation and a Towncrier fragment. - Run focused and proportional regression checks, self-review, and attempt the diff --git a/tests/test_behavior_inputs.py b/tests/test_behavior_inputs.py new file mode 100644 index 00000000..bb751fb0 --- /dev/null +++ b/tests/test_behavior_inputs.py @@ -0,0 +1,272 @@ +"""Tests for the data-only behavior input boundary.""" + +from typing import Optional + +import pandas as pd +import pytest +from microdf import MicroDataFrame +from policyengine.core.behavior import _resolve_behavior_inputs +from pydantic import ValidationError + +import policyengine.core as core +from policyengine.core import BehaviorInputBinding, BehaviorInputs +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.loc[0, BEHAVIOR_COLUMN] = pd.NA + assert values.loc[30] == False # noqa: E712 From 13a28640326910a26805e029bd02eaa17962d0a2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 31 Aug 2026 07:19:32 -0400 Subject: [PATCH 03/12] chore: record resumed behavior boundary state --- PROGRESS.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 16278fa4..02cdf7a3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -5,10 +5,15 @@ - Branch: `feat/be-behavior-input-contract` - Starting base: `3c3b4f6442f4a5adc47274734d71a6ca10103b43` - Local `origin/main` at start: `3c3b4f6442f4a5adc47274734d71a6ca10103b43` +- Last fetch attempt: 2026-08-31; blocked because the execution environment + cannot resolve `github.com`. +- Live comparison: GitHub's repository page shows a newer `main` history than + the 1,161 commits available in local `origin/main`. Publication and final + base selection remain gated on fetching the actual upstream Git objects. +- 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. -- Upstream refresh: attempted on 2026-08-30, but GitHub DNS resolution is - unavailable in the execution environment. ## Done @@ -23,10 +28,17 @@ expected. The normal `uv run` environment could not be created offline, so the red run used the available Python environment with repository conftests disabled; full validation remains pending. +- 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. ## Next -- Implement the minimal frozen Pydantic models and pure resolver. +- Validate and commit the minimal frozen Pydantic models and pure resolver. - Add ownership/vocabulary documentation and a Towncrier fragment. - Run focused and proportional regression checks, self-review, and attempt the - required same-repository draft PR workflow. + required same-repository draft PR workflow only after current upstream Git + objects can be fetched and reconciled. From a0297bffea054d68cbb06fad2ad6cc90a4e997a2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 31 Aug 2026 07:21:31 -0400 Subject: [PATCH 04/12] feat: add stable behavior input resolution --- PROGRESS.md | 12 +++- src/policyengine/core/__init__.py | 2 + src/policyengine/core/behavior.py | 108 ++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/policyengine/core/behavior.py diff --git a/PROGRESS.md b/PROGRESS.md index 02cdf7a3..f6ad873a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -34,10 +34,20 @@ 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. The canonical `uv run` remains blocked because + the sandbox cannot write the configured user cache; canonical validation is + still pending. ## Next -- Validate and commit the minimal frozen Pydantic models and pure resolver. - Add ownership/vocabulary documentation and a Towncrier fragment. - Run focused and proportional regression checks, self-review, and attempt the required same-repository draft PR workflow only after current upstream Git 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 From f7c902876ecaf6c58e16e9556a14543f740d75fc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 31 Aug 2026 07:25:33 -0400 Subject: [PATCH 05/12] test: harden behavior input copy check --- PROGRESS.md | 3 +++ tests/test_behavior_inputs.py | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index f6ad873a..cf72e432 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -45,6 +45,9 @@ for the implementation slice. The canonical `uv run` remains blocked because the sandbox cannot write the configured user cache; canonical validation is still pending. +- 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. ## Next diff --git a/tests/test_behavior_inputs.py b/tests/test_behavior_inputs.py index bb751fb0..335a2473 100644 --- a/tests/test_behavior_inputs.py +++ b/tests/test_behavior_inputs.py @@ -5,11 +5,11 @@ import pandas as pd import pytest from microdf import MicroDataFrame -from policyengine.core.behavior import _resolve_behavior_inputs 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" @@ -268,5 +268,8 @@ def test_resolution_copies_values_without_mutating_or_aliasing_source( 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.loc[0, BEHAVIOR_COLUMN] = pd.NA + be_year_data.person[BEHAVIOR_COLUMN] = pd.Series( + [pd.NA, pd.NA, pd.NA], + dtype="boolean", + ) assert values.loc[30] == False # noqa: E712 From 771a65c4631d4d85cec27497f2cca991d6b9d970 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 31 Aug 2026 07:26:30 -0400 Subject: [PATCH 06/12] docs: define behavior input ownership boundary --- PROGRESS.md | 6 +++- docs/methodology/model-architecture.md | 41 +++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index cf72e432..be4ea33b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -48,10 +48,14 @@ - 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. ## Next -- Add ownership/vocabulary documentation and a Towncrier fragment. +- Create or identify the required GitHub issue, then add its issue-numbered + Towncrier fragment using the live `.added.md` convention. - Run focused and proportional regression checks, self-review, and attempt the required same-repository draft PR workflow only after current upstream Git objects can be fetched and reconciled. diff --git a/docs/methodology/model-architecture.md b/docs/methodology/model-architecture.md index 91561932..91f24de2 100644 --- a/docs/methodology/model-architecture.md +++ b/docs/methodology/model-architecture.md @@ -45,6 +45,46 @@ 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. +- **Award** is an administrative determination. +- **Payment** is a legal amount due, issued, or disbursed. +- **Receipt** is a measured or latent population fact or a PolicyEngine + behavioral outcome. +- **Simulated non-take-up** is a PolicyEngine-owned behavioral outcome. It is + not ineligibility, denial, or loss of entitlement. + +`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 formula, +adapter registry, legal rerun, cache behavior, or effect on `Simulation.run()`. + ## What belongs in generated reference Generated reference pages should include: @@ -70,4 +110,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. - From a491d4e2f90474eb93a6ed85a66ad8680a7b9107 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 31 Aug 2026 07:37:58 -0400 Subject: [PATCH 07/12] chore: record behavior boundary verification --- PROGRESS.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index be4ea33b..4e948ae8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -51,11 +51,25 @@ - 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. ## Next - Create or identify the required GitHub issue, then add its issue-numbered Towncrier fragment using the live `.added.md` convention. -- Run focused and proportional regression checks, self-review, and attempt the - required same-repository draft PR workflow only after current upstream Git - objects can be fetched and reconciled. +- Fetch and reconcile the current upstream Git objects, then run changelog and + final verification and attempt the required same-repository draft PR + workflow only if every gate is green. From 6b73500fe562551e4031b31a9a7a32323b9cb2ee Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 31 Aug 2026 07:42:43 -0400 Subject: [PATCH 08/12] docs: clarify behavior boundary non-goals --- PROGRESS.md | 3 +++ docs/methodology/model-architecture.md | 20 ++++++++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 4e948ae8..467a1cfc 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -65,6 +65,9 @@ - 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. ## Next diff --git a/docs/methodology/model-architecture.md b/docs/methodology/model-architecture.md index 91f24de2..e97b41f6 100644 --- a/docs/methodology/model-architecture.md +++ b/docs/methodology/model-architecture.md @@ -62,13 +62,19 @@ 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. -- **Award** is an administrative determination. +- **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. + 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, or loss of entitlement. + 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; @@ -82,8 +88,10 @@ 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 formula, -adapter registry, legal rerun, cache behavior, or effect on `Simulation.run()`. +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 From efac723c12bdfa684dcfcca5340189667dd428c3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 31 Aug 2026 07:46:12 -0400 Subject: [PATCH 09/12] chore: correct upstream history comparison --- PROGRESS.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 467a1cfc..5fc29070 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -5,11 +5,14 @@ - Branch: `feat/be-behavior-input-contract` - Starting base: `3c3b4f6442f4a5adc47274734d71a6ca10103b43` - Local `origin/main` at start: `3c3b4f6442f4a5adc47274734d71a6ca10103b43` -- Last fetch attempt: 2026-08-31; blocked because the execution environment - cannot resolve `github.com`. -- Live comparison: GitHub's repository page shows a newer `main` history than - the 1,161 commits available in local `origin/main`. Publication and final - base selection remain gated on fetching the actual upstream Git objects. +- Last network fetch attempt: 2026-08-31; blocked because the execution + environment cannot resolve `github.com`. +- 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 @@ -42,9 +45,9 @@ 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. The canonical `uv run` remains blocked because - the sandbox cannot write the configured user cache; canonical validation is - still pending. + 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. @@ -73,6 +76,5 @@ - Create or identify the required GitHub issue, then add its issue-numbered Towncrier fragment using the live `.added.md` convention. -- Fetch and reconcile the current upstream Git objects, then run changelog and - final verification and attempt the required same-repository draft PR - workflow only if every gate is green. +- Retry the network fetch, then run changelog and final verification and attempt + the required same-repository draft PR workflow only if every gate is green. From e16dd5f4160f63995f6521d2343acbe3fe4adcb5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 31 Aug 2026 07:50:30 -0400 Subject: [PATCH 10/12] chore: record external publication blocker --- PROGRESS.md | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 5fc29070..d6ac1bf0 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -17,6 +17,10 @@ substitute. - Scope: data-only behavior input configuration and ID-keyed resolution; no simulation or country-computation integration. +- Frozen-review status: not ready. The implementation, tests, documentation, + type check, Ruff, and diff checks are green, but the required GitHub issue, + issue-numbered changelog fragment, current-network fetch, push, and draft PR + cannot be completed while GitHub DNS/API access is unavailable. ## Done @@ -30,7 +34,7 @@ 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; full validation remains pending. + 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 @@ -71,10 +75,23 @@ - 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 was run with an available local installation and fails + only because no new fragment exists: `No new newsfragments found on this + branch.` A fragment was not fabricated without the required issue number. +- Independent final reviews found no blocking code or architecture defect and + assessed implementation risk as low. The sole tracked completion blocker is + the missing issue-numbered Towncrier fragment. ## Next -- Create or identify the required GitHub issue, then add its issue-numbered - Towncrier fragment using the live `.added.md` convention. -- Retry the network fetch, then run changelog and final verification and attempt - the required same-repository draft PR workflow only if every gate is green. +- Retry `git fetch origin main`; reconcile only if the exact upstream SHA has + advanced from `3c3b4f6442f4a5adc47274734d71a6ca10103b43`. +- Create or verify the GitHub issue, add `changelog.d/.added.md`, and make + the first PR-body line `Fixes #`. +- Re-run Towncrier, final tests/Ruff/diff checks, and the full docs render in an + environment with a writable Quarto cache. +- Push with `make push-pr-branch`, open only a same-repository draft PR, and + verify `isDraft=true` and head repository `PolicyEngine/policyengine.py`. From d5a254d08995fce7a3ca87d75a316c367a20296f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 31 Aug 2026 08:04:07 -0400 Subject: [PATCH 11/12] chore: bind behavior contract to issue 510 --- PROGRESS.md | 33 +++++++++++++++++---------------- changelog.d/510.added.md | 1 + 2 files changed, 18 insertions(+), 16 deletions(-) create mode 100644 changelog.d/510.added.md diff --git a/PROGRESS.md b/PROGRESS.md index d6ac1bf0..bc057141 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -5,8 +5,8 @@ - Branch: `feat/be-behavior-input-contract` - Starting base: `3c3b4f6442f4a5adc47274734d71a6ca10103b43` - Local `origin/main` at start: `3c3b4f6442f4a5adc47274734d71a6ca10103b43` -- Last network fetch attempt: 2026-08-31; blocked because the execution - environment cannot resolve `github.com`. +- 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 @@ -17,10 +17,10 @@ substitute. - Scope: data-only behavior input configuration and ID-keyed resolution; no simulation or country-computation integration. -- Frozen-review status: not ready. The implementation, tests, documentation, - type check, Ruff, and diff checks are green, but the required GitHub issue, - issue-numbered changelog fragment, current-network fetch, push, and draft PR - cannot be completed while GitHub DNS/API access is unavailable. +- Frozen-review status: implementation-ready. The code, tests, documentation, + type check, Ruff, and diff checks are green; issue #510 and its Towncrier + fragment now bind the change. Draft publication and independent frozen-head + review remain. ## Done @@ -82,16 +82,17 @@ only because no new fragment exists: `No new newsfragments found on this branch.` A fragment was not fabricated without the required issue number. - Independent final reviews found no blocking code or architecture defect and - assessed implementation risk as low. The sole tracked completion blocker is - the missing issue-numbered Towncrier fragment. + 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. ## Next -- Retry `git fetch origin main`; reconcile only if the exact upstream SHA has - advanced from `3c3b4f6442f4a5adc47274734d71a6ca10103b43`. -- Create or verify the GitHub issue, add `changelog.d/.added.md`, and make - the first PR-body line `Fixes #`. -- Re-run Towncrier, final tests/Ruff/diff checks, and the full docs render in an - environment with a writable Quarto cache. -- Push with `make push-pr-branch`, open only a same-repository draft PR, and - verify `isDraft=true` and head repository `PolicyEngine/policyengine.py`. +- Re-run Towncrier, focused tests, Ruff, and diff checks on the issue-bound + tree. +- 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. From bf2034de96e64c513823ba2b7440f6c9e5224245 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 31 Aug 2026 08:04:34 -0400 Subject: [PATCH 12/12] chore: record issue-bound verification --- PROGRESS.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index bc057141..1313411f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -17,10 +17,9 @@ substitute. - Scope: data-only behavior input configuration and ID-keyed resolution; no simulation or country-computation integration. -- Frozen-review status: implementation-ready. The code, tests, documentation, - type check, Ruff, and diff checks are green; issue #510 and its Towncrier - fragment now bind the change. Draft publication and independent frozen-head - review remain. +- 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 @@ -78,20 +77,23 @@ - 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 was run with an available local installation and fails - only because no new fragment exists: `No new newsfragments found on this - branch.` A fragment was not fabricated without the required issue number. +- 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 -- Re-run Towncrier, focused tests, Ruff, and diff checks on the issue-bound - tree. - 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