Skip to content

[v4.0.1] Fix contrib-CTC structural reforms in US microsim path#305

Merged
MaxGhenis merged 5 commits intomainfrom
v4.0.1-fix
Apr 20, 2026
Merged

[v4.0.1] Fix contrib-CTC structural reforms in US microsim path#305
MaxGhenis merged 5 commits intomainfrom
v4.0.1-fix

Conversation

@MaxGhenis
Copy link
Copy Markdown
Contributor

Summary

Fixes a silent bug in the US microsim path that made dict-reforms involving gov.contrib.ctc.* parameters crash at .ensure() with:

AttributeError: 'NoneType' object has no attribute 'entity'

Concretely: Tara Watson's published CTC+EITC reform (policy 94589 on app.policyengine.org) — 42 parameters with three gov.contrib.ctc.*.in_effect = True gates — was completely unable to run through pe.us.economic_impact_analysis on the v4.0 stack.

Root cause

PolicyEngineUSLatest._build_simulation_from_dataset was instantiating entities against the module-level policyengine_us.system:

builder.populations = system.instantiate_entities()   # module system, no user reform
# ...
microsim.build_from_populations(builder.populations)

But Microsimulation.__init__ applies structural reforms (triggered by user-reform parameter gates like gov.contrib.ctc.minimum_refundable.in_effect=True) to its own tax_benefit_system — not the module one. Building populations against the module system then left reform-registered variables like ctc_minimum_refundable_amount invisible at calc time; refundable_ctc (which had been swapped to the reform version) would then crash trying to sum them.

Fix

Pass microsim.tax_benefit_system to _build_simulation_from_dataset instead of the module-level system. One-line change in src/policyengine/tax_benefit_models/us/model.py.

Evidence

On the v4.0 stack (pe-us 1.653.3 + us-data 1.73.0), running the full 42-param reform now produces:

Metric Baseline Reform Δ
Federal income tax $2,141.8B $2,053.8B −$87.9B
CTC $138.2B $236.3B +$98.0B
Refundable CTC $35.9B $215.7B +$179.8B
EITC $61.4B $38.9B −$22.6B

vs. pe-us 1.601.0 (March 2026): −$69.6B (the +$18B drift is upstream model evolution, not a pe.py regression).

Tests

New tests/test_us_microsim_structural_reforms.py — builds a tiny in-memory dataset, applies the minimum-refundable reform via dict, runs the sim end-to-end. Before the fix: crashes. After: passes.

72/72 existing tests still pass.

Version

Bumped to 4.0.1 (patch — bug fix, no API changes).

🤖 Generated with Claude Code

Simulation(policy={"gov.contrib.ctc.*": ...}) was crashing at
.ensure() with 'NoneType has no attribute entity' because
_build_simulation_from_dataset instantiated entities against the
module-level policyengine_us.system (no user reform applied) while
building the population from the per-sim Microsimulation whose
tax_benefit_system DID have the structural reform applied. Reform-
registered variables like ctc_minimum_refundable_amount were then
absent from the population's entity registry at calc time.

Fix: pass microsim.tax_benefit_system to _build_simulation_from_dataset
instead of the module-level system. The per-sim system already has
all structural reforms (gov.contrib.ctc.minimum_refundable,
per_child_phase_in, per_child_phase_out) applied by
Microsimulation.__init__'s post-user-reform structural pass.

Real-world impact: Tara Watson's CTC+EITC expansion (policy 94589,
42 parameters including three gov.contrib.ctc.* in_effect gates)
now runs end-to-end on the v4.0 stack, producing the full -$87.9B
federal income tax impact for 2025 instead of crashing.

Regression guarded by tests/test_us_microsim_structural_reforms.py
which builds a tiny in-memory dataset, applies the minimum-refundable
reform via policy dict, and asserts the sim runs to completion.

Version bumped to 4.0.1.
Adds three additional tests to guard against the same class of bug:

- Parametrized per-gate smoke test across all three gov.contrib.ctc
  structural-reform gates (minimum_refundable, per_child_phase_in,
  per_child_phase_out). Each activates cleanly through a dict reform.

- Invariant test that the module-level policyengine_us.system stays
  pristine — structural reforms must only mutate the per-sim system.
  If someone refactors _build_simulation_from_dataset and
  accidentally points it back at the module system, both ends of the
  invariant fire.

- "Reform parameter change reaches the output" test using a plain
  scalar parameter override (no structural-reform machinery), so
  regressions where ANY parameter-value reform silently becomes a
  no-op get caught, not just the contrib-CTC class.

Confirmed each test fails when the fix is reverted; 3/6 catch the
original bug (the two per_child_phase_* reforms only update existing
variables so build_from_populations doesn't trip on them — kept as
smoke tests for defense-in-depth).

403/403 tests pass with fix applied.
Code-simplifier:
- Inline-lambda -> def _simple (tests/test_us_microsim_structural_reforms.py
  L68) — drops the noqa: E731 smell.
- Tighten the fix's inline comment by ~3 lines, keep the load-bearing
  'hide reform-registered variables' clause.

Reproducibility-reviewer (blocker):
- Replace brittle "module-level system stays pristine" check with a
  direct identity assertion: the TaxBenefitSystem passed into
  _build_simulation_from_dataset must be microsim.tax_benefit_system.
  Uses monkeypatch to capture the argument; passes a positive
  assertion that the captured system has the structural-reform
  variable registered. Resilient to upstream policyengine_us ever
  shipping that variable unconditionally.

Reproducibility-reviewer (follow-ups):
- test__reform_parameter_change_is_reflected_in_output now asserts
  on BOTH CTC (base-amount change) AND EITC (phase_in_rate change)
  so partial-application regressions are caught, not only no-op ones.
- Added one non-CTC contrib gate (gov.contrib.streamlined_eitc) to
  the parametrised smoke test to prove the fix generalises beyond
  the CTC family.

Verified fail-without-fix: reverting the one-line fix breaks 3/7
tests (same set as before) with clean errors — the invariant test
now reports "system is not microsim.tax_benefit_system" instead of
the raw NoneType traceback, which is the actual contract.

7 tests, all pass.
@MaxGhenis MaxGhenis merged commit 2ae16c9 into main Apr 20, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant