diff --git a/skills/policyengine-healthcare/SKILL.md b/skills/policyengine-healthcare/SKILL.md index 24a93dc..a71e66f 100644 --- a/skills/policyengine-healthcare/SKILL.md +++ b/skills/policyengine-healthcare/SKILL.md @@ -26,8 +26,9 @@ This skill is the domain layer for analysts using the `policyengine` package; fo programs use **policyengine-us**, for the shared calculation/reform mechanics use the **policyengine** skill, and for building new health variables use **policyengine-model-development**. -Verified against policyengine 4.21.0 / policyengine-us 1.764.6 (2026-07). Re-verify variable names -and parameter values before reporting. +Verified against policyengine 4.21.0 / policyengine-us 1.764.6 (2026-07); the marked examples +re-verified on policyengine 6.1.2 / policyengine-us 2.2.1 (2026-09). Re-verify variable names and +parameter values before reporting. ## The one gotcha that dominates everything: health benefits are excluded from net income @@ -49,7 +50,8 @@ Measure health impact one of these ways instead: Δtax − Δbenefits, so it captures the health split automatically — do not hand-roll it from `household_net_income`. See the policyengine skill for the population flow. -Verified end to end (single adult, $35k, Texas — ACA-eligible, not Medicaid-eligible): +Verified end to end (single adult, $35k, Harris County, Texas — ACA-eligible, not +Medicaid-eligible): ```python @@ -58,11 +60,11 @@ import policyengine as pe r = pe.us.calculate_household( people=[{"age": 45, "employment_income": 35_000}], tax_unit={"filing_status": "SINGLE"}, - household={"state_code": "TX"}, + household={"state_code": "TX", "county_fips": "48201"}, # Harris County year=2026, extra_variables=["aca_ptc", "household_net_income_including_health_benefits"], ) -assert round(r.tax_unit.aca_ptc, 2) == 6_250.38 # a real PTC... +assert round(r.tax_unit.aca_ptc, 2) == 6_635.45 # a real PTC... # ...but it is NOT in default net income; the gap is exactly the PTC: gap = (r.household.household_net_income_including_health_benefits - r.household.household_net_income) @@ -170,7 +172,33 @@ p.gov.aca.state_rating_area_cost # indexed by state and rating area ## Geographic variation: age curves and family tiers -ACA premiums vary by rating area, and the age-rating rule is not uniform. Most states use the +ACA premiums vary by rating area, which the model looks up from the household's county, so pass +`county_fips` for any premium or PTC question. Without it the model uses the state's +alphabetically first county (`first_county_in_state`), silently: the $35k Texas adult above +lands in Anderson County (rating area 21) instead of Harris County (rating area 10), with a +different benchmark premium and PTC. Tracked in PolicyEngine/policyengine-us#9480. + + +```python +import policyengine as pe + +def aca(household): + r = pe.us.calculate_household( + people=[{"age": 45, "employment_income": 35_000}], + tax_unit={"filing_status": "SINGLE"}, + household=household, + year=2026, + spm={"geography_kind": "national"}, # lets the county-less call run + extra_variables=["slcsp_rating_area", "slcsp", "aca_ptc"], + ) + # slcsp is monthly; on an annual result it is the year's total benchmark premium + return r.household.slcsp_rating_area, round(r.tax_unit.slcsp, 2), round(r.tax_unit.aca_ptc, 2) + +assert aca({"state_code": "TX"}) == (21, 8_856.62, 6_250.38) # Anderson County +assert aca({"state_code": "TX", "county_fips": "48201"}) == (10, 9_241.69, 6_635.45) # Harris +``` + +The age-rating rule is not uniform either. Most states use the federal age curve, but the model carries **custom age curves for AL, DC, MA, MN, MS, OR, UT** and **family-tier rating (not age-based) for NY and VT** (verified from `policyengine_us/parameters/gov/aca/age_curves/`: `al, dc, ma, mn, ms, or, ut` plus `ny, vt`). When diff --git a/skills/policyengine-model-development/references/tests.md b/skills/policyengine-model-development/references/tests.md index 62fc445..f953f6b 100644 --- a/skills/policyengine-model-development/references/tests.md +++ b/skills/policyengine-model-development/references/tests.md @@ -65,6 +65,13 @@ assert both sides of a boundary in one case: 2024-09: true ``` +Always quote `county_fips`: YAML reads an unquoted `06037` as the octal integer 3103, and the +model accepts only a five-digit string. Since policyengine-us 2.0 a case asserting an +SPM-dependent output (`spm_unit_spm_threshold`, `spm_unit_is_in_spm_poverty`, `in_poverty`, or +the SPM resources of a unit allocated housing assistance) also needs a household +`county_fips`; with `state_code` alone the calculation raises `SPMInputError` +(`SPM_GEOGRAPHY_REQUIRED`). Cases for other variables still run on `state_code` alone. + Any month works here. Annual inputs can remain scalars; supply monthly inputs with period keys when their values vary through the year. This is useful whenever a MONTH-defined variable changes mid-year — a benefit standard that re-bases in April, a waiver that starts in September, a rate that changes on a state's own schedule. diff --git a/skills/policyengine-tools/SKILL.md b/skills/policyengine-tools/SKILL.md index e9802ef..b8b3e44 100644 --- a/skills/policyengine-tools/SKILL.md +++ b/skills/policyengine-tools/SKILL.md @@ -163,7 +163,7 @@ import modal from _image_setup import snapshot_models app = modal.App("my-tool-workers") image = (modal.Image.debian_slim(python_version="3.11") - .pip_install("policyengine[us]==X.Y.Z", "pydantic") # top-level release (>=5.0.1): pins matched country model + certified data bundle + .pip_install("policyengine[us]==X.Y.Z", "pydantic") # top-level release (>=6.0.0): pins matched country model + certified data bundle .run_function(snapshot_models) .add_local_file("backend/simulation.py", remote_path="/root/simulation.py")) @@ -178,7 +178,10 @@ policyengine as pe; sim = pe.us.managed_microsimulation(reform=...)` (same weigh MicroSeries surface: `.calc()` US / `.calculate()` UK; provenance on `sim.policyengine_bundle`) — never a directly-imported country-package `Microsimulation`, whose default dataset can lag the certified bundle. -Household-level endpoints may use `from policyengine_us import Simulation`. Pre-cache the +Household-level endpoints may use `from policyengine_us import Simulation`; a US situation +needs a household `county_fips` (five-digit string) or `spm={"geography_kind": "national"}` +for SPM outputs, and the county drives county-dependent rules, so collect it in the form (the +`policyengine` skill, "US geography"). Pre-cache the certified dataset by constructing the managed microsim inside `snapshot_models()` (UK builds need `HUGGING_FACE_TOKEN` — private dataset repo). Details: the `policyengine` skill. diff --git a/skills/policyengine-us/SKILL.md b/skills/policyengine-us/SKILL.md index d103193..d89ff7a 100644 --- a/skills/policyengine-us/SKILL.md +++ b/skills/policyengine-us/SKILL.md @@ -25,8 +25,9 @@ reform dicts, datasets — from the **policyengine** skill; read that first for US-specific. For building new US variables or parameters, use **policyengine-model-development**. For Medicaid / ACA / CHIP / Medicare, use **policyengine-healthcare**. -Verified against policyengine 4.21.0 / policyengine-us 1.764.6 (2026-07). Re-verify law-year -values before reporting — the model updates continuously. +Verified against policyengine 4.21.0 / policyengine-us 1.764.6 (2026-07); the marked examples +re-verified on policyengine 6.1.2 / policyengine-us 2.2.1 (2026-09). Re-verify law-year values +before reporting — the model updates continuously. ## The six entities, and which program lives on which @@ -95,7 +96,7 @@ def odc(dependent): return pe.us.calculate_household( people=[{"age": 45, "employment_income": 40_000}, kid], tax_unit={"filing_status": "HEAD_OF_HOUSEHOLD"}, - household={"state_code": "CA"}, + household={"state_code": "CA", "county_fips": "06037"}, year=2026, ).tax_unit.ctc @@ -105,8 +106,11 @@ assert odc(True) == 500 # $500 credit for other dependents (ODC) ## Household calculation -`state_code` (not `state_code_str`) goes in the `household` dict. A low-income married couple with -two children, showing where each program lands: +`state_code` (not `state_code_str`) and `county_fips` (a five-digit string) go in the `household` +dict. Since policyengine 6.0.0 a state alone raises `SPMInputError`, because the default outputs +include SPM poverty and its threshold is set by county; the policyengine skill covers the +national-threshold alternative. A low-income married couple with two children in Albany County, +showing where each program lands: ```python @@ -120,22 +124,34 @@ r = pe.us.calculate_household( {"age": 5}, ], tax_unit={"filing_status": "JOINT"}, - household={"state_code": "NY"}, + household={"state_code": "NY", "county_fips": "36001"}, # Albany County year=2026, + extra_variables=["head_start"], ) assert round(r.tax_unit.ctc) == 4_400 # 2 children x $2,200 (OBBBA, 2026) assert round(r.tax_unit.eitc) == 7_316 # federal EITC (tax_unit) -assert round(r.spm_unit.snap) == 7_364 # annual = sum of 12 monthly allotments -assert round(r.household.household_net_income) == 62_681 +assert round(r.spm_unit.snap) == 7_398 # annual = sum of 12 monthly allotments +assert round(r.household.household_net_income) == 46_246 +assert round(r.person[3].head_start) == 16_491 # the 5-year-old; not in net income ``` Here `income_tax` is about −$10,691 (refundable EITC + CTC exceed liability), so `household_net_income` exceeds gross earnings. `state_income_tax` can likewise be negative when refundable state credits exceed state liability. -Variables outside the default output columns (poverty flags, `is_child`, state credits, most -intermediate variables) must be requested with `extra_variables=[...]`, or attribute access raises -`AttributeError` listing what *is* available: +**Head Start is left out of net income by default.** The 5-year-old's `head_start` value (the +program's per-enrollee cost) enters `household_benefits` only when +`gov.simulation.include_head_start_benefits_in_net_income` is true; it defaults to false +(policyengine-us 1.819.0 onward), the same in-kind treatment as health benefits (see +policyengine-healthcare). Before that release this household's net income was about $16,000 +higher. + +The default output columns are short: on `spm_unit`, `snap`, `tanf`, `spm_unit_net_income` and +the SPM poverty flags (`spm_unit_is_in_spm_poverty`, `spm_unit_is_in_deep_spm_poverty`); on +`person`, `age`, `is_child`, `is_adult`, `employment_income`, `ssi` and a few more. Anything else +(`in_poverty`, `person_in_poverty`, state credits, most intermediate variables) must be requested +with `extra_variables=[...]`, or attribute access raises `AttributeError` listing what *is* +available: ```python @@ -144,11 +160,11 @@ import policyengine as pe r = pe.us.calculate_household( people=[{"age": 35, "employment_income": 25_000}, {"age": 8}], tax_unit={"filing_status": "HEAD_OF_HOUSEHOLD"}, - household={"state_code": "NY"}, + household={"state_code": "NY", "county_fips": "36001"}, year=2026, - extra_variables=["in_poverty", "person_in_poverty", "is_child"], + extra_variables=["in_poverty", "person_in_poverty"], ) -assert r.person[1].is_child == 1 +assert r.person[1].is_child == 1 # a default column r.spm_unit.in_poverty # SPM-unit poverty flag r.person[0].person_in_poverty # person-level projection ``` @@ -218,12 +234,12 @@ import policyengine as pe r = pe.us.calculate_household( people=[{"age": 30, "employment_income": 20_000}, {"age": 4}], tax_unit={"filing_status": "HEAD_OF_HOUSEHOLD"}, - household={"state_code": "CA"}, + household={"state_code": "CA", "county_fips": "06037"}, year=2026, extra_variables=["ca_eitc"], ) assert round(r.tax_unit.eitc) == 4_427 # federal EITC -assert round(r.tax_unit.ca_eitc, 2) == 410.16 # CalEITC (state) +assert round(r.tax_unit.ca_eitc, 2) == 401.89 # CalEITC (state) assert r.tax_unit.state_income_tax < 0 # refundable CA credits exceed liability ``` @@ -235,8 +251,56 @@ state's variables with `[v for v in CountryTaxBenefitSystem().variables if v.sta SNAP is the US benefit whose timing most often surprises analysts. Everything below is verified against the model source. +**The county sets New York's and Alaska's utility allowances.** SNAP utility allowances follow +`snap_utility_region`, which reads the county in Alaska and New York. The same Albany County +family from "Household calculation", moved to New York County (Manhattan), gets more SNAP, pays +New York City tax, and crosses the SPM poverty line (Manhattan's SPM threshold is higher): + + +```python +import policyengine as pe + +def ny(county_fips): + r = pe.us.calculate_household( + people=[ + {"age": 35, "employment_income": 25_000}, + {"age": 33, "employment_income": 0}, + {"age": 8}, + {"age": 5}, + ], + tax_unit={"filing_status": "JOINT"}, + household={"state_code": "NY", "county_fips": county_fips}, + year=2026, + ) + return round(r.spm_unit.snap), bool(r.spm_unit.spm_unit_is_in_spm_poverty) + +assert ny("36001") == (7_398, False) # Albany County: rest-of-state utility allowance +assert ny("36061") == (8_061, True) # New York County: New York City utility allowance +``` + +**Adults subject to the ABAWD rules need their hours.** `weekly_hours_worked_before_lsr` +defaults to 0 (policyengine-us 2.x), so a childless adult who is subject to the ABAWD work +requirement (`is_subject_to_snap_abawd`) fails it (`meets_snap_abawd_work_requirements`) unless +you pass their hours: + + +```python +import policyengine as pe + +def snap(**hours): + return pe.us.calculate_household( + people=[{"age": 30, "employment_income": 8_000, **hours}], + tax_unit={"filing_status": "SINGLE"}, + household={"state_code": "CA", "county_fips": "06037"}, + year=2026, + ).spm_unit.snap + +assert round(snap()) == 298 # hours default to 0 +assert round(snap(weekly_hours_worked_before_lsr=25), 2) == 3_607.57 +``` + **SNAP is monthly.** `snap` and its inputs have `definition_period = MONTH`. When -`calculate_household` reports an annual figure (like the $7,364 above), it is the **sum of twelve +`calculate_household` reports an annual figure (like the $7,398 above), it is the **sum of twelve monthly allotments** — not one annual calculation. That is why SNAP produces partial-year cliffs that annual-only reasoning misses. @@ -272,7 +336,10 @@ The 30% expected-contribution rate is itself a parameter — `gov.usda.snap.expe To debug one month, drop to the country package and calculate at a monthly period: `from policyengine_us import Simulation; Simulation(situation=...).calculate("snap_normal_allotment", "2026-11")`, with `.trace = True` for the dependency tree. That direct-import surface is for tracing, -not for analysis results you report (see the policyengine skill). +not for analysis results you report (see the policyengine skill). It applies the same SPM rule: +to trace an SPM output (`in_poverty`, `spm_unit_spm_threshold`), put a year-keyed five-digit +`county_fips` in the situation's household (`{"2026": "06037"}`) or construct +`Simulation(situation=..., spm={"geography_kind": "national"})`. ## Datasets and population analysis diff --git a/skills/policyengine-writing/SKILL.md b/skills/policyengine-writing/SKILL.md index 2929f94..d1097ad 100644 --- a/skills/policyengine-writing/SKILL.md +++ b/skills/policyengine-writing/SKILL.md @@ -650,7 +650,7 @@ Install PolicyEngine from PyPI: uv pip install "policyengine[us]" ``` -This installs version 5.0.1 or later, which pins the US model and the +This installs version 6.0.0 or later, which pins the US model and the certified Microcosm data bundle. ``` diff --git a/skills/policyengine/SKILL.md b/skills/policyengine/SKILL.md index dbb89ce..614436c 100644 --- a/skills/policyengine/SKILL.md +++ b/skills/policyengine/SKILL.md @@ -22,7 +22,7 @@ interface for both single-household calculations and population microsimulation. certified model + data bundle, so results are reproducible and the data provenance is known. Originally verified against policyengine 4.21.0 (2026-07); the marked examples re-run in CI -against the latest release (5.0.1 at 2026-08). Re-verify the bundle when precision matters +against the latest release (6.1.2 at 2026-09-29). Re-verify the bundle when precision matters (see "Checking what you're running" below). ## Setup @@ -35,8 +35,9 @@ uv pip install "policyengine[uk]" # UK model (population data needs HUGGING_FA uv pip install "policyengine" # both countries ``` -Analysis always runs on the **latest released** `policyengine` (`>=5.0.1`; resolve "latest" -from PyPI as described in "Checking what you're running"). Each release pins exactly-matched +Analysis always runs on the **latest released** `policyengine` (`>=6.0.0`, which changed the +US household inputs below; resolve "latest" from PyPI as described in "Checking what you're +running"). Each release pins exactly-matched country-model versions and the certified data bundle, which is what makes results reproducible. Directly-imported country packages (`policyengine_us` / `policyengine_uk`) are for model development and tests, not for analysis compute. @@ -46,6 +47,12 @@ for model development and tests, not for analysis compute. `calculate_household` answers "what does this specific household get/pay?" — no dataset download, runs in seconds. +**A US household needs a county, not just a state.** Since policyengine 6.0.0 the default US +outputs include SPM poverty, and the SPM threshold is set by county, so a household with only +`state_code` raises `SPMInputError: County selection has no county FIPS input`. Pass +`county_fips` in `household` as a five-digit string (see "US geography" below for the +national alternative). + ```python import policyengine as pe @@ -53,19 +60,20 @@ import policyengine as pe result = pe.us.calculate_household( people=[{"age": 40, "employment_income": 50_000}, {"age": 8}], tax_unit={"filing_status": "HEAD_OF_HOUSEHOLD"}, - household={"state_code": "CA"}, + household={"state_code": "CA", "county_fips": "06037"}, # Los Angeles County year=2026, extra_variables=["income_tax"], ) assert result.tax_unit.ctc == 2_200 # OBBBA CTC, 2026 -assert round(result.household.household_net_income) == 46_358 +assert round(result.household.household_net_income) == 46_369 print(result.spm_unit.snap, result.tax_unit.eitc, result.tax_unit.income_tax) ``` Result access is **dot-attribute on singular entities** — `result.tax_unit.ctc`, never `result.tax_unit[0]["ctc"]`. Only `result.person` is a list (`result.person[0].age`). Entities: `person[i]`, `marital_unit`, `family`, `spm_unit`, `tax_unit`, `household` (US); -`person[i]`, `benunit`, `household` (UK). +`person[i]`, `benunit`, `household` (UK). US results also carry `result.provenance`, a plain +dict rather than an entity (see "US geography"). **Each entity exposes a limited default column set** — accessing anything else raises `AttributeError` listing what's available and telling you the fix: pass @@ -81,13 +89,13 @@ import policyengine as pe baseline = pe.us.calculate_household( people=[{"age": 40, "employment_income": 50_000}, {"age": 8}], tax_unit={"filing_status": "HEAD_OF_HOUSEHOLD"}, - household={"state_code": "CA"}, + household={"state_code": "CA", "county_fips": "06037"}, year=2026, ) reformed = pe.us.calculate_household( people=[{"age": 40, "employment_income": 50_000}, {"age": 8}], tax_unit={"filing_status": "HEAD_OF_HOUSEHOLD"}, - household={"state_code": "CA"}, + household={"state_code": "CA", "county_fips": "06037"}, year=2026, reform={"gov.irs.credits.ctc.amount.base[0].amount": 3_000}, ) @@ -116,7 +124,7 @@ import policyengine as pe result = pe.us.calculate_household( people=[{"age": 40}], tax_unit={"filing_status": "SINGLE"}, - household={"state_code": "TX"}, + household={"state_code": "TX", "county_fips": "48201"}, # Harris County year=2026, axes=[[{"name": "employment_income", "min": 0, "max": 200_000, "count": 401}]], ) @@ -126,6 +134,83 @@ assert len(earnings) == len(net) == 401 assert earnings[1] == 500.0 ``` +### US geography: a county, or an explicit SPM selection + +Since policyengine 6.0.0 (policyengine-us 2.x) SPM measurement never guesses a place from the +state, and the default US outputs include SPM poverty (`spm_unit_is_in_spm_poverty`, +`spm_unit_is_in_deep_spm_poverty`). `extra_variables` only adds columns, so even a call that +wants nothing but `income_tax` needs one of these: + +- **`county_fips` as a five-digit string** (`"06037"`). In the default county mode an integer + (`6037`, or even `36061`) or a dropped leading zero (`"6037"`) raises `SPMInputError` with + `code == "SPM_GEOGRAPHY_REQUIRED"`, the same as a missing county. +- **`spm={"geography_kind": "national"}`** when you only know the state: SPM poverty is then + measured against the national threshold. Nothing validates `county_fips` in this mode, so + `6037` or `"6037"` silently becomes the state's first county (see below). +- **`spm={"geography_kind": "metro", "geography_id": "31080"}`** to name an SPM area directly + (31080 is the Los Angeles–Long Beach–Anaheim MSA). `pe.us.SPMSelection` is the typed form of + the mapping; a malformed selection, such as metro without `geography_id`, raises a pydantic + `ValidationError`. `SPMInputError` (from `spm_calculator.errors`) subclasses `ValueError`. + + +```python +import policyengine as pe +from spm_calculator.errors import SPMInputError + +family = dict( + people=[{"age": 40, "employment_income": 50_000}, {"age": 8}], + tax_unit={"filing_status": "HEAD_OF_HOUSEHOLD"}, + year=2026, + extra_variables=["spm_unit_spm_threshold"], +) +for household in ({"state_code": "CA"}, {"state_code": "CA", "county_fips": 6037}): + try: + pe.us.calculate_household(**family, household=household) # expect: SPMInputError + except SPMInputError: + pass + else: + raise AssertionError(f"{household} should need a five-digit county string") + +state_only = pe.us.calculate_household( + **family, household={"state_code": "CA"}, spm={"geography_kind": "national"} +) +assert state_only.provenance["spm_config"]["geography_kind"] == "national" + +by_county = pe.us.calculate_household( + **family, household={"state_code": "CA", "county_fips": "06037"} +) +by_nation = pe.us.calculate_household( + **family, + household={"state_code": "CA", "county_fips": "06037"}, + spm={"geography_kind": "national"}, +) +assert by_county.provenance["spm_config"]["geography_kind"] == "county" +# Same household and county: the SPM selection moves the threshold, not net income. +assert by_county.spm_unit.spm_unit_spm_threshold != by_nation.spm_unit.spm_unit_spm_threshold +assert by_county.household.household_net_income == by_nation.household.household_net_income +``` + +`result.provenance["spm_config"]` records the selection a result used (geography kind, +scenario, the pinned forecast's hash); `result.provenance["spm"]` is the calculation receipt +(forecast id, runtime versions, and under `geographies` the SPM area each county resolved to, +with its geographic factor). + +**Years 2022–2035 only.** The pinned SPM forecast covers 2022–2035, and because the default +outputs include SPM poverty, `calculate_household` raises `SPMInputError` +(`SPM_YEAR_UNAVAILABLE`) for any other year, whatever the geography. The country package's +`Simulation` still computes non-SPM variables such as `income_tax` for other years. + +**The county matters beyond SPM.** `county_fips` also sets the household's `county`, which +county-dependent rules read: ACA rating areas, SNAP utility regions (Alaska and New York), +local income taxes such as New York City's, and county programs. Without it the model falls +back to `first_county_in_state`, the state's alphabetically first county: Anderson County for +any Texas household rather than Houston's Harris County, Albany County for New York rather +than New York City. `spm=` does not change that fallback. Nothing checks `county_fips` against +`state_code` either: a `"NY"` household given `"06037"` runs New York state rules with Los +Angeles County rules, so the county's first two digits must be the state's FIPS code. Pass the +real county whenever a result can depend on location (see policyengine-us for SNAP and New +York City, policyengine-healthcare for ACA premiums). + ## Population analysis (heavy: tens of GB RAM, minutes per simulation) The canonical population flow builds year-specific datasets from the certified bundle, then @@ -170,7 +255,11 @@ Key facts: `unattributed`. Sign convention: **positive = government better off**. `total` is Δhousehold_tax − Δhousehold_benefits plus shared-funding health-program cost (Medicaid/CHIP/MSP), so it captures cascading interactions — never score a reform by - summing the directly-modified program variable alone. + summing the directly-modified program variable alone. The exception is an in-kind program + that the US model leaves out of `household_benefits` by default: Head Start and Early Head + Start count only when `gov.simulation.include_head_start_benefits_in_net_income` is true + (default false; see policyengine-us), so score a Head Start reform from `head_start` / + `early_head_start` directly or switch the toggle on in both simulations. - **Memory/time**: a full US population simulation is tens of GB of RAM and several minutes; a baseline+reform pair with full outputs took ~15 minutes on a 128 GB machine. Run ONE heavy simulation pipeline at a time. Household calculations are the cheap path — prefer @@ -351,13 +440,14 @@ import importlib.metadata as md import json from pathlib import Path -versions = {p: md.version(p) for p in ("policyengine", "policyengine-us")} +versions = {p: md.version(p) for p in ("policyengine", "policyengine-us", "spm-calculator")} manifest = json.loads( Path(md.distribution("policyengine").locate_file("policyengine/data/bundle/manifest.json")) .read_text() ) us = manifest["data_releases"]["us"] -print(versions, us["default_dataset"], us["build_id"]) +spm = manifest["measurements"]["spm"] # pinned SPM forecast hash, scenario, default geography +print(versions, us["default_dataset"], us["build_id"], spm) assert us["default_dataset"].startswith("populace_us") ``` diff --git a/targets/claude/agents/dashboard/backend-builder.md b/targets/claude/agents/dashboard/backend-builder.md index c32bebf..203612f 100644 --- a/targets/claude/agents/dashboard/backend-builder.md +++ b/targets/claude/agents/dashboard/backend-builder.md @@ -213,7 +213,7 @@ When `data_pattern: custom-modal`, build a two-layer architecture on Modal: a li pip index versions policyengine 2>/dev/null | head -1 ``` -Use the version number returned (must be `>=5.0.1`) in the `pip_install()` call below. Install `policyengine[us]` (or `policyengine[uk]`) rather than a bare country package: the extra pins an exactly-matched `policyengine-us`/`policyengine-uk` + `policyengine-core` and carries the certified data-bundle manifest, so population results are reproducible and their provenance is known. +Use the version number returned (must be `>=6.0.0`) in the `pip_install()` call below. Install `policyengine[us]` (or `policyengine[uk]`) rather than a bare country package: the extra pins an exactly-matched `policyengine-us`/`policyengine-uk` + `policyengine-core` and carries the certified data-bundle manifest, so population results are reproducible and their provenance is known. ### Step 2: Create Image Setup @@ -244,7 +244,7 @@ image build (attach it as a Modal secret to the snapshot function and the worker Generate `backend/simulation.py`. This is **pure business logic** — no Modal imports. policyengine imports are at module level because they are captured in the image snapshot. -**Population/statewide endpoints go through policyengine.py's managed path** — `pe.us.managed_microsimulation()` (policyengine>=5.0.1), which returns a country-package `Microsimulation` pinned to the certified data bundle, with the same weighted MicroSeries interface (`.calc()` US / `.calculate()` UK) and provenance stamped on `sim.policyengine_bundle`. A directly-imported country-package `Microsimulation` is deprecated for analysis: its default dataset can lag the certified bundle, so results are not provenance-known. +**Population/statewide endpoints go through policyengine.py's managed path** — `pe.us.managed_microsimulation()` (policyengine>=6.0.0), which returns a country-package `Microsimulation` pinned to the certified data bundle, with the same weighted MicroSeries interface (`.calc()` US / `.calculate()` UK) and provenance stamped on `sim.policyengine_bundle`. A directly-imported country-package `Microsimulation` is deprecated for analysis: its default dataset can lag the certified bundle, so results are not provenance-known. ```python import policyengine as pe # managed analysis surface — snapshotted at build time @@ -256,6 +256,12 @@ from pydantic import BaseModel # Available in image def run_household(params: dict) -> dict: # Build household from params (per plan.yaml endpoints) + # US situations need a household county_fips: a five-digit string keyed by year, + # {"2026": "06037"}. Without it SPM outputs (poverty, thresholds) raise SPMInputError, + # and county-dependent rules (ACA rating area, SNAP utility region, local taxes) use + # the state's first county. Collect the county (or a ZIP mapped to one) in the form; + # if only the state is known, pass spm={"geography_kind": "national"} to Simulation + # and label SPM results as national. See the policyengine skill, "US geography". sim = Simulation(situation=params["household"]) return {"net_income": float(sim.calculate("household_net_income", 2025).sum())} diff --git a/targets/claude/agents/dashboard/dashboard-planner.md b/targets/claude/agents/dashboard/dashboard-planner.md index ef1afa4..1bfdffa 100644 --- a/targets/claude/agents/dashboard/dashboard-planner.md +++ b/targets/claude/agents/dashboard/dashboard-planner.md @@ -326,7 +326,7 @@ precomputed: # for precomputed / precomputed-csv patterns custom_modal: # for custom-modal pattern reason: "Needs microsimulation with custom CTC phase-out parameter" - policyengine_package: "policyengine[us]" # top-level extra (>=5.0.1) — pins matched country model + certified bundle; microsim endpoints use pe.us.managed_microsimulation() + policyengine_package: "policyengine[us]" # top-level extra (>=6.0.0) — pins matched country model + certified bundle; microsim endpoints use pe.us.managed_microsimulation() architecture: gateway-polling # Always use this — mirrors API v2 simulation service backend_files: # Three-file structure (avoids module-level import crash-loop) image_setup: backend/_image_setup.py # Standalone snapshot function diff --git a/targets/claude/agents/microsim-runner.md b/targets/claude/agents/microsim-runner.md index 9918d38..9e5d676 100644 --- a/targets/claude/agents/microsim-runner.md +++ b/targets/claude/agents/microsim-runner.md @@ -11,7 +11,7 @@ Runs a single reform through PolicyEngine and returns structured impact results. **Two execution paths** — pick based on environment: 1. **API path (default, no install required):** call `api.policyengine.org`. -2. **Local path (heavy, more flexible):** install the `policyengine` package (latest, `>=5.0.1`) and run through its managed `pe.{us,uk}` surfaces (see "Process — Local path"). +2. **Local path (heavy, more flexible):** install the `policyengine` package (latest, `>=6.0.0`) and run through its managed `pe.{us,uk}` surfaces (see "Process — Local path"). ## Inputs @@ -231,7 +231,7 @@ If a 10-year cost is required, run all 10 years via `run_horizon(policy_id, base ## Process — Local path -Load the `policyengine` skill for current import patterns and certified dataset names. The canonical local entry point is the top-level `policyengine` package (latest, `>=5.0.1`) pinned to the certified Microcosm bundle — **never** `from policyengine_us import Microsimulation` (unmanaged; deprecated for analysis) or a bare `from policyengine import Microsimulation` (that class does not exist). +Load the `policyengine` skill for current import patterns and certified dataset names. The canonical local entry point is the top-level `policyengine` package (latest, `>=6.0.0`) pinned to the certified Microcosm bundle — **never** `from policyengine_us import Microsimulation` (unmanaged; deprecated for analysis) or a bare `from policyengine import Microsimulation` (that class does not exist). Baseline-vs-reform economy analysis (budget, poverty, deciles, inequality in one pass): diff --git a/targets/claude/commands/household-calc.md b/targets/claude/commands/household-calc.md index 366cb4c..0f551a1 100644 --- a/targets/claude/commands/household-calc.md +++ b/targets/claude/commands/household-calc.md @@ -18,19 +18,26 @@ Do NOT use for population-level questions ("how many households benefit"; "what' `$ARGUMENTS` — natural-language household description + question. Include: - Country (`US`, `UK`, `CA`) and state / province if relevant +- US: the county (or a city/ZIP to map to its five-digit `county_fips`). Since policyengine + 6.0.0 a US household needs one for SPM poverty, and it sets county-dependent rules (ACA + rating area, SNAP utility region in AK/NY, local taxes). If the user gives only a state, run + with `spm={"geography_kind": "national"}` and say that county-dependent results use the + state's first county. +- US: weekly hours worked for each working-age adult (`weekly_hours_worked_before_lsr`, + default 0, which fails the SNAP ABAWD work requirement) - Household composition (single, married, dependents with ages) - Income sources ($ amounts for wages, SS, unemployment, etc.) - The question — baseline benefits? A specific reform? A marginal-rate calculation? Flags: -- `--year YYYY` (default: current tax/benefit year) +- `--year YYYY` (default: current tax/benefit year; US household calculations cover 2022–2035) - `--reform ` — apply a preset reform (see `presets/reforms/`) or a raw reform-dict JSON before calculating - `--reform-file PATH` — load reform-dict from a file ## What this command does 1. Loads the appropriate country skill (`policyengine-us` / `policyengine-uk` / `policyengine-canada`) — these carry the correct API patterns for the current package version. -2. Constructs a `Simulation` (household-level, not `Microsimulation`) using the parsed household description. +2. Computes the household from the parsed description: US and UK through `pe.us.calculate_household` / `pe.uk.calculate_household` (see the `policyengine` skill), Canada through `policyengine_canada`'s household `Simulation` (see `policyengine-canada`). Never `Microsimulation`. 3. Calculates the variables the user asked about (net income, specific benefit, tax owed, marginal rate). 4. Reports baseline values, reform values (if `--reform`), and the delta. 5. Optionally cites the parameter paths that drove each result so the analyst can trace numbers back to statute. diff --git a/targets/claude/commands/new-tool.md b/targets/claude/commands/new-tool.md index 485ac6d..2c6058e 100644 --- a/targets/claude/commands/new-tool.md +++ b/targets/claude/commands/new-tool.md @@ -182,7 +182,7 @@ export async function calculate(countryId, household) { Pattern C uses a **three-file backend structure** mirroring policyengine-api-v2's simulation service: a standalone image setup, a worker app, and pure simulation logic. A lightweight gateway manages job submission/polling. This avoids Modal's ~150s gateway timeout and a common crash-loop where module-level imports fail. -First, look up the latest version of the top-level `policyengine` package from PyPI (must be `>=5.0.1`). Do NOT guess or use a version from memory: +First, look up the latest version of the top-level `policyengine` package from PyPI (must be `>=6.0.0`). Do NOT guess or use a version from memory: ```bash pip index versions policyengine 2>/dev/null | head -1 @@ -213,13 +213,19 @@ Create `backend/simulation.py` (pure logic, policyengine at module level — cap from policyengine_us import Simulation # household-level; snapshotted at build time def run_compute(params: dict) -> dict: + # US situations need a household county_fips: a five-digit string keyed by year, + # {"2026": "06037"}. Without it SPM outputs (poverty, thresholds) raise SPMInputError, + # and county-dependent rules (ACA rating area, SNAP utility region, local taxes) use + # the state's first county. Collect the county (or a ZIP mapped to one) in the form; + # if only the state is known, pass spm={"geography_kind": "national"} to Simulation + # and label SPM results as national. See the policyengine skill, "US geography". sim = Simulation(situation=params["household"]) return {"result": float(sim.calculate("variable_name", 2025).sum())} ``` If the tool needs **population-scale** results (statewide/national impacts), compute them through policyengine.py's managed path — `import policyengine as pe; -sim = pe.us.managed_microsimulation(reform=...)` (policyengine>=5.0.1) — never a +sim = pe.us.managed_microsimulation(reform=...)` (policyengine>=6.0.0) — never a directly-imported country-package `Microsimulation`, whose default dataset can lag the certified bundle. The managed constructor returns the same MicroSeries `.calc()` surface and records provenance on `sim.policyengine_bundle`; see the `policyengine` skill. diff --git a/tests/test_no_stale_references.py b/tests/test_no_stale_references.py index e9a9165..ee2e9f7 100644 --- a/tests/test_no_stale_references.py +++ b/tests/test_no_stale_references.py @@ -40,7 +40,7 @@ MICROSIM_REASON = ( "direct country-package Microsimulation is deprecated for analysis " "(2026-08-01); use pe.{us,uk}.managed_microsimulation() from " - "policyengine>=5.0.1 — deliberate deprecation notes and explicitly-scoped " + "policyengine>=6.0.0 — deliberate deprecation notes and explicitly-scoped " "engine-development examples take " ) diff --git a/tests/test_us_household_geography.py b/tests/test_us_household_geography.py new file mode 100644 index 0000000..924f2d5 --- /dev/null +++ b/tests/test_us_household_geography.py @@ -0,0 +1,117 @@ +"""Every US household example must name its SPM geography. + +Since policyengine 6.0.0 (policyengine-us 2.x, spm-calculator 1.0.0), the +default outputs of ``pe.us.calculate_household`` include SPM poverty, and a +household with only ``state_code`` raises ``SPMInputError``: the SPM threshold +is set by county, and a state alone does not identify one. Eight fast skill +examples broke that way before anyone noticed, because only the +``skill-examples`` job (which installs the full model) could see it. + +This lint catches the same mistake in the plain ``test`` job, and in examples +that are not marked ````: each ``pe.us.calculate_household(`` +call must pass ``county_fips`` (a five-digit string) or an ``spm=`` selection. +A call written to demonstrate the error opts out with an +``# expect: SPMInputError`` comment inside the call or on the line that closes +it. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +SCAN_DIRS = ["skills", "targets", "docs", "bundles", "presets"] +SCAN_SUFFIXES = {".md", ".py", ".ipynb"} + +CALL = re.compile(r"\bpe\.us\.calculate_household\(") +GEOGRAPHY = re.compile(r"\bcounty_fips\b|\bspm\s*=") +EXPECTED_ERROR = "# expect: SPMInputError" + + +def call_text(text: str, open_paren: int) -> str: + """Return the call whose "(" is at ``open_paren``, through the end of the + line that closes it (so a trailing comment on that line belongs to it).""" + depth = 0 + for index in range(open_paren, len(text)): + if text[index] == "(": + depth += 1 + elif text[index] == ")": + depth -= 1 + if depth == 0: + line_end = text.find("\n", index) + return text[open_paren : len(text) if line_end == -1 else line_end] + return text[open_paren:] + + +def scan_text(text: str) -> list[int]: + """Return the line numbers of US household calls with no SPM geography.""" + missing = [] + for match in CALL.finditer(text): + arguments = call_text(text, match.end() - 1) + if GEOGRAPHY.search(arguments) or EXPECTED_ERROR in arguments: + continue + missing.append(text.count("\n", 0, match.start()) + 1) + return missing + + +def iter_scan_files(): + for dirname in SCAN_DIRS: + base = REPO_ROOT / dirname + if not base.exists(): + continue + for path in sorted(base.rglob("*")): + if path.is_file() and path.suffix in SCAN_SUFFIXES: + yield path + + +def test_us_household_calls_name_spm_geography() -> None: + violations = [] + for path in iter_scan_files(): + text = path.read_text(encoding="utf-8", errors="replace") + for lineno in scan_text(text): + violations.append(f"{path.relative_to(REPO_ROOT)}:{lineno}") + assert not violations, ( + "pe.us.calculate_household calls without county_fips or spm= " + "(state_code alone raises SPMInputError since policyengine 6.0.0):\n" + + "\n".join(violations) + ) + + +def test_scanner_flags_state_only_households() -> None: + state_only = ( + "r = pe.us.calculate_household(\n" + " people=[{'age': 40}],\n" + " household={'state_code': 'CA'},\n" + " year=2026,\n" + ")\n" + ) + assert scan_text(state_only) == [1] + # A nested call's parentheses do not end the scan early. + nested = "pe.us.calculate_household(people=list(range(1)), household=dict(state_code='CA'))" + assert scan_text(nested) == [1] + + +def test_scanner_accepts_county_spm_or_marked_error() -> None: + county = "pe.us.calculate_household(household={'state_code': 'CA', 'county_fips': '06037'})" + national = "pe.us.calculate_household(household={'state_code': 'CA'}, spm={'geography_kind': 'national'})" + national_spaced = "pe.us.calculate_household(household=h, spm = selection)" + marked = ( + "pe.us.calculate_household(**family, household={'state_code': 'CA'})" + " # expect: SPMInputError" + ) + marked_inside = ( + "pe.us.calculate_household(\n" + " household={'state_code': 'CA'}, # expect: SPMInputError\n" + ")" + ) + prose = "Use `pe.us.calculate_household` for one household." + for sample in (county, national, national_spaced, marked, marked_inside, prose): + assert scan_text(sample) == [], sample + # A marker on a later line belongs to whatever follows, not to this call. + marked_later = ( + "pe.us.calculate_household(household={'state_code': 'CA'})\n" + "# expect: SPMInputError\n" + ) + assert scan_text(marked_later) == [1]