diff --git a/packages/populace-build/src/populace/build/__init__.py b/packages/populace-build/src/populace/build/__init__.py index 4ad0f76..1280396 100644 --- a/packages/populace-build/src/populace/build/__init__.py +++ b/packages/populace-build/src/populace/build/__init__.py @@ -70,6 +70,7 @@ def _assert_frame_compatible(version: str, required: tuple[int, int]) -> None: export_surface_gate, exported_nonzero_gate, formula_owned_export_gate, + input_column_coverage_gate, input_mass_parity_gate, macro_realism_gate, nonconstant_columns_gate, @@ -155,6 +156,7 @@ def _assert_frame_compatible(version: str, required: tuple[int, int]) -> None: "export_surface_gate", "exported_nonzero_gate", "formula_owned_export_gate", + "input_column_coverage_gate", "input_mass_parity_gate", "load_ledger_consumer_artifact", "macro_realism_gate", diff --git a/packages/populace-build/src/populace/build/gates.py b/packages/populace-build/src/populace/build/gates.py index 9caaa29..cbd374b 100644 --- a/packages/populace-build/src/populace/build/gates.py +++ b/packages/populace-build/src/populace/build/gates.py @@ -56,6 +56,7 @@ "export_surface_gate", "formula_owned_export_gate", "exported_nonzero_gate", + "input_column_coverage_gate", "input_mass_parity_gate", "macro_realism_gate", "nonconstant_columns_gate", @@ -1520,6 +1521,133 @@ def source_stage_input_coverage_gate( ) +def input_column_coverage_gate( + present_columns: Iterable[str], + *, + required_columns: Iterable[str], + degenerate_columns: Iterable[str] = (), + reviewed_exclusions: Mapping[str, str] | None = None, + name: str = "input_column_coverage", +) -> GateResult: + """Every declared-required input column is present AND carries signal. + + This is the release-blocking column-coverage contract of populace #368: a + release must persist every input column the reference eCPS exports, as a + real dataset key whose values are not all the engine default. It is the + generalization of ``assert_required_us_release_source_columns`` — which + guarded five SNAP/ACA/immigration columns — to the full eCPS input surface, + and it closes the gap the export-mass parity gate leaves open: parity only + covers the ~35 columns with a weighted-mass anchor, so an input the base + never imputed (SSI countable-resource assets, tips, overtime, education + credits, IRA/HSA — populace #340/#356/#361/#278) is *absent from the export + entirely* and every reform binding through it silently scores ~$0 while + every mass and parity gate still passes. + + A required column fails when it is absent from ``present_columns`` (the + engine defaults it at simulation time) or listed in ``degenerate_columns`` + (present as a key but every observed value equals the engine default, so + the export writer's default-broadcast makes it indistinguishable from + absence). Both are the same silent-zero failure and both are refused unless + the column carries a reviewed exclusion. + + Reviewed exclusions accept a known, tracked gap by name with a reason (and, + by convention, the tracking issue in the reason). They carry the #286 + "cannot rot" semantics: a reviewed exclusion whose column is now present + *and* non-degenerate is stale — the data caught up, so the column must be + promoted to a hard requirement — and fails the gate. An exclusion for a + column absent from this surface is dormant and only reported (release lines + persist different subsets). ``required_columns`` and + ``reviewed_exclusions`` must be disjoint: a column is either a hard + requirement or a tracked gap, never both. + + Args: + present_columns: Every input column the export persists as a key. + required_columns: Columns that must be present and non-degenerate. + degenerate_columns: The subset of present columns whose every observed + value equals the engine default (computed by the caller against the + engine's declared defaults). Columns not present are never here. + reviewed_exclusions: Column -> reason for tracked gaps allowed to be + absent or degenerate. Each needs a non-empty reason; a stale entry + fails the gate, a dormant entry is only reported. + name: Gate name for the manifest (defaults to + ``"input_column_coverage"``). + + Returns: + Pass iff every required column is present and non-degenerate, no + required column overlaps the exclusion register, and no exclusion is + stale. + + Raises: + ValueError: If a reviewed exclusion has an empty reason, or a column is + both required and a reviewed exclusion. + TypeError: If ``reviewed_exclusions`` is not a mapping. + """ + if not name: + raise ValueError("input column coverage gate name must be non-empty.") + exclusions = _reviewed_exclusion_reasons(reviewed_exclusions) + present = {str(column) for column in present_columns} + degenerate = {str(column) for column in degenerate_columns} & present + required = {str(column) for column in required_columns} + + both = sorted(required & set(exclusions)) + if both: + raise ValueError( + "A column cannot be both a hard requirement and a reviewed " + f"exclusion: {both}." + ) + + failures: list[str] = [] + missing: list[str] = [] + degenerate_required: list[str] = [] + for column in sorted(required): + if column not in present: + missing.append(column) + failures.append( + f"{column}: required eCPS input column is absent from the " + "export; the engine defaults it and every reform binding " + "through it scores ~$0. Impute it (carry it through the base " + "and selection) or record a reviewed exclusion with the " + "tracking issue." + ) + elif column in degenerate: + degenerate_required.append(column) + failures.append( + f"{column}: required eCPS input column is present but every " + "value equals the engine default; the export writer's " + "default-broadcast makes it indistinguishable from absence. " + "Impute it or record a reviewed exclusion with the tracking " + "issue." + ) + + signal_present = present - degenerate + stale = sorted(column for column in exclusions if column in signal_present) + if stale: + failures.append( + "Stale reviewed exclusions — the column carries signal now, " + f"promote it to a hard requirement: {stale}." + ) + dormant = sorted(column for column in exclusions if column not in present) + + return GateResult( + name=name, + passed=not failures, + failures=tuple(failures), + details={ + "required_columns": len(required), + "present_columns": len(present), + "missing": missing, + "degenerate_required": degenerate_required, + "reviewed_exclusions": { + column: reason + for column, reason in sorted(exclusions.items()) + if column not in dormant + }, + "stale_exclusions": stale, + "dormant_exclusions": dormant, + }, + ) + + def parity_gate( candidate_nonzero: Mapping[str, float], reference_nonzero: Mapping[str, float], diff --git a/packages/populace-build/src/populace/build/us/country_package.json b/packages/populace-build/src/populace/build/us/country_package.json index 10756d8..252887e 100644 --- a/packages/populace-build/src/populace/build/us/country_package.json +++ b/packages/populace-build/src/populace/build/us/country_package.json @@ -9,6 +9,7 @@ "fiscal_target_references.json", "obbba_reforms.json", "puf_aggregate_record_disaggregation.json", + "release_input_coverage_manifest.json", "soca_capital_gain_distribution_shares.json", "soi_baseline_levels.json", "source_stages.json", diff --git a/packages/populace-build/src/populace/build/us/release_input_coverage_manifest.json b/packages/populace-build/src/populace/build/us/release_input_coverage_manifest.json new file mode 100644 index 0000000..a105274 --- /dev/null +++ b/packages/populace-build/src/populace/build/us/release_input_coverage_manifest.json @@ -0,0 +1,714 @@ +{ + "columns": { + "age": { + "status": "required" + }, + "alimony_expense": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "alimony_income": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "attends_eligible_educational_institution_for_american_opportunity_credit": { + "issue": "PolicyEngine/populace#253", + "reason": "American Opportunity / tuition education-credit input essentially unimputed on the candidate frame (education credits understated).", + "status": "reviewed_exclusion" + }, + "auto_loan_balance": { + "issue": "PolicyEngine/populace#49", + "reason": "Vehicle-ownership / auto-loan input (populace#49 cites us-data #267/#281; see also #252 for the OBBBA auto-loan deduction); not yet on the frame.", + "status": "reviewed_exclusion" + }, + "auto_loan_interest": { + "issue": "PolicyEngine/populace#49", + "reason": "Vehicle-ownership / auto-loan input (populace#49 cites us-data #267/#281; see also #252 for the OBBBA auto-loan deduction); not yet on the frame.", + "status": "reviewed_exclusion" + }, + "bank_account_assets": { + "note": "SSI countable-resource asset input; required with NO reviewed exclusion per PolicyEngine/populace#368 so the gate fails until the asset stage is restored (Deliverable 2). Currently absent — this is the intended red gate.", + "status": "required" + }, + "block_geoid": { + "issue": "PolicyEngine/populace#38", + "reason": "Fine-grain geography code carried by the incumbent but not on the small national candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "bond_assets": { + "note": "SSI countable-resource asset input; required with NO reviewed exclusion per PolicyEngine/populace#368 so the gate fails until the asset stage is restored (Deliverable 2). Currently absent — this is the intended red gate.", + "status": "required" + }, + "business_is_sstb": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "casualty_loss": { + "issue": "PolicyEngine/populace#32", + "reason": "SPM MOOP / work-expense / deduction input (childcare-MOOP-work-expense family of #32); not yet on the candidate frame.", + "status": "reviewed_exclusion" + }, + "charitable_cash_donations": { + "status": "required" + }, + "charitable_non_cash_donations": { + "status": "required" + }, + "child_support_expense": { + "issue": "PolicyEngine/populace#32", + "reason": "HHS OCSE child-support received/paid SPM input; not yet on the frame.", + "status": "reviewed_exclusion" + }, + "child_support_received": { + "issue": "PolicyEngine/populace#32", + "reason": "HHS OCSE child-support received/paid SPM input; not yet on the frame.", + "status": "reviewed_exclusion" + }, + "congressional_district_geoid": { + "status": "required" + }, + "county_fips": { + "issue": "PolicyEngine/populace#38", + "reason": "Fine-grain geography code carried by the incumbent but not on the small national candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "cps_race": { + "issue": "PolicyEngine/populace#38", + "reason": "Demographic / occupation / labor-status descriptor input carried by the incumbent but not yet on the candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "detailed_occupation_recode": { + "issue": "PolicyEngine/populace#38", + "reason": "Demographic / occupation / labor-status descriptor input carried by the incumbent but not yet on the candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "disability_benefits": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "domestic_production_ald": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "educational_assistance": { + "issue": "PolicyEngine/populace#253", + "reason": "Educational-assistance income input tied to the education-credit surface; not yet on the candidate frame.", + "status": "reviewed_exclusion" + }, + "educator_expense": { + "issue": "PolicyEngine/populace#32", + "reason": "SPM MOOP / work-expense / deduction input (childcare-MOOP-work-expense family of #32); not yet on the candidate frame.", + "status": "reviewed_exclusion" + }, + "employer_sponsored_insurance_premiums": { + "issue": "PolicyEngine/populace#32", + "reason": "SPM MOOP / work-expense / deduction input (childcare-MOOP-work-expense family of #32); not yet on the candidate frame.", + "status": "reviewed_exclusion" + }, + "employment_income_before_lsr": { + "status": "required" + }, + "estate_income": { + "status": "required" + }, + "estate_income_would_be_qualified": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "farm_income": { + "status": "required" + }, + "farm_operations_income": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "farm_operations_income_would_be_qualified": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "farm_rent_income": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "farm_rent_income_would_be_qualified": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "financial_assistance": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "first_home_mortgage_balance": { + "status": "required" + }, + "first_home_mortgage_interest": { + "status": "required" + }, + "first_home_mortgage_origination_year": { + "status": "required" + }, + "has_american_opportunity_credit_1098_t_or_exception": { + "issue": "PolicyEngine/populace#253", + "reason": "American Opportunity / tuition education-credit input essentially unimputed on the candidate frame (education credits understated).", + "status": "reviewed_exclusion" + }, + "has_american_opportunity_credit_institution_ein": { + "issue": "PolicyEngine/populace#253", + "reason": "American Opportunity / tuition education-credit input essentially unimputed on the candidate frame (education credits understated).", + "status": "reviewed_exclusion" + }, + "has_champva_health_coverage_at_interview": { + "status": "required" + }, + "has_esi": { + "status": "required" + }, + "has_indian_health_service_coverage_at_interview": { + "status": "required" + }, + "has_marketplace_health_coverage": { + "status": "required" + }, + "has_marketplace_health_coverage_at_interview": { + "status": "required" + }, + "has_medicaid_health_coverage_at_interview": { + "status": "required" + }, + "has_never_worked": { + "issue": "PolicyEngine/populace#38", + "reason": "Demographic / occupation / labor-status descriptor input carried by the incumbent but not yet on the candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "has_non_marketplace_direct_purchase_health_coverage_at_interview": { + "status": "required" + }, + "has_other_means_tested_health_coverage_at_interview": { + "status": "required" + }, + "has_tricare_health_coverage_at_interview": { + "status": "required" + }, + "has_va_health_coverage_at_interview": { + "status": "required" + }, + "health_insurance_premiums_without_medicare_part_b": { + "status": "required" + }, + "health_savings_account_ald": { + "issue": "PolicyEngine/populace#32", + "reason": "SPM MOOP / work-expense / deduction input (childcare-MOOP-work-expense family of #32); not yet on the candidate frame.", + "status": "reviewed_exclusion" + }, + "home_mortgage_interest": { + "status": "required" + }, + "hourly_wage": { + "issue": "PolicyEngine/populace#242", + "reason": "Hours-worked / hourly-wage / labor input not yet carried through Populace US outputs (#242).", + "status": "reviewed_exclusion" + }, + "hours_worked_last_week": { + "status": "required" + }, + "household_vehicles_owned": { + "issue": "PolicyEngine/populace#49", + "reason": "Vehicle-ownership / auto-loan input (populace#49 cites us-data #267/#281; see also #252 for the OBBBA auto-loan deduction); not yet on the frame.", + "status": "reviewed_exclusion" + }, + "household_vehicles_value": { + "issue": "PolicyEngine/populace#49", + "reason": "Vehicle-ownership / auto-loan input (populace#49 cites us-data #267/#281; see also #252 for the OBBBA auto-loan deduction); not yet on the frame.", + "status": "reviewed_exclusion" + }, + "household_weight": { + "issue": "PolicyEngine/populace#38", + "reason": "Incumbent stored a household_weight input column; Populace carries weights as a typed Frame weight vector, not a stored input variable. Classify as reported-observation vs reviewed-exclusion under #38.", + "status": "reviewed_exclusion" + }, + "immigration_status_str": { + "status": "required" + }, + "investment_income_elected_form_4952": { + "issue": "PolicyEngine/populace#274", + "reason": "Capital-gains component detail (collectibles / unrecaptured 1250 / 4952 election / investment-interest) not yet split onto the candidate frame.", + "status": "reviewed_exclusion" + }, + "investment_interest_expense": { + "issue": "PolicyEngine/populace#274", + "reason": "Capital-gains component detail (collectibles / unrecaptured 1250 / 4952 election / investment-interest) not yet split onto the candidate frame.", + "status": "reviewed_exclusion" + }, + "is_blind": { + "status": "required" + }, + "is_computer_scientist": { + "issue": "PolicyEngine/populace#38", + "reason": "Demographic / occupation / labor-status descriptor input carried by the incumbent but not yet on the candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "is_disabled": { + "status": "required" + }, + "is_enrolled_at_least_half_time_for_american_opportunity_credit": { + "issue": "PolicyEngine/populace#253", + "reason": "American Opportunity / tuition education-credit input essentially unimputed on the candidate frame (education credits understated).", + "status": "reviewed_exclusion" + }, + "is_executive_administrative_professional": { + "issue": "PolicyEngine/populace#38", + "reason": "Demographic / occupation / labor-status descriptor input carried by the incumbent but not yet on the candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "is_farmer_fisher": { + "issue": "PolicyEngine/populace#38", + "reason": "Demographic / occupation / labor-status descriptor input carried by the incumbent but not yet on the candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "is_female": { + "status": "required" + }, + "is_full_time_college_student": { + "status": "required" + }, + "is_hispanic": { + "issue": "PolicyEngine/populace#38", + "reason": "Demographic / occupation / labor-status descriptor input carried by the incumbent but not yet on the candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "is_household_head": { + "issue": "PolicyEngine/populace#38", + "reason": "Demographic / occupation / labor-status descriptor input carried by the incumbent but not yet on the candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "is_military": { + "issue": "PolicyEngine/populace#38", + "reason": "Demographic / occupation / labor-status descriptor input carried by the incumbent but not yet on the candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "is_paid_hourly": { + "issue": "PolicyEngine/populace#242", + "reason": "Hours-worked / hourly-wage / labor input not yet carried through Populace US outputs (#242).", + "status": "reviewed_exclusion" + }, + "is_pregnant": { + "status": "required" + }, + "is_pursuing_credential_for_american_opportunity_credit": { + "issue": "PolicyEngine/populace#253", + "reason": "American Opportunity / tuition education-credit input essentially unimputed on the candidate frame (education credits understated).", + "status": "reviewed_exclusion" + }, + "is_separated": { + "issue": "PolicyEngine/populace#38", + "reason": "Demographic / occupation / labor-status descriptor input carried by the incumbent but not yet on the candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "is_surviving_spouse": { + "issue": "PolicyEngine/populace#38", + "reason": "Demographic / occupation / labor-status descriptor input carried by the incumbent but not yet on the candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "is_union_member_or_covered": { + "issue": "PolicyEngine/populace#242", + "reason": "Hours-worked / hourly-wage / labor input not yet carried through Populace US outputs (#242).", + "status": "reviewed_exclusion" + }, + "is_unmarried_partner_of_household_head": { + "issue": "PolicyEngine/populace#38", + "reason": "Demographic / occupation / labor-status descriptor input carried by the incumbent but not yet on the candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "is_wic_at_nutritional_risk": { + "issue": "PolicyEngine/populace#312", + "reason": "WIC take-up / eligibility-screen input the incumbent seeds; not yet produced by Populace (take-up family).", + "status": "reviewed_exclusion" + }, + "keogh_distributions": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "long_term_capital_gains_before_response": { + "status": "required" + }, + "long_term_capital_gains_on_collectibles": { + "issue": "PolicyEngine/populace#274", + "reason": "Capital-gains component detail (collectibles / unrecaptured 1250 / 4952 election / investment-interest) not yet split onto the candidate frame.", + "status": "reviewed_exclusion" + }, + "miscellaneous_income": { + "status": "required" + }, + "net_worth": { + "issue": "PolicyEngine/populace#49", + "reason": "SCF-derived household asset/net-worth stock; not yet imputed onto the candidate frame (wealth imputation backlog).", + "status": "reviewed_exclusion" + }, + "non_qualified_dividend_income": { + "status": "required" + }, + "non_sch_d_capital_gains": { + "status": "required" + }, + "other_health_insurance_premiums": { + "issue": "PolicyEngine/populace#32", + "reason": "SPM MOOP / work-expense / deduction input (childcare-MOOP-work-expense family of #32); not yet on the candidate frame.", + "status": "reviewed_exclusion" + }, + "other_medical_expenses": { + "status": "required" + }, + "over_the_counter_health_expenses": { + "status": "required" + }, + "own_children_in_household": { + "status": "required" + }, + "partnership_s_corp_income_would_be_qualified": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "pre_subsidy_rent": { + "issue": "PolicyEngine/populace#32", + "reason": "HUD housing-assistance / tenure SPM input feeding capped housing subsidy; not yet on the candidate frame.", + "status": "reviewed_exclusion" + }, + "previous_year_income_available": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "qualified_bdc_income": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "qualified_dividend_income": { + "status": "required" + }, + "qualified_reit_and_ptp_income": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "qualified_tuition_expenses": { + "issue": "PolicyEngine/populace#253", + "reason": "American Opportunity / tuition education-credit input essentially unimputed on the candidate frame (#253).", + "status": "reviewed_exclusion" + }, + "real_estate_taxes": { + "status": "required" + }, + "receives_housing_assistance": { + "issue": "PolicyEngine/populace#32", + "reason": "HUD housing-assistance / tenure SPM input feeding capped housing subsidy; not yet on the candidate frame.", + "status": "reviewed_exclusion" + }, + "rental_income": { + "status": "required" + }, + "rental_income_would_be_qualified": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "salt_refund_income": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "selected_marketplace_plan_benchmark_ratio": { + "status": "required" + }, + "self_employment_income_before_lsr": { + "status": "required" + }, + "self_employment_income_last_year": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "self_employment_income_would_be_qualified": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "short_term_capital_gains": { + "status": "required" + }, + "social_security_dependents": { + "status": "required" + }, + "social_security_disability": { + "status": "required" + }, + "social_security_retirement": { + "status": "required" + }, + "social_security_survivors": { + "status": "required" + }, + "spm_unit_energy_subsidy": { + "issue": "PolicyEngine/populace#32", + "reason": "SPM energy-subsidy (LIHEAP) resource input; not yet on the candidate frame.", + "status": "reviewed_exclusion" + }, + "spm_unit_pre_subsidy_childcare_expenses": { + "issue": "PolicyEngine/populace#32", + "reason": "SPM childcare-expense input; not yet on the candidate frame.", + "status": "reviewed_exclusion" + }, + "spm_unit_tenure_type": { + "issue": "PolicyEngine/populace#32", + "reason": "HUD housing-assistance / tenure SPM input feeding capped housing subsidy; not yet on the candidate frame.", + "status": "reviewed_exclusion" + }, + "ssn_card_type": { + "status": "required" + }, + "sstb_self_employment_income_before_lsr": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "sstb_self_employment_income_would_be_qualified": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "sstb_unadjusted_basis_qualified_property": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "sstb_w2_wages_from_qualified_business": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "state_fips": { + "status": "required" + }, + "stock_assets": { + "note": "SSI countable-resource asset input; required with NO reviewed exclusion per PolicyEngine/populace#368 so the gate fails until the asset stage is restored (Deliverable 2). Currently absent — this is the intended red gate.", + "status": "required" + }, + "student_loan_interest": { + "status": "required" + }, + "survivor_benefits": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "takes_up_aca_if_eligible": { + "status": "required" + }, + "takes_up_dc_ptc": { + "issue": "PolicyEngine/populace#312", + "reason": "Stochastic take-up flag the incumbent seeds so take-up-gated programs do not ship at 100% participation; Populace does not yet produce it.", + "status": "reviewed_exclusion" + }, + "takes_up_early_head_start_if_eligible": { + "issue": "PolicyEngine/populace#312", + "reason": "Stochastic take-up flag the incumbent seeds so take-up-gated programs do not ship at 100% participation; Populace does not yet produce it.", + "status": "reviewed_exclusion" + }, + "takes_up_eitc": { + "status": "required" + }, + "takes_up_head_start_if_eligible": { + "issue": "PolicyEngine/populace#312", + "reason": "Stochastic take-up flag the incumbent seeds so take-up-gated programs do not ship at 100% participation; Populace does not yet produce it.", + "status": "reviewed_exclusion" + }, + "takes_up_housing_assistance_if_eligible": { + "issue": "PolicyEngine/populace#312", + "reason": "Stochastic take-up flag the incumbent seeds so take-up-gated programs do not ship at 100% participation; Populace does not yet produce it.", + "status": "reviewed_exclusion" + }, + "takes_up_medicaid_if_eligible": { + "status": "required" + }, + "takes_up_medicare_if_eligible": { + "issue": "PolicyEngine/populace#312", + "reason": "Stochastic take-up flag the incumbent seeds so take-up-gated programs do not ship at 100% participation; Populace does not yet produce it.", + "status": "reviewed_exclusion" + }, + "takes_up_snap_if_eligible": { + "status": "required" + }, + "takes_up_ssi_if_eligible": { + "issue": "PolicyEngine/populace#312", + "reason": "Stochastic take-up flag the incumbent seeds so take-up-gated programs do not ship at 100% participation; Populace does not yet produce it.", + "status": "reviewed_exclusion" + }, + "takes_up_tanf_if_eligible": { + "status": "required" + }, + "tax_exempt_interest_income": { + "status": "required" + }, + "tax_exempt_ira_distributions": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "tax_exempt_private_pension_income": { + "status": "required" + }, + "taxable_401k_distributions": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "taxable_403b_distributions": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "taxable_interest_income": { + "status": "required" + }, + "taxable_ira_distributions": { + "status": "required" + }, + "taxable_private_pension_income": { + "status": "required" + }, + "taxable_sep_distributions": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "tenure_type": { + "issue": "PolicyEngine/populace#32", + "reason": "HUD housing-assistance / tenure SPM input feeding capped housing subsidy; not yet on the candidate frame.", + "status": "reviewed_exclusion" + }, + "tip_income": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "tract_geoid": { + "issue": "PolicyEngine/populace#38", + "reason": "Fine-grain geography code carried by the incumbent but not on the small national candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "treasury_tipped_occupation_code": { + "issue": "PolicyEngine/populace#38", + "reason": "Residual US tax-input / income-source layer not yet sourced onto the candidate frame; tracked as remaining input-layer work in #38.", + "status": "reviewed_exclusion" + }, + "unadjusted_basis_qualified_property": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "unemployment_compensation": { + "status": "required" + }, + "unrecaptured_section_1250_gain": { + "issue": "PolicyEngine/populace#274", + "reason": "Capital-gains component detail (collectibles / unrecaptured 1250 / 4952 election / investment-interest) not yet split onto the candidate frame.", + "status": "reviewed_exclusion" + }, + "unreimbursed_business_employee_expenses": { + "issue": "PolicyEngine/populace#32", + "reason": "SPM MOOP / work-expense / deduction input (childcare-MOOP-work-expense family of #32); not yet on the candidate frame.", + "status": "reviewed_exclusion" + }, + "veterans_benefits": { + "status": "required" + }, + "w2_wages_from_qualified_business": { + "issue": "PolicyEngine/populace#298", + "reason": "Section 199A QBI / passthrough-qualification input; not yet on the candidate frame (QBI base already off vs targets, #298).", + "status": "reviewed_exclusion" + }, + "weekly_hours_worked_before_lsr": { + "status": "required" + }, + "weeks_unemployed": { + "issue": "PolicyEngine/populace#38", + "reason": "Demographic / occupation / labor-status descriptor input carried by the incumbent but not yet on the candidate frame; remaining input-layer work (#38).", + "status": "reviewed_exclusion" + }, + "workers_compensation": { + "issue": "PolicyEngine/populace#32", + "reason": "Workers' compensation benefit SPM input; not yet on the candidate frame.", + "status": "reviewed_exclusion" + }, + "would_claim_wic": { + "issue": "PolicyEngine/populace#312", + "reason": "WIC take-up / eligibility-screen input the incumbent seeds; not yet produced by Populace (take-up family).", + "status": "reviewed_exclusion" + }, + "would_file_taxes_voluntarily": { + "issue": "PolicyEngine/populace#312", + "reason": "Voluntary-filing take-up flag the incumbent seeds; not yet produced by Populace (take-up family).", + "status": "reviewed_exclusion" + } + }, + "counts": { + "required": 65, + "reviewed_exclusion": 93, + "total": 158 + }, + "derivation": "Required surface = ecps_parity_reference.json populated layers (input columns the pinned, sha-verified reference eCPS populates). status='reviewed_exclusion' for ecps_parity_known_gaps.json entries (reason+issue from that register); EXCEPT the SSI countable-resource asset inputs (bank_account_assets, stock_assets, bond_assets), which are status='required' with NO exclusion per PolicyEngine/populace#368 so the gate fails on today's artifacts and asset restoration (Deliverable 2) turns it green. All other populated layers are 'required'. Regenerate with tools/build_us_release_input_coverage_manifest.py.", + "description": "Declared full-coverage contract for a US release: every input column the reference eCPS exports must be persisted as a key with non-default signal, or carry a reviewed exclusion. Enforced as a hard release gate (populace.build.us_runtime.release_input_coverage) that generalizes assert_required_us_release_source_columns from 5 columns to the full eCPS input surface.", + "issue": "PolicyEngine/populace#368", + "reference": { + "derived_from": "ecps_parity_reference.json", + "filename": "enhanced_cps_2024.h5", + "note": "Column surface derived from the populated input layers of the pinned, sha-verified reference eCPS recorded in ecps_parity_reference.json (the launch parity contract, PolicyEngine/populace#313) — the single allow-listed record of the incumbent coordinates. This manifest names no data package.", + "period": "2024", + "revision": "21280dca5995e978d706740a8a4b9b7860cfd7b6", + "sha256": "0a6b961ad363a421bde99f2c8e5d8f20370bcba45fd303050537a25bdd805b14", + "vintage": "2024" + }, + "reform_coverage_probes": [ + { + "binding_inputs": [ + "bank_account_assets", + "stock_assets", + "bond_assets" + ], + "budget_measure": "ssi", + "effect_direction": "reform_minus_baseline", + "id": "ssi_asset_limit_10k_20k", + "issue": "PolicyEngine/populace#356", + "min_abs_effect": 1000000000.0, + "name": "SSI asset limits raised to $10k individual / $20k couple", + "parameter_changes": { + "gov.ssa.ssi.eligibility.resources.limit.couple": { + "2024-01-01.2100-12-31": 20000 + }, + "gov.ssa.ssi.eligibility.resources.limit.individual": { + "2024-01-01.2100-12-31": 10000 + } + }, + "reason": "Raising the SSI resource limit is a pure relaxation that only changes who passes meets_ssi_resource_test, which compares ssi_countable_resources = bank_account_assets + stock_assets + bond_assets against the limit. With those asset inputs absent, countable resources are 0 for every record, everyone already passes the resource test, and raising the limit scores exactly $0. Dense-native reference: +$1.6B at $10k/$20k, ~+$16.1B with no limit (PolicyEngine/populace#356)." + } + ], + "schema_version": 1, + "ssi_countable_resource_assets": [ + "bank_account_assets", + "stock_assets", + "bond_assets" + ] +} diff --git a/packages/populace-build/src/populace/build/us_runtime/__init__.py b/packages/populace-build/src/populace/build/us_runtime/__init__.py index 93bfaa9..115e43d 100644 --- a/packages/populace-build/src/populace/build/us_runtime/__init__.py +++ b/packages/populace-build/src/populace/build/us_runtime/__init__.py @@ -196,6 +196,9 @@ support_clone_index_column, support_source_id_column, ) +from populace.build.us_runtime.reform_coverage_smoke import ( + us_reform_coverage_smoke_gate, +) from populace.build.us_runtime.reform_validation import ( REFORM_VALIDATION_SCHEMA_VERSION, ReformValidationSpec, @@ -205,6 +208,19 @@ reform_validation_payload, write_reform_validation, ) +from populace.build.us_runtime.release_input_coverage import ( + SSI_COUNTABLE_RESOURCE_ASSETS, + US_RELEASE_INPUT_COVERAGE_RESOURCE, + ReformCoverageProbe, + ReleaseInputColumn, + ReleaseInputCoverageManifest, + assert_release_input_coverage_manifest_current, + load_release_input_coverage_manifest, + us_release_input_coverage_gate, + us_release_input_coverage_required_columns, + us_release_input_coverage_reviewed_exclusions, + us_release_reform_coverage_probes, +) from populace.build.us_runtime.snap_discretionary_exemption import ( US_SNAP_DISCRETIONARY_EXEMPTION_NONCONSTANT_PERSON_COLUMNS, US_SNAP_DISCRETIONARY_EXEMPTION_OUTPUT_COLUMN, @@ -452,6 +468,18 @@ "US_VALIDATION_PROVISION_INPUT_LEAVES", "ValidationInputLeaf", "assert_validation_leaf_registry_current", + "SSI_COUNTABLE_RESOURCE_ASSETS", + "US_RELEASE_INPUT_COVERAGE_RESOURCE", + "ReformCoverageProbe", + "ReleaseInputColumn", + "ReleaseInputCoverageManifest", + "assert_release_input_coverage_manifest_current", + "load_release_input_coverage_manifest", + "us_release_input_coverage_gate", + "us_release_input_coverage_required_columns", + "us_release_input_coverage_reviewed_exclusions", + "us_release_reform_coverage_probes", + "us_reform_coverage_smoke_gate", "write_us_source_coverage_diagnostics", "support_channel_column", "support_clone_index_column", diff --git a/packages/populace-build/src/populace/build/us_runtime/reform_coverage_smoke.py b/packages/populace-build/src/populace/build/us_runtime/reform_coverage_smoke.py new file mode 100644 index 0000000..053013b --- /dev/null +++ b/packages/populace-build/src/populace/build/us_runtime/reform_coverage_smoke.py @@ -0,0 +1,129 @@ +"""Reform-coverage smoke: a bound reform scoring $0 fails the release (#368). + +The column-coverage gate proves the required input *keys* are present and carry +signal. This is the end-to-end companion: a pinned set of reforms that +mechanically bind through named input leaves must move their budget measure on +the export. If a probe scores ~$0, the leaves it binds through are absent or +degenerate — the silent-zero failure proven through the engine, not just the +column surface. + +First probe: the SSI resource limit raised to $10k individual / $20k couple. It +binds only through ``ssi_countable_resources`` (= ``bank_account_assets`` + +``stock_assets`` + ``bond_assets``); with those absent, countable resources are +0 for every record, everyone already passes the resource test, and the +relaxation scores exactly $0. Until Deliverable 2 restores the asset stage this +probe fails by design — that is the gate doing its job. + +The gate takes an injected ``simulate(reform) -> simulation`` (the same seam as +:mod:`populace.build.us_runtime.reform_validation`), so it unit-tests without +policyengine-us and runs live against the written release H5 in the build via +:func:`populace.build.us_runtime.reform_validation.default_simulate_factory`. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import Any + +from populace.build.gates import GateResult +from populace.build.us_runtime.release_input_coverage import ( + ReformCoverageProbe, + us_release_reform_coverage_probes, +) + +__all__ = [ + "us_reform_coverage_smoke_gate", +] + +# simulate(reform_or_None) -> object with .calculate(measure, period).sum() +SimulateFn = Callable[[Any], Any] + + +def _weighted_total(simulation: Any, measure: str, period: int) -> float: + """Weighted population total (policyengine-us MicroSeries .sum() is weighted).""" + return float(simulation.calculate(measure, period).sum()) + + +def _build_reform(parameter_changes: dict[str, Any]) -> Any: + from policyengine_core.reforms import Reform + + return Reform.from_dict(parameter_changes, country_id="us") + + +def us_reform_coverage_smoke_gate( + *, + simulate: SimulateFn, + probes: Iterable[ReformCoverageProbe] | None = None, + period: int = 2024, + name: str = "us_reform_coverage_smoke", +) -> GateResult: + """Score each pinned probe on the export; a ~$0 bound reform fails. + + For each probe, the budget-measure change (reform vs baseline, signed by + ``effect_direction``) must have magnitude at least ``min_abs_effect``. A + smaller magnitude means the reform did not bind — its ``binding_inputs`` are + absent/degenerate on the export — and fails the release. + + Args: + simulate: ``simulate(None)`` builds the baseline; ``simulate(reform)`` + builds the reformed simulation. Each result answers + ``.calculate(measure, period).sum()`` as a weighted total. + probes: Probes to run. Defaults to the shipped manifest's probe set. + period: The period to score at. + name: Gate name for the manifest. + + Returns: + The reform-coverage smoke gate result. Passes iff every probe scores at + least its ``min_abs_effect`` in magnitude. + """ + probes = tuple( + probes if probes is not None else us_release_reform_coverage_probes() + ) + if not probes: + raise ValueError( + "reform-coverage smoke gate needs at least one probe; a probe-less " + "gate would pass vacuously." + ) + + baseline = simulate(None) + failures: list[str] = [] + results: dict[str, Any] = {} + for probe in probes: + reform = _build_reform(dict(probe.parameter_changes)) + reformed = simulate(reform) + baseline_total = _weighted_total(baseline, probe.budget_measure, period) + reform_total = _weighted_total(reformed, probe.budget_measure, period) + if probe.effect_direction == "baseline_minus_reform": + effect = baseline_total - reform_total + else: + effect = reform_total - baseline_total + results[probe.id] = { + "name": probe.name, + "budget_measure": probe.budget_measure, + "baseline_total": baseline_total, + "reform_total": reform_total, + "effect": effect, + "min_abs_effect": probe.min_abs_effect, + "binding_inputs": list(probe.binding_inputs), + "issue": probe.issue, + "passed": abs(effect) >= probe.min_abs_effect, + } + if abs(effect) < probe.min_abs_effect: + failures.append( + f"{probe.id}: '{probe.name}' scores {effect:+,.0f} on " + f"{probe.budget_measure} (|effect| < ${probe.min_abs_effect:,.0f}) " + "— the reform did not bind, so its input leaves " + f"{list(probe.binding_inputs)} are absent or degenerate on the " + f"export. {probe.reason} Restore them ({probe.issue})." + ) + + return GateResult( + name=name, + passed=not failures, + failures=tuple(failures), + details={ + "period": int(period), + "probes": len(probes), + "results": results, + }, + ) diff --git a/packages/populace-build/src/populace/build/us_runtime/release_input_coverage.py b/packages/populace-build/src/populace/build/us_runtime/release_input_coverage.py new file mode 100644 index 0000000..9123ee9 --- /dev/null +++ b/packages/populace-build/src/populace/build/us_runtime/release_input_coverage.py @@ -0,0 +1,512 @@ +"""US release input-column coverage: the eCPS export surface as a HARD gate. + +populace #368. The launch failure this closes: an SSI asset-limit reform scored +exactly $0 on a certified default because ``ssi_countable_resources`` is 0 for +every record — the asset input columns were dropped at the base pool and are +absent from the export. This is the #340/#356/#361/#278 family: input columns +the reference eCPS exports that the sparse national release never persists, so +the engine silently defaults them and every reform binding through them scores +$0 while export-mass parity (a ~35-column weighted-mass check) still passes. +Column *mass* parity is not column *coverage*. + +This module is the coverage contract. It declares, in a versioned in-repo +manifest, every input column the pinned reference eCPS populates, each with a +status: + +- ``required`` — the release must persist it as a key with non-default signal; +- ``reviewed_exclusion`` — a documented, issue-tracked gap allowed to be + absent/degenerate for now (the debt ledger that shrinks as inputs are + restored). + +:func:`us_release_input_coverage_gate` enforces it on the export frame and, wired +into the release tool, hard-fails the export before ``write_dataset`` exactly +like the export-mass parity gate. It generalizes +:func:`populace.build.us_runtime.l0_refit_export.assert_required_us_release_source_columns` +from five SNAP/ACA/immigration columns to the full eCPS input surface. + +Per #368 ("it must be an actual gate"), the three SSI countable-resource asset +inputs (``bank_account_assets``, ``stock_assets``, ``bond_assets``) are +``required`` with NO reviewed exclusion even though they are currently absent, so +the gate ships RED for today's artifacts; asset restoration (Deliverable 2) turns +it green. A gate that fails the current default is the point, not a bug. + +:func:`assert_release_input_coverage_manifest_current` proves the manifest against +the pinned eCPS surface and the live PolicyEngine-US graph, so the register +cannot silently rot: it must cover exactly the reference eCPS populated layers, +every declared column must be a real engine input leaf, and the SSI asset inputs +must stay hard requirements. +""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from dataclasses import dataclass, field +from importlib.resources import files +from pathlib import Path +from typing import Any + +from populace.build.gates import GateResult, input_column_coverage_gate + +__all__ = [ + "US_RELEASE_INPUT_COVERAGE_RESOURCE", + "ReformCoverageProbe", + "ReleaseInputColumn", + "ReleaseInputCoverageManifest", + "assert_release_input_coverage_manifest_current", + "load_release_input_coverage_manifest", + "us_release_input_coverage_gate", + "us_release_input_coverage_required_columns", + "us_release_input_coverage_reviewed_exclusions", + "us_release_reform_coverage_probes", +] + +US_RELEASE_INPUT_COVERAGE_RESOURCE = "release_input_coverage_manifest.json" + +_US_PACKAGE = "populace.build.us" +_ECPS_PARITY_REFERENCE_RESOURCE = "ecps_parity_reference.json" + +REQUIRED_STATUS = "required" +REVIEWED_EXCLUSION_STATUS = "reviewed_exclusion" +_VALID_STATUSES = frozenset({REQUIRED_STATUS, REVIEWED_EXCLUSION_STATUS}) + +#: The ``adds`` list of PolicyEngine-US ``ssi_countable_resources``: the asset +#: leaves SSI eligibility keys on. Per #368 these ship as hard requirements with +#: NO reviewed exclusion, so the coverage gate and the SSI reform probe both fail +#: until the asset stage is restored. +SSI_COUNTABLE_RESOURCE_ASSETS = ( + "bank_account_assets", + "stock_assets", + "bond_assets", +) + + +@dataclass(frozen=True) +class ReleaseInputColumn: + """One declared coverage-contract column. + + Attributes: + name: The PolicyEngine-US input variable the release must persist. + status: ``"required"`` (present + non-degenerate) or + ``"reviewed_exclusion"`` (a tracked gap allowed to be absent). + reason: Why the gap is accepted — required and non-empty for a reviewed + exclusion, empty for a hard requirement. + issue: The tracking issue owning the gap's closure — required for a + reviewed exclusion. + note: Optional free-text annotation (e.g. why an absent column is still + a hard requirement). + """ + + name: str + status: str + reason: str = "" + issue: str = "" + note: str = "" + + def __post_init__(self) -> None: + if not self.name: + raise ValueError("ReleaseInputColumn.name is required.") + if self.status not in _VALID_STATUSES: + raise ValueError( + f"{self.name}: status must be one of {sorted(_VALID_STATUSES)}, " + f"got {self.status!r}." + ) + if self.status == REVIEWED_EXCLUSION_STATUS: + if not self.reason: + raise ValueError( + f"{self.name}: a reviewed exclusion needs a reason " + "(an undocumented exclusion is a silent zero with extra steps)." + ) + if not self.issue: + raise ValueError( + f"{self.name}: a reviewed exclusion needs a tracking issue." + ) + + +@dataclass(frozen=True) +class ReformCoverageProbe: + """A pinned reform whose $0 score on the export signals a coverage hole. + + A reform that mechanically binds through named input leaves must move its + budget measure. If it scores ~$0 on the release, the leaves it binds through + are absent/degenerate — the same silent-zero failure the column gate catches, + proven end-to-end against the engine. + + Attributes: + id: Stable probe id. + name: Human-readable reform description. + parameter_changes: ``Reform.from_dict`` payload (country ``us``). + budget_measure: The variable whose weighted total change is scored. + effect_direction: ``"reform_minus_baseline"`` or + ``"baseline_minus_reform"``. + binding_inputs: The input leaves the reform binds through; named in the + failure so the fix target is explicit. + min_abs_effect: The reform fails the smoke gate when + ``abs(effect) < min_abs_effect`` — a floor above simulation noise + and far below the plausible effect, so only a structural zero fails. + reason: Why a $0 here means a coverage hole (the mechanism). + issue: Tracking issue for the restoration work. + """ + + id: str + name: str + parameter_changes: Mapping[str, Any] + budget_measure: str + binding_inputs: tuple[str, ...] + min_abs_effect: float + reason: str + issue: str + effect_direction: str = "reform_minus_baseline" + + def __post_init__(self) -> None: + if not self.id: + raise ValueError("ReformCoverageProbe.id is required.") + if not self.parameter_changes: + raise ValueError(f"{self.id}: parameter_changes is required.") + if not self.budget_measure: + raise ValueError(f"{self.id}: budget_measure is required.") + if self.effect_direction not in { + "reform_minus_baseline", + "baseline_minus_reform", + }: + raise ValueError( + f"{self.id}: effect_direction must be 'reform_minus_baseline' or " + "'baseline_minus_reform'." + ) + if not (self.min_abs_effect > 0): + raise ValueError(f"{self.id}: min_abs_effect must be positive.") + + +@dataclass(frozen=True) +class ReleaseInputCoverageManifest: + """The full parsed coverage contract. + + Attributes: + reference: Provenance of the reference eCPS the surface is derived from. + columns: Every declared column, in name order. + probes: The pinned reform-coverage probes. + schema_version: Manifest schema version. + """ + + reference: Mapping[str, str] + columns: tuple[ReleaseInputColumn, ...] + probes: tuple[ReformCoverageProbe, ...] = () + schema_version: int = 1 + _by_name: dict[str, ReleaseInputColumn] = field( + default_factory=dict, repr=False, compare=False + ) + + def __post_init__(self) -> None: + by_name: dict[str, ReleaseInputColumn] = {} + for column in self.columns: + if column.name in by_name: + raise ValueError(f"Duplicate manifest column {column.name!r}.") + by_name[column.name] = column + object.__setattr__(self, "_by_name", by_name) + + @property + def declared_columns(self) -> frozenset[str]: + """Every column the manifest declares (required + reviewed).""" + return frozenset(self._by_name) + + @property + def required_columns(self) -> frozenset[str]: + """Columns that must be present and non-degenerate.""" + return frozenset( + column.name for column in self.columns if column.status == REQUIRED_STATUS + ) + + @property + def reviewed_exclusions(self) -> dict[str, str]: + """Reviewed-exclusion columns as ``name -> reason``.""" + return { + column.name: column.reason + for column in self.columns + if column.status == REVIEWED_EXCLUSION_STATUS + } + + +def _resource_text(resource: str) -> str: + candidate = Path(resource) + if candidate.exists(): + return candidate.read_text(encoding="utf-8") + return files(_US_PACKAGE).joinpath(resource).read_text(encoding="utf-8") + + +def _resource_payload(resource: str) -> Mapping[str, Any]: + raw = json.loads(_resource_text(resource)) + if not isinstance(raw, Mapping): + raise ValueError(f"{resource}: expected a JSON object.") + return raw + + +def load_release_input_coverage_manifest( + resource: str = US_RELEASE_INPUT_COVERAGE_RESOURCE, +) -> ReleaseInputCoverageManifest: + """Load and validate the release input-column coverage manifest. + + Raises: + ValueError: If the payload shape is wrong, a column has an unknown + status, a reviewed exclusion is missing its reason or issue, or the + declared column set is empty (a silently-empty manifest would make + the coverage gate vacuous). + """ + payload = _resource_payload(resource) + + raw_reference = payload.get("reference") + if not isinstance(raw_reference, Mapping): + raise ValueError(f"{resource}: 'reference' must be a JSON object.") + reference = {str(key): str(value) for key, value in raw_reference.items()} + + raw_columns = payload.get("columns") + if not isinstance(raw_columns, Mapping) or not raw_columns: + raise ValueError( + f"{resource}: 'columns' must be a non-empty JSON object; a silently " + "empty manifest would make the coverage gate vacuous." + ) + columns: list[ReleaseInputColumn] = [] + for name, entry in sorted(raw_columns.items()): + if not isinstance(entry, Mapping): + raise ValueError(f"{resource}: column {name!r} must be a JSON object.") + columns.append( + ReleaseInputColumn( + name=str(name), + status=str(entry.get("status", "")), + reason=str(entry.get("reason", "")), + issue=str(entry.get("issue", "")), + note=str(entry.get("note", "")), + ) + ) + + probes: list[ReformCoverageProbe] = [] + for raw_probe in payload.get("reform_coverage_probes", []) or []: + if not isinstance(raw_probe, Mapping): + raise ValueError(f"{resource}: each reform probe must be a JSON object.") + parameter_changes = raw_probe.get("parameter_changes") + if not isinstance(parameter_changes, Mapping): + raise ValueError( + f"{resource}: probe {raw_probe.get('id')!r} parameter_changes " + "must be a JSON object." + ) + probes.append( + ReformCoverageProbe( + id=str(raw_probe.get("id", "")), + name=str(raw_probe.get("name", "")), + parameter_changes=dict(parameter_changes), + budget_measure=str(raw_probe.get("budget_measure", "")), + binding_inputs=tuple( + str(leaf) for leaf in raw_probe.get("binding_inputs", ()) + ), + min_abs_effect=float(raw_probe.get("min_abs_effect", 0.0)), + reason=str(raw_probe.get("reason", "")), + issue=str(raw_probe.get("issue", "")), + effect_direction=str( + raw_probe.get("effect_direction", "reform_minus_baseline") + ), + ) + ) + + schema_version = payload.get("schema_version", 1) + if not isinstance(schema_version, int): + raise ValueError(f"{resource}: 'schema_version' must be an integer.") + + return ReleaseInputCoverageManifest( + reference=reference, + columns=tuple(columns), + probes=tuple(probes), + schema_version=schema_version, + ) + + +def us_release_input_coverage_required_columns() -> frozenset[str]: + """The hard-required input columns from the shipped manifest.""" + return load_release_input_coverage_manifest().required_columns + + +def us_release_input_coverage_reviewed_exclusions() -> dict[str, str]: + """The reviewed-exclusion register from the shipped manifest.""" + return load_release_input_coverage_manifest().reviewed_exclusions + + +def us_release_reform_coverage_probes() -> tuple[ReformCoverageProbe, ...]: + """The pinned reform-coverage probes from the shipped manifest.""" + return load_release_input_coverage_manifest().probes + + +def _degenerate_columns( + column_values: Mapping[str, Any], + engine: Any, +) -> set[str]: + """Columns whose every observed value equals the engine default. + + Reuses :func:`populace.build.gates.default_valued_columns_gate`'s exact + degeneracy classification (same scalar/enum comparison the degenerate-input + gate uses) so "present but default" is judged identically everywhere. + """ + from populace.build.gates import default_valued_columns_gate + + defaults = engine.default_values(sorted(column_values)) + classification = default_valued_columns_gate(column_values, defaults) + return set(classification.details["default_valued_columns"]) + + +def us_release_input_coverage_gate( + frame: Any, + engine: Any, + *, + manifest: ReleaseInputCoverageManifest | None = None, +) -> GateResult: + """Build the named US release input-column coverage gate for an export frame. + + Every ``required`` manifest column must be persisted by ``frame`` as a key + with at least one non-default value; a required column that is absent or + degenerate (every value the engine default) fails the gate, and a reviewed + exclusion whose column now carries signal is stale and fails too (#286 + cannot-rot). Run on the calibrated export frame just before + ``write_dataset``, it hard-fails the release like the export-mass parity gate. + + Args: + frame: The export :class:`populace.frame.Frame`. + engine: A ``PolicyEngineUSEngine`` (for input-variable defaults). + manifest: Override the shipped manifest (tests). + + Returns: + The ``"us_release_input_coverage"`` gate result. + """ + manifest = manifest or load_release_input_coverage_manifest() + required = manifest.required_columns + reviewed = manifest.reviewed_exclusions + relevant = required | set(reviewed) + + present_values: dict[str, Any] = {} + for entity in frame.entities: + table = frame.table(entity) + for column in table.columns: + if column in relevant and column not in present_values: + present_values[column] = table[column].to_numpy() + + degenerate = _degenerate_columns(present_values, engine) + return input_column_coverage_gate( + present_values.keys(), + required_columns=required, + degenerate_columns=degenerate, + reviewed_exclusions=reviewed, + name="us_release_input_coverage", + ) + + +def _coverage_engine() -> Any | None: + """A PolicyEngine-US adapter for graph checks, or ``None`` if absent. + + Mirrors the validation-leaf registry's lazy engine: succeeds without the + ``[us]`` extra and treats a missing extra at call time as ``None``, so the + manifest consistency check is a no-op off-build and runs live where the + extra is installed (the build). + """ + try: + from populace.frame.adapters.policyengine_us import PolicyEngineUSEngine + except ImportError: + return None + try: + return PolicyEngineUSEngine() + except Exception: # pragma: no cover - defensive + return None + + +def _ecps_populated_layers() -> frozenset[str]: + payload = _resource_payload(_ECPS_PARITY_REFERENCE_RESOURCE) + shares = payload.get("nonzero_shares") + if not isinstance(shares, Mapping) or not shares: + raise ValueError( + f"{_ECPS_PARITY_REFERENCE_RESOURCE}: 'nonzero_shares' must be a " + "non-empty JSON object." + ) + return frozenset(str(name) for name, share in shares.items() if float(share) > 0.0) + + +def assert_release_input_coverage_manifest_current( + *, + engine: Any | None = None, + manifest: ReleaseInputCoverageManifest | None = None, +) -> None: + """Fail if the coverage manifest has drifted from its authoritative sources. + + Checked against the checked-in facts (always) and the live engine graph + (when available): + + - The declared columns must equal the reference eCPS populated input surface + (``ecps_parity_reference.json``): the manifest is the eCPS export surface, + no more, no less. A layer the incumbent gains/loses must be reflected here. + - The three SSI countable-resource asset inputs must be ``required`` with no + reviewed exclusion — the #368 red-gate guarantee cannot be quietly undone. + - Every declared column must be a real PolicyEngine-US input leaf, and every + probe's ``binding_inputs`` / ``budget_measure`` must resolve on the live + engine, so the contract cannot guard names the engine no longer has. + + A no-op for the engine-graph half when no engine is available (the workspace + test environment); the checked-in-facts half always runs. + + Raises: + ValueError: Naming every drift found. + """ + manifest = manifest or load_release_input_coverage_manifest() + failures: list[str] = [] + + declared = set(manifest.declared_columns) + surface = set(_ecps_populated_layers()) + missing_from_manifest = sorted(surface - declared) + extra_in_manifest = sorted(declared - surface) + if missing_from_manifest: + failures.append( + "manifest is missing reference eCPS populated input column(s) " + f"{missing_from_manifest}; regenerate with " + "tools/build_us_release_input_coverage_manifest.py." + ) + if extra_in_manifest: + failures.append( + "manifest declares column(s) the reference eCPS does not populate " + f"{extra_in_manifest}; regenerate the manifest." + ) + + required = manifest.required_columns + reviewed = set(manifest.reviewed_exclusions) + for asset in SSI_COUNTABLE_RESOURCE_ASSETS: + if asset in reviewed: + failures.append( + f"{asset}: SSI countable-resource asset input is a reviewed " + "exclusion, but #368 requires it be a hard requirement with no " + "exclusion so the gate fails until the asset stage is restored." + ) + elif asset not in required: + failures.append( + f"{asset}: SSI countable-resource asset input must be a " + "required manifest column (#368)." + ) + + if engine is None: + engine = _coverage_engine() + if engine is not None: + try: + input_variables = set(engine.variables()) + except ImportError: + input_variables = None + if input_variables is not None: + non_leaves = sorted(declared - input_variables) + if non_leaves: + failures.append( + "manifest declares column(s) that are not PolicyEngine-US " + f"input leaves (formula-owned or unknown): {non_leaves}." + ) + for probe in manifest.probes: + bad_inputs = sorted(set(probe.binding_inputs) - input_variables) + if bad_inputs: + failures.append( + f"probe {probe.id!r}: binding_inputs are not " + f"PolicyEngine-US input leaves: {bad_inputs}." + ) + + if failures: + raise ValueError( + "US release input-column coverage manifest has drifted:\n" + + "\n".join(f" - {line}" for line in failures) + ) diff --git a/packages/populace-build/tests/test_release_input_coverage.py b/packages/populace-build/tests/test_release_input_coverage.py new file mode 100644 index 0000000..5c9d30e --- /dev/null +++ b/packages/populace-build/tests/test_release_input_coverage.py @@ -0,0 +1,351 @@ +"""US release input-column coverage + reform-coverage smoke, isolated from PE-US. + +populace #368. The five acceptance cases the brief pins: + +1. a full required set present with signal passes; +2. a missing required column fails, named; +3. a required column present but degenerate (every value the engine default) + fails without a reviewed exclusion; +4. a reviewed-exclusion column that has caught up (present with signal) is stale + and fails (#286 cannot-rot); +5. a bound reform scoring ~$0 fails the reform-coverage smoke. + +The frame is a real :class:`~populace.frame.Frame`; the engine is a stub exposing +only ``default_values`` (the surface the gate uses) and the simulation is injected +(and ``_build_reform`` monkeypatched), so nothing here imports policyengine-us — +the same isolation ``test_reform_validation`` relies on. Separate tests assert the +shipped manifest keeps the #368 red-by-design guarantee: the SSI countable-resource +assets stay hard requirements with no exclusion, and demoting one is rejected. +""" + +from __future__ import annotations + +import importlib.util +import json +from importlib.resources import files +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +import populace.build.us_runtime.reform_coverage_smoke as smoke_module +from populace.build.us_runtime import ( + SSI_COUNTABLE_RESOURCE_ASSETS, + US_RELEASE_INPUT_COVERAGE_RESOURCE, + ReformCoverageProbe, + ReleaseInputColumn, + ReleaseInputCoverageManifest, + assert_release_input_coverage_manifest_current, + load_release_input_coverage_manifest, + us_reform_coverage_smoke_gate, + us_release_input_coverage_gate, + us_release_reform_coverage_probes, +) +from populace.frame import EntitySchema, Frame, WeightKind, Weights + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_MANIFEST_GENERATOR = ( + _REPO_ROOT / "tools" / "build_us_release_input_coverage_manifest.py" +) + + +def _person_frame(columns: dict[str, np.ndarray]) -> Frame: + """A real single-household Frame carrying ``columns`` on the person table.""" + n = len(next(iter(columns.values()))) + person = pd.DataFrame( + { + "person_id": np.arange(n, dtype="int64"), + "person_household_id": np.ones(n, dtype="int64"), + **{name: np.asarray(values) for name, values in columns.items()}, + } + ) + household = pd.DataFrame({"household_id": np.asarray([1], dtype="int64")}) + return Frame( + {"person": person, "household": household}, + EntitySchema(group_entities=("household",)), + {"household": Weights(values=np.asarray([1000.0]), kind=WeightKind.DESIGN)}, + ) + + +class _StubEngine: + """Only ``default_values(names)`` — the single engine surface the gate uses.""" + + def __init__(self, defaults: dict[str, object]) -> None: + self._defaults = dict(defaults) + + def default_values(self, names) -> dict[str, object]: + return {name: self._defaults[name] for name in names if name in self._defaults} + + +def _manifest( + columns: tuple[ReleaseInputColumn, ...], + probes: tuple[ReformCoverageProbe, ...] = (), +) -> ReleaseInputCoverageManifest: + return ReleaseInputCoverageManifest( + reference={"source": "test"}, columns=columns, probes=probes + ) + + +# A two-required-plus-one-excluded contract, reused across the gate cases. +_CONTRACT = _manifest( + ( + ReleaseInputColumn("employment_income", "required"), + ReleaseInputColumn("stock_assets", "required"), + ReleaseInputColumn( + "alimony_income", + "reviewed_exclusion", + reason="Residual income-source layer not yet sourced; tracked.", + issue="PolicyEngine/populace#38", + ), + ) +) + +# Every declared column defaults to 0.0 in the stub engine, so an all-zero +# required column reads as degenerate (present but indistinguishable from absent). +_DEFAULTS = {"employment_income": 0.0, "stock_assets": 0.0, "alimony_income": 0.0} + + +class TestReleaseInputCoverageGate: + def test_full_required_set_with_signal_passes(self) -> None: + # Case 1: both required columns present and carrying signal; the excluded + # column is absent (dormant), which is reported, not failed. + frame = _person_frame( + { + "employment_income": np.asarray([0.0, 52_000.0, 12_000.0]), + "stock_assets": np.asarray([0.0, 1_500.0, 0.0]), + } + ) + result = us_release_input_coverage_gate( + frame, _StubEngine(_DEFAULTS), manifest=_CONTRACT + ) + assert result.passed + assert result.failures == () + assert result.details["dormant_exclusions"] == ["alimony_income"] + + def test_missing_required_column_fails(self) -> None: + # Case 2: stock_assets is absent from the export entirely — the silent + # zero the #368 launch failure rode in on. + frame = _person_frame({"employment_income": np.asarray([0.0, 52_000.0])}) + result = us_release_input_coverage_gate( + frame, _StubEngine(_DEFAULTS), manifest=_CONTRACT + ) + assert not result.passed + assert "stock_assets" in result.details["missing"] + assert any( + "stock_assets" in failure and "absent" in failure + for failure in result.failures + ) + + def test_degenerate_required_column_without_exclusion_fails(self) -> None: + # Case 3: stock_assets is present but every value is the engine default, + # so the export writer's default-broadcast makes it indistinguishable + # from absence — and there is no reviewed exclusion to accept it. + frame = _person_frame( + { + "employment_income": np.asarray([0.0, 52_000.0]), + "stock_assets": np.asarray([0.0, 0.0]), + } + ) + result = us_release_input_coverage_gate( + frame, _StubEngine(_DEFAULTS), manifest=_CONTRACT + ) + assert not result.passed + assert "stock_assets" in result.details["degenerate_required"] + assert any( + "stock_assets" in failure and "default" in failure + for failure in result.failures + ) + + def test_stale_reviewed_exclusion_fails(self) -> None: + # Case 4: alimony_income is a reviewed exclusion, but the data caught up + # — it is now present with signal, so the exclusion is stale and must be + # promoted to a hard requirement (#286 cannot-rot). + frame = _person_frame( + { + "employment_income": np.asarray([0.0, 52_000.0]), + "stock_assets": np.asarray([0.0, 1_500.0]), + "alimony_income": np.asarray([0.0, 800.0]), + } + ) + result = us_release_input_coverage_gate( + frame, _StubEngine(_DEFAULTS), manifest=_CONTRACT + ) + assert not result.passed + assert result.details["stale_exclusions"] == ["alimony_income"] + assert any( + "Stale reviewed exclusions" in failure for failure in result.failures + ) + + def test_absent_and_degenerate_excluded_column_passes(self) -> None: + # The exclusion accepts both an absent column and a degenerate one: with + # alimony_income present-but-all-default, the reviewed exclusion holds. + frame = _person_frame( + { + "employment_income": np.asarray([0.0, 52_000.0]), + "stock_assets": np.asarray([0.0, 1_500.0]), + "alimony_income": np.asarray([0.0, 0.0]), + } + ) + result = us_release_input_coverage_gate( + frame, _StubEngine(_DEFAULTS), manifest=_CONTRACT + ) + assert result.passed + assert result.details["reviewed_exclusions"] == { + "alimony_income": "Residual income-source layer not yet sourced; tracked." + } + + +class _Series: + def __init__(self, total: float) -> None: + self._total = total + + def sum(self) -> float: + return self._total + + +class _Sim: + """A simulation whose weighted total for the measure is a fixed number.""" + + def __init__(self, total: float) -> None: + self._total = total + + def calculate(self, measure: str, period): # noqa: ARG002 - stub + return _Series(self._total) + + +def _probe(min_abs_effect: float = 1_000_000_000.0) -> ReformCoverageProbe: + return ReformCoverageProbe( + id="ssi_probe", + name="SSI asset limits raised to $10k / $20k", + parameter_changes={ + "gov.ssa.ssi.eligibility.resources.limit.individual": { + "2024-01-01.2100-12-31": 10000 + } + }, + budget_measure="ssi", + binding_inputs=("bank_account_assets", "stock_assets", "bond_assets"), + min_abs_effect=min_abs_effect, + reason="Assets absent → countable resources 0 → the relaxation scores $0.", + issue="PolicyEngine/populace#356", + ) + + +class TestReformCoverageSmokeGate: + def test_zero_bound_reform_fails(self, monkeypatch) -> None: + # Case 5: with the asset inputs absent, everyone already passes the SSI + # resource test, so raising the limit moves nothing — baseline and reform + # score the same total and the bound reform reads as a coverage hole. + monkeypatch.setattr(smoke_module, "_build_reform", lambda changes: "REFORM") + + def simulate(reform): # noqa: ARG001 - baseline == reform on purpose + return _Sim(4.0e10) + + result = us_reform_coverage_smoke_gate( + simulate=simulate, probes=[_probe()], period=2024 + ) + assert not result.passed + assert result.details["results"]["ssi_probe"]["effect"] == 0.0 + assert "did not bind" in result.failures[0] + assert "bank_account_assets" in result.failures[0] + + def test_bound_reform_with_effect_passes(self, monkeypatch) -> None: + # The green counterpart: when the assets are carried, the same reform + # moves SSI by ~$1.6B (the dense-native reference), clearing the floor. + monkeypatch.setattr(smoke_module, "_build_reform", lambda changes: "REFORM") + + def simulate(reform): + return _Sim(4.16e10 if reform == "REFORM" else 4.0e10) + + result = us_reform_coverage_smoke_gate( + simulate=simulate, probes=[_probe()], period=2024 + ) + assert result.passed + assert result.details["results"]["ssi_probe"]["effect"] == pytest.approx(1.6e9) + + def test_probeless_gate_is_refused(self) -> None: + # A probe-less smoke gate would pass vacuously — refuse it. + with pytest.raises(ValueError, match="at least one probe"): + us_reform_coverage_smoke_gate(simulate=lambda reform: _Sim(0.0), probes=[]) + + +class TestShippedManifest: + def test_manifest_is_current(self) -> None: + # The checked-in-facts half runs everywhere (no engine in this env): the + # declared surface must equal the reference eCPS populated columns and the + # SSI assets must stay hard requirements. + assert_release_input_coverage_manifest_current() + + def test_ssi_assets_are_required_without_exclusion(self) -> None: + manifest = load_release_input_coverage_manifest() + for asset in SSI_COUNTABLE_RESOURCE_ASSETS: + assert asset in manifest.required_columns + assert asset not in manifest.reviewed_exclusions + + def test_shipped_ssi_probe_binds_through_the_assets(self) -> None: + probes = us_release_reform_coverage_probes() + assert probes, "the shipped manifest must pin at least one reform probe" + ssi = next(probe for probe in probes if probe.id == "ssi_asset_limit_10k_20k") + assert set(SSI_COUNTABLE_RESOURCE_ASSETS) <= set(ssi.binding_inputs) + assert ssi.budget_measure == "ssi" + assert ssi.min_abs_effect > 0 + + def test_demoting_an_ssi_asset_to_exclusion_is_rejected(self) -> None: + # The #368 red-gate guarantee cannot be quietly undone: turning an SSI + # asset into a reviewed exclusion must fail the anti-rot assertion. + manifest = load_release_input_coverage_manifest() + tampered_columns = tuple( + ReleaseInputColumn( + column.name, + "reviewed_exclusion", + reason="pretend this gap is tracked", + issue="PolicyEngine/populace#000", + ) + if column.name == "stock_assets" + else column + for column in manifest.columns + ) + tampered = ReleaseInputCoverageManifest( + reference=manifest.reference, + columns=tampered_columns, + probes=manifest.probes, + ) + with pytest.raises(ValueError, match="stock_assets"): + assert_release_input_coverage_manifest_current( + manifest=tampered, engine=None + ) + + +def _load_manifest_generator(): + spec = importlib.util.spec_from_file_location( + "build_us_release_input_coverage_manifest", _MANIFEST_GENERATOR + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TestManifestGeneratorSync: + def test_committed_manifest_matches_regeneration(self) -> None: + # The committed manifest is derivable purely from the checked-in eCPS + # parity reference + known-gaps register, so it cannot drift from them: + # regenerating must reproduce the committed file byte-for-value. + generator = _load_manifest_generator() + committed = json.loads( + files("populace.build.us") + .joinpath(US_RELEASE_INPUT_COVERAGE_RESOURCE) + .read_text(encoding="utf-8") + ) + assert generator.build_manifest() == committed + + def test_generated_manifest_names_no_retired_data_package(self) -> None: + # The manifest is not on the incumbent-reference allow-list, so its + # provenance block must not name the retired data package (the guard + # test_us_plan.test_no_incumbent_data_package_references_in_live_tree + # enforces on the committed file; this pins the generator too). + generator = _load_manifest_generator() + rendered = json.dumps(generator.build_manifest()) + # Build the needles by concatenation so this test file does not itself + # trip the live-tree guard it mirrors (test_us_plan does the same). + assert ("policyengine-" + "us-data") not in rendered + assert ("policyengine_" + "us_data") not in rendered diff --git a/tools/build_us_fiscal_refresh_release.py b/tools/build_us_fiscal_refresh_release.py index f106bac..085b892 100644 --- a/tools/build_us_fiscal_refresh_release.py +++ b/tools/build_us_fiscal_refresh_release.py @@ -52,6 +52,7 @@ US_JCT_TAX_EXPENDITURE_REFORMS, US_MEDICAID_ENROLLMENT_TARGET_TABLE, US_SOURCE_MANIFEST, + assert_release_input_coverage_manifest_current, assert_validation_leaf_registry_current, compile_us_fiscal_target_registry, hard_target_package_aliases, @@ -61,6 +62,8 @@ us_immigration_composition_gate, us_medicaid_take_up_gate, us_pregnancy_signal_gate, + us_reform_coverage_smoke_gate, + us_release_input_coverage_gate, us_snap_discretionary_exemption_signal_gate, us_snap_take_up_signal_gate, us_source_coverage_diagnostics, @@ -711,6 +714,36 @@ def _parse_args() -> argparse.Namespace: "unexempted) without failing the build." ), ) + parser.add_argument( + "--allow-input-coverage-gaps", + action="store_true", + help=( + "Diagnostic escape hatch (populace#368): record the release " + "input-column coverage gate result — required eCPS input columns " + "the export drops or leaves degenerate — without failing the " + "build. Release builds must leave this unset; the gate is a hard " + "certification blocker." + ), + ) + parser.add_argument( + "--skip-reform-coverage-smoke", + action="store_true", + help=( + "Do not run the reform-coverage smoke gate (populace#368): the " + "pinned bound-reform probes (SSI $10k/$20k asset limits) that must " + "score nonzero on the written release. Skipping loses the " + "end-to-end $0-reform backstop; release builds should leave it on." + ), + ) + parser.add_argument( + "--allow-reform-coverage-smoke-failures", + action="store_true", + help=( + "Diagnostic escape hatch (populace#368): record the reform-coverage " + "smoke gate result without failing the build when a bound reform " + "probe scores ~$0. Release builds must leave this unset." + ), + ) parser.add_argument( "--epochs", type=int, @@ -5540,6 +5573,12 @@ def main() -> None: for failure in validation_input_coverage_gate.failures ) ) + # Preflight (populace#368): the release input-column coverage manifest must + # still equal the pinned reference eCPS input surface, keep the SSI + # countable-resource assets as hard requirements, and declare only live + # PolicyEngine-US input leaves. A drifted manifest fails here before the + # expensive calibration so a stale contract cannot silently narrow coverage. + assert_release_input_coverage_manifest_current() ledger_artifact = load_ledger_consumer_artifact( args.ledger_facts, expected_facts_sha256=args.ledger_facts_sha256, @@ -6281,6 +6320,56 @@ def main() -> None: if args.dense_default_dataset else _with_l0_refit_weights(base_frame, result) ) + release_engine = PolicyEngineUSEngine() + # populace#368: full eCPS input-column coverage as a HARD release gate. + # Every input column the reference eCPS exports must be persisted by the + # export as a key with non-default signal, or carry a reviewed exclusion. + # This generalizes assert_required_us_release_source_columns (5 SNAP/ACA/ + # immigration columns) to the whole eCPS input surface and closes the hole + # export-mass parity leaves open: an input the base never imputed (the SSI + # countable-resource assets, tips, education credits, ...) is absent from the + # export entirely, so every reform binding through it silently scores ~$0 + # while mass and parity gates still pass. Runs on the calibrated export for + # BOTH the dense and sparse default paths (both reach this line). The SSI + # asset inputs ship as hard requirements with no exclusion, so this gate is + # RED on today's asset-less artifacts by design (Deliverable 2 turns it + # green); that is the gate doing its job, not a bug. + input_coverage_gate = us_release_input_coverage_gate(export_frame, release_engine) + input_coverage_path = release_dir / "input_coverage.json" + input_coverage_path.write_text( + json.dumps( + { + "schema_version": 1, + "enforced": not args.allow_input_coverage_gaps, + "input_coverage": { + "passed": input_coverage_gate.passed, + "failures": list(input_coverage_gate.failures), + "details": dict(input_coverage_gate.details), + }, + }, + indent=2, + sort_keys=True, + ) + + "\n" + ) + if telemetry is not None: + telemetry.attach_artifact("input_coverage", input_coverage_path) + if not input_coverage_gate.passed and not args.allow_input_coverage_gaps: + if telemetry is not None: + telemetry.stage( + "export_dataset", + status="failed", + message="Release input-column coverage gate failed.", + failures=list(input_coverage_gate.failures), + force_upload=True, + ) + raise RuntimeError( + "Release gates failed: " + + "; ".join( + f"Input coverage failed: {failure}" + for failure in input_coverage_gate.failures + ) + ) # #327: the export gate compares the calibrated export against a reference. # By default that reference is the raw pre-calibration base — but for a dense # parent built from a raw pooled-ASEC base, calibration correctly scales @@ -6360,7 +6449,66 @@ def main() -> None: ) ) dataset_path = artifact_root / DATASET_FILENAME - PolicyEngineUSEngine().write_dataset(export_frame, dataset_path, period=PERIOD) + release_engine.write_dataset(export_frame, dataset_path, period=PERIOD) + # populace#368: reform-coverage smoke on the WRITTEN release H5. The column + # gate above proves the required keys exist and carry signal; this is the + # end-to-end backstop: each pinned probe (first: SSI asset limits at + # $10k/$20k) mechanically binds through named input leaves and must move + # its budget measure. A ~$0 score on a bound reform means those leaves are + # absent or degenerate — the release aborts before any manifest or + # contract file certifies the artifact. Runs on both dense and sparse + # default paths (both reach this line). Until the asset stage is restored + # (Deliverable 2) the SSI probe fails by design; that is the gate working. + if not args.skip_reform_coverage_smoke: + if telemetry is not None: + telemetry.stage( + "reform_coverage_smoke", + message="Scoring pinned bound-reform probes on the written H5.", + ) + reform_coverage_smoke_gate = us_reform_coverage_smoke_gate( + simulate=default_simulate_factory(dataset_path), + period=PERIOD, + ) + reform_coverage_smoke_path = release_dir / "reform_coverage_smoke.json" + reform_coverage_smoke_path.write_text( + json.dumps( + { + "schema_version": 1, + "enforced": not args.allow_reform_coverage_smoke_failures, + "reform_coverage_smoke": { + "passed": reform_coverage_smoke_gate.passed, + "failures": list(reform_coverage_smoke_gate.failures), + "details": dict(reform_coverage_smoke_gate.details), + }, + }, + indent=2, + sort_keys=True, + ) + + "\n" + ) + if telemetry is not None: + telemetry.attach_artifact( + "reform_coverage_smoke", reform_coverage_smoke_path + ) + if ( + not reform_coverage_smoke_gate.passed + and not args.allow_reform_coverage_smoke_failures + ): + if telemetry is not None: + telemetry.stage( + "reform_coverage_smoke", + status="failed", + message="Reform-coverage smoke gate failed.", + failures=list(reform_coverage_smoke_gate.failures), + force_upload=True, + ) + raise RuntimeError( + "Release gates failed: " + + "; ".join( + f"Reform coverage smoke failed: {failure}" + for failure in reform_coverage_smoke_gate.failures + ) + ) if args.audit_export_targets: if telemetry is not None: telemetry.stage( diff --git a/tools/build_us_release_input_coverage_manifest.py b/tools/build_us_release_input_coverage_manifest.py new file mode 100644 index 0000000..83bb1df --- /dev/null +++ b/tools/build_us_release_input_coverage_manifest.py @@ -0,0 +1,221 @@ +"""Regenerate the US release input-column coverage manifest (populace #368). + +The manifest is the declared column-coverage contract the release gate enforces: +every input column the reference eCPS exports must be persisted by a populace +release as a real key with non-default signal, or carry a reviewed exclusion. + +Derivation (fully from checked-in, sha-pinned facts — no transient artifact): + +- Required surface = every input-variable column the pinned reference eCPS + populates, i.e. the ``nonzero_shares`` keys of ``ecps_parity_reference.json`` + (computed once from the sha-verified ``enhanced_cps_2024.h5``; an input the + incumbent exports but leaves all-zero is not a coverage requirement, the same + rule the parity gate uses). +- Status per column: + * ``reviewed_exclusion`` — the column is a documented incumbent-parity gap + (an entry in ``ecps_parity_known_gaps.json``, carrying that register's + reason and tracking issue), so the current candidate does not populate it + yet. EXCEPT the SSI countable-resource asset inputs (below). + * ``required`` — every other populated layer, PLUS the SSI countable-resource + asset inputs. Per #368 ("it must be an actual gate"), the asset inputs get + NO exclusion even though they are currently absent, so the gate ships red + for today's artifacts and Deliverable 2 (asset restoration) turns it green. + +The SSI asset inputs are ``bank_account_assets``, ``stock_assets``, and +``bond_assets`` — the exact ``adds`` list of ``ssi_countable_resources``. With +them absent, countable resources are 0 for every record, so the SSI asset-limit +reform probe scores $0; that is the failure the gate exists to surface. + +Run: uv run python tools/build_us_release_input_coverage_manifest.py +It rewrites packages/populace-build/src/populace/build/us/ +release_input_coverage_manifest.json. A test asserts the committed file matches +this regeneration, so the manifest cannot silently drift from the pinned eCPS +surface or the parity register. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +US_PACKAGE_DIR = ( + Path(__file__).resolve().parents[1] + / "packages" + / "populace-build" + / "src" + / "populace" + / "build" + / "us" +) +MANIFEST_PATH = US_PACKAGE_DIR / "release_input_coverage_manifest.json" + +#: The ``adds`` list of PolicyEngine-US ``ssi_countable_resources``: the asset +#: leaves SSI eligibility keys on. Absent from the release, they zero countable +#: resources and the SSI asset-limit reform scores $0. Per #368 these ship as +#: hard requirements with NO reviewed exclusion. +SSI_COUNTABLE_RESOURCE_ASSETS = ( + "bank_account_assets", + "stock_assets", + "bond_assets", +) + +#: The single pinned reform-coverage probe: raising the SSI resource limit from +#: the 2024 statutory $2,000 individual / $3,000 couple to $10,000 / $20,000 is +#: a pure relaxation that binds only through ``ssi_countable_resources``. Nonzero +#: iff the asset inputs are restored. Dense-native reference magnitudes: +$1.6B +#: at $10k/$20k and ~+$16.1B with no limit (populace #356). ``min_abs_effect`` is +#: a floor far below the plausible effect but far above simulation noise, so a +#: structural $0 fails while a real (even conservative) score passes. +REFORM_COVERAGE_PROBES = [ + { + "id": "ssi_asset_limit_10k_20k", + "name": "SSI asset limits raised to $10k individual / $20k couple", + "parameter_changes": { + "gov.ssa.ssi.eligibility.resources.limit.individual": { + "2024-01-01.2100-12-31": 10_000 + }, + "gov.ssa.ssi.eligibility.resources.limit.couple": { + "2024-01-01.2100-12-31": 20_000 + }, + }, + "budget_measure": "ssi", + "effect_direction": "reform_minus_baseline", + "binding_inputs": list(SSI_COUNTABLE_RESOURCE_ASSETS), + "min_abs_effect": 1_000_000_000.0, + "reason": ( + "Raising the SSI resource limit is a pure relaxation that only " + "changes who passes meets_ssi_resource_test, which compares " + "ssi_countable_resources = bank_account_assets + stock_assets + " + "bond_assets against the limit. With those asset inputs absent, " + "countable resources are 0 for every record, everyone already " + "passes the resource test, and raising the limit scores exactly " + "$0. Dense-native reference: +$1.6B at $10k/$20k, ~+$16.1B with no " + "limit (PolicyEngine/populace#356)." + ), + "issue": "PolicyEngine/populace#356", + } +] + + +def _load(name: str) -> dict: + return json.loads((US_PACKAGE_DIR / name).read_text(encoding="utf-8")) + + +def build_manifest() -> dict: + parity = _load("ecps_parity_reference.json") + known_gaps = _load("ecps_parity_known_gaps.json")["known_gaps"] + + populated_layers = { + name for name, share in parity["nonzero_shares"].items() if float(share) > 0.0 + } + ssi_assets = set(SSI_COUNTABLE_RESOURCE_ASSETS) + + missing_assets = sorted(ssi_assets - populated_layers) + if missing_assets: + raise ValueError( + "SSI countable-resource asset inputs are not in the reference eCPS " + f"populated surface, cannot pin them as required: {missing_assets}." + ) + + columns: dict[str, dict] = {} + for name in sorted(populated_layers): + if name in known_gaps and name not in ssi_assets: + entry = known_gaps[name] + columns[name] = { + "status": "reviewed_exclusion", + "reason": str(entry["reason"]), + "issue": str(entry["issue"]), + } + else: + column = {"status": "required"} + if name in ssi_assets: + # These are currently absent; #368 forbids an exclusion so the + # gate fails until Build J restores them. + column["note"] = ( + "SSI countable-resource asset input; required with NO " + "reviewed exclusion per PolicyEngine/populace#368 so the " + "gate fails until the asset stage is restored (Deliverable " + "2). Currently absent — this is the intended red gate." + ) + columns[name] = column + + required = sorted(n for n, c in columns.items() if c["status"] == "required") + reviewed = sorted( + n for n, c in columns.items() if c["status"] == "reviewed_exclusion" + ) + + # Provenance without naming the retired data package. Only the eCPS parity + # reference, its loader, and its test are allow-listed to name the incumbent + # (test_us_plan.test_no_incumbent_data_package_references_in_live_tree); this + # manifest is not, so it records the sha-locked coordinates that DON'T name a + # package (filename + content sha + revision + vintage) and points at that + # allow-listed parity reference for the full record. Nothing reads these + # fields — the gate and the anti-rot check derive the surface from + # ecps_parity_reference.json directly — so this stays pure documentation. + parity_source = dict(parity["source"]) + reference = { + "derived_from": "ecps_parity_reference.json", + "filename": str(parity_source.get("filename", "")), + "revision": str(parity_source.get("revision", "")), + "sha256": str(parity_source.get("sha256", "")), + "vintage": str(parity_source.get("vintage", "")), + "period": str(parity_source.get("period", "")), + "note": ( + "Column surface derived from the populated input layers of the " + "pinned, sha-verified reference eCPS recorded in " + "ecps_parity_reference.json (the launch parity contract, " + "PolicyEngine/populace#313) — the single allow-listed record of the " + "incumbent coordinates. This manifest names no data package." + ), + } + + return { + "schema_version": 1, + "issue": "PolicyEngine/populace#368", + "description": ( + "Declared full-coverage contract for a US release: every input " + "column the reference eCPS exports must be persisted as a key with " + "non-default signal, or carry a reviewed exclusion. Enforced as a " + "hard release gate (populace.build.us_runtime.release_input_" + "coverage) that generalizes assert_required_us_release_source_" + "columns from 5 columns to the full eCPS input surface." + ), + "reference": reference, + "derivation": ( + "Required surface = ecps_parity_reference.json populated layers " + "(input columns the pinned, sha-verified reference eCPS populates). " + "status='reviewed_exclusion' for ecps_parity_known_gaps.json entries " + "(reason+issue from that register); EXCEPT the SSI countable-resource " + "asset inputs (bank_account_assets, stock_assets, bond_assets), which " + "are status='required' with NO exclusion per PolicyEngine/populace#368 " + "so the gate fails on today's artifacts and asset restoration " + "(Deliverable 2) turns it green. All other populated layers are " + "'required'. Regenerate with " + "tools/build_us_release_input_coverage_manifest.py." + ), + "counts": { + "required": len(required), + "reviewed_exclusion": len(reviewed), + "total": len(columns), + }, + "ssi_countable_resource_assets": list(SSI_COUNTABLE_RESOURCE_ASSETS), + "columns": columns, + "reform_coverage_probes": REFORM_COVERAGE_PROBES, + } + + +def main() -> None: + manifest = build_manifest() + MANIFEST_PATH.write_text( + json.dumps(manifest, indent=1, sort_keys=True, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + print( + f"wrote {MANIFEST_PATH} — {manifest['counts']['required']} required, " + f"{manifest['counts']['reviewed_exclusion']} reviewed exclusions, " + f"{len(manifest['reform_coverage_probes'])} reform probe(s)." + ) + + +if __name__ == "__main__": + main()