diff --git a/CLAUDE.md b/CLAUDE.md index f9f5a725b4e..74711ab4b46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,7 +127,8 @@ changelog.d/medicaid-ce-exclusions.md - **When adding a new program**: add an entry with `id`, `name`, `full_name`, `category`, `agency`, `status`, `coverage`, `variable`, `parameter_prefix` - **When extending year coverage**: update the entry's year field — most entries use `verified_start_year`, a few use a `verified_years` range (e.g., `"2022-2026"`) — after verifying parameters and tests cover the new year - **When adding state implementations**: add to `state_implementations` list under the parent federal program -- **Status values**: `complete`, `partial`, `in_progress` +- **Status values**: `complete`, `partial`, `in_progress`. There is no not-started value: when an `in_progress` entry's PR closes unmerged and no code is on main, remove the entry (and its state from `coverage`) +- `policyengine_us/tests/test_programs_registry.py` checks statuses, state codes, and that every `variable` exists and every `parameter_prefix` resolves in the parameter tree. Never add keys to its `KNOWN_UNRESOLVED` list; delete them as entries are fixed - Keep entries sorted by: Taxes, then Benefits by agency (USDA, HHS, SSA, HUD, FCC, ED, DOE), then State, then Local ## State Program Patterns diff --git a/changelog.d/fix-liheap-registry.fixed.md b/changelog.d/fix-liheap-registry.fixed.md new file mode 100644 index 00000000000..af427d424b0 --- /dev/null +++ b/changelog.d/fix-liheap-registry.fixed.md @@ -0,0 +1 @@ +Program registry LIHEAP fixes: DC LIHEAP now points at `dc_liheap_payment` (`dc_liheap` does not exist); Riverside County LIHEAP's parameter prefix is `gov.local.ca.riv.cap.liheap`; the Oregon LIHEAP entry, which had no code on main or open PR, is removed; DC, Massachusetts and Illinois gain parameter prefixes; and Texas CEAP's note gives the SMI limit's actual years. A new registry test checks that every listed variable and parameter prefix exists. diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index 33096ded8c4..efe44de4602 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -802,27 +802,26 @@ programs: category: Benefits agency: HHS status: partial - coverage: OR, DC, Riverside County, MA, IL, IN, KS, KY, MS, NC, NE, TX + coverage: DC, Riverside County, MA, IL, IN, KS, KY, MS, NC, NE, TX state_implementations: - - state: OR - status: in_progress - name: LIHEAP - full_name: Low Income Home Energy Assistance Program (Oregon) - state: DC status: complete name: DC LIHEAP full_name: DC Low Income Home Energy Assistance Program - variable: dc_liheap + variable: dc_liheap_payment + parameter_prefix: gov.states.dc.doee.liheap - state: MA status: complete name: Massachusetts LIHEAP full_name: Massachusetts Low Income Home Energy Assistance Program variable: ma_liheap + parameter_prefix: gov.states.ma.doer.liheap - state: IL status: complete name: Illinois LIHEAP full_name: Illinois Low Income Home Energy Assistance Program variable: il_liheap + parameter_prefix: gov.states.il.dceo.liheap - state: IN status: partial name: Indiana EAP @@ -877,7 +876,7 @@ programs: full_name: Texas Comprehensive Energy Assistance Program variable: tx_ceap parameter_prefix: gov.states.tx.tdhca.ceap - notes: Models the FPG income limit, the FY2024-only SMI limit, benefit amounts, and categorical eligibility through TANF enrollment, SNAP eligibility, and SSI receipt. Not modeled - net rather than gross gambling winnings, the Medicare premium deduction from counted Social Security, and the exclusion of means-tested veterans' pensions from counted income (10 TAC 6.4(c)) + notes: Models the 150% FPG income limit, the alternative 60% SMI limit from the FY2024 state plan (applied through 2024), benefit amounts, and categorical eligibility through TANF enrollment, SNAP eligibility, and SSI receipt. Not modeled - net rather than gross gambling winnings, the Medicare premium deduction from counted Social Security, and the exclusion of means-tested veterans' pensions from counted income (10 TAC 6.4(c)) # --- SSA programs --- - id: ssi @@ -1718,7 +1717,7 @@ programs: status: complete coverage: Riverside County variable: ca_riv_liheap_eligible - parameter_prefix: gov.local.ca.riv.liheap + parameter_prefix: gov.local.ca.riv.cap.liheap notes: Only includes eligibility rules - id: share diff --git a/policyengine_us/tests/test_programs_registry.py b/policyengine_us/tests/test_programs_registry.py new file mode 100644 index 00000000000..593b481391f --- /dev/null +++ b/policyengine_us/tests/test_programs_registry.py @@ -0,0 +1,148 @@ +"""Structural guards for the program registry (programs.yaml). + +The registry is served through the /us/metadata API as ``modelled_policies`` +and renders the model coverage page, so every model reference in it has to +point at something the model defines. Two failure modes have shipped before: +1. A ``variable`` that the model does not define (DC LIHEAP listed + ``dc_liheap``, but the benefit variable is ``dc_liheap_payment``). +2. A ``parameter_prefix`` that resolves to no parameter node (Riverside County + LIHEAP listed ``gov.local.ca.riv.liheap``; its parameters live under + ``gov.local.ca.riv.cap.liheap``). +""" + +from collections import Counter +from pathlib import Path + +import pytest +import yaml + +REGISTRY = Path(__file__).parent.parent / "programs.yaml" + +STATUSES = {"complete", "partial", "in_progress"} + +# References that did not resolve when this guard was added, keyed by +# (entry label, field). Fix the registry entry and delete its key here; do +# not add new keys. +KNOWN_UNRESOLVED = { + ("csfp", "variable"), + ("fdpir", "parameter_prefix"), + ("social_security", "parameter_prefix"), + ("clean_vehicle_credits", "variable"), + ("dc_power", "parameter_prefix"), + ("dc_gac", "parameter_prefix"), + ("la_expectant_parent", "parameter_prefix"), + ("sf_wftc", "variable"), + ("montgomery_county_eitc", "variable"), + ("montgomery_county_eitc", "parameter_prefix"), +} + + +def registry_entries(): + """(label, state or None, entry) for every program and state implementation. + + A state implementation's label includes its name, since a program can list + more than one implementation for a state (Washington's two ECEAPs). + """ + programs = yaml.safe_load(REGISTRY.read_text())["programs"] + entries = [] + for program in programs: + entries.append((program["id"], None, program)) + for implementation in program.get("state_implementations") or []: + state = implementation["state"] + label = f"{program['id']}/{state}/{implementation['name']}" + entries.append((label, state, implementation)) + return entries + + +ENTRIES = registry_entries() +ENTRIES_BY_LABEL = {label: entry for label, _, entry in ENTRIES} + + +def references(field): + return [ + pytest.param(label, entry[field], id=label) + for label, _, entry in ENTRIES + if field in entry + ] + + +@pytest.fixture(scope="module") +def system(): + from policyengine_us.system import system + + return system + + +def parameter_prefix_resolves(system, prefix): + try: + system.parameters.get_child(prefix) + except ValueError: + return False + return True + + +def test_entry_labels_are_unique(): + counts = Counter(label for label, _, _ in ENTRIES) + duplicates = sorted(label for label, count in counts.items() if count > 1) + assert not duplicates, f"Duplicate program ids or implementations: {duplicates}" + + +@pytest.mark.parametrize( + "label, entry", + [pytest.param(label, entry, id=label) for label, _, entry in ENTRIES], +) +def test_status_is_known(label, entry): + assert entry["status"] in STATUSES, ( + f"{label} has status {entry['status']!r}; the registry uses " + f"{sorted(STATUSES)}. List a program only once work on it has started." + ) + + +def test_state_implementations_name_valid_states(): + from policyengine_us.variables.household.demographic.geographic.state_code import ( + StateCode, + ) + + valid = {state.value for state in StateCode} + invalid = sorted( + label for label, state, _ in ENTRIES if state is not None and state not in valid + ) + assert not invalid, f"Unknown state codes: {invalid}" + + +@pytest.mark.parametrize("label, variable", references("variable")) +def test_variable_is_defined(system, label, variable): + if (label, "variable") in KNOWN_UNRESOLVED: + pytest.skip("listed in KNOWN_UNRESOLVED") + assert variable in system.variables, ( + f"{label} names variable {variable!r}, which the model does not define." + ) + + +@pytest.mark.parametrize("label, prefix", references("parameter_prefix")) +def test_parameter_prefix_resolves(system, label, prefix): + if (label, "parameter_prefix") in KNOWN_UNRESOLVED: + pytest.skip("listed in KNOWN_UNRESOLVED") + assert parameter_prefix_resolves(system, prefix), ( + f"{label} names parameter_prefix {prefix!r}, which resolves to no " + "parameter node." + ) + + +def test_known_unresolved_references_are_still_unresolved(system): + """Keep the exemption list from outliving the bugs it records.""" + stale = [] + for label, field in sorted(KNOWN_UNRESOLVED): + entry = ENTRIES_BY_LABEL.get(label) + if entry is None or field not in entry: + stale.append((label, field)) + elif field == "variable" and entry[field] in system.variables: + stale.append((label, field)) + elif field == "parameter_prefix" and parameter_prefix_resolves( + system, entry[field] + ): + stale.append((label, field)) + assert not stale, ( + "These KNOWN_UNRESOLVED keys now resolve or no longer exist; " + f"delete them: {stale}" + )