diff --git a/packages/populace-build/src/populace/build/us_runtime/reform_validation.py b/packages/populace-build/src/populace/build/us_runtime/reform_validation.py index ece2b645..94e43d60 100644 --- a/packages/populace-build/src/populace/build/us_runtime/reform_validation.py +++ b/packages/populace-build/src/populace/build/us_runtime/reform_validation.py @@ -354,10 +354,12 @@ def reform_validation_payload( budget effect rather than failing the build. OBBBA rows are special because policyengine-us already carries OBBBA in its baseline, while JCX-35-25 scores enactment relative to a pre-OBBBA present-law baseline. For those - rows, each spec's ``parameter_changes`` remains the counterfactual repeal - patch, but scoring first merges all OBBBA repeal patches into a pre-OBBBA - baseline and then adds the row's provision back. The shape matches the - calibration-diagnostics dashboard's reform_validation reader. + rows, each spec's ``parameter_changes`` is the counterfactual repeal patch; + scoring merges all repeals into a pre-OBBBA baseline, then enacts the + provisions one at a time in JCX order and scores each *stacked* — the + incremental effect given the lines above it — so the per-line effects sum to + the bill total, matching JCT (see ``stacked_obbba_effects``). The shape + matches the calibration-diagnostics dashboard's reform_validation reader. """ estimates = in_sample_estimates or {} targets = in_sample_targets or {} @@ -366,6 +368,7 @@ def reform_validation_payload( parameter_reform_sims: dict[str, Any] = {} obbba_specs = tuple(spec for spec in specs if _is_obbba_spec(spec)) obbba_pre_baseline_changes = _merged_parameter_changes(obbba_specs) + obbba_stacked: dict[str, tuple[float, float, float]] | None = None def parameter_changes_key(changes: dict[str, Any]) -> str: return json.dumps(changes, sort_keys=True, separators=(",", ":")) @@ -386,9 +389,60 @@ def simulation_for_parameter_changes(changes: dict[str, Any]) -> Any: parameter_reform_sims[key] = simulate(reform) # type: ignore[misc] return parameter_reform_sims[key] - def obbba_component_effect( + def stacked_obbba_effects() -> dict[str, tuple[float, float, float]]: + """Score the OBBBA provisions *stacked* in their JCX-35-25 order. + + JCT presents each provision's budget effect incrementally — given the + provisions above it in the document — so the line items sum to the + bill's total. We mirror that: starting from the pre-OBBBA baseline (all + provisions reverted), enact the provisions one at a time in ``specs`` + order, and score each as the change in its budget measure from enacting + it on top of the lines already enacted. The per-line effects then + telescope to the true total OBBBA effect, rather than each being + measured in isolation against pre-OBBBA law (which ignores the + interactions between provisions, e.g. the standard deduction and the + personal-exemption repeal). + + All OBBBA specs must share one (budget_measure, period) for the + cumulative baseline to be coherent; a mixed group falls back to + isolated scoring. + """ + if simulate is None or not obbba_specs: + return {} + measures = {(spec.budget_measure, spec.period) for spec in obbba_specs} + if len(measures) != 1: + return { + spec.id: _isolated_obbba_effect(spec) for spec in obbba_specs + } + measure, period = next(iter(measures)) + # state 0: pre-OBBBA (every provision reverted). + prev_total = _weighted_total( + simulation_for_parameter_changes(obbba_pre_baseline_changes), + measure, + period, + ) + enacted: set[str] = set() + effects: dict[str, tuple[float, float, float]] = {} + for spec in obbba_specs: + # Enacting a provision means dropping its repeal from the baseline. + enacted |= set((spec.parameter_changes or {}).keys()) + state_changes = { + path: change + for path, change in obbba_pre_baseline_changes.items() + if path not in enacted + } + cur_total = _weighted_total( + simulation_for_parameter_changes(state_changes), measure, period + ) + effects[spec.id] = (cur_total - prev_total, prev_total, cur_total) + prev_total = cur_total + return effects + + def _isolated_obbba_effect( spec: ReformValidationSpec, ) -> tuple[float | None, float | None, float | None]: + # One provision enacted alone on the pre-OBBBA baseline. Only used as a + # fallback when the OBBBA group spans multiple measures/periods. if simulate is None: return None, None, None component_paths = set((spec.parameter_changes or {}).keys()) @@ -406,10 +460,13 @@ def obbba_component_effect( def simulated_effect( spec: ReformValidationSpec, ) -> tuple[float | None, float | None, float | None]: + nonlocal obbba_stacked if simulate is None: return None, None, None if _is_obbba_spec(spec): - return obbba_component_effect(spec) + if obbba_stacked is None: + obbba_stacked = stacked_obbba_effects() + return obbba_stacked.get(spec.id, (None, None, None)) base = baseline_total(spec.budget_measure, spec.period) reform_total = _weighted_total( simulate(spec.build_reform()), spec.budget_measure, spec.period diff --git a/packages/populace-build/tests/test_reform_validation.py b/packages/populace-build/tests/test_reform_validation.py index 638d175c..a147e476 100644 --- a/packages/populace-build/tests/test_reform_validation.py +++ b/packages/populace-build/tests/test_reform_validation.py @@ -152,7 +152,7 @@ def simulate(reform): assert payload["reforms"][0]["populace"]["budget_effect"] == pytest.approx(-33e9) -def test_obbba_components_score_against_pre_obbba_baseline(monkeypatch): +def test_obbba_components_score_stacked_in_jcx_order(monkeypatch): specs = ( ReformValidationSpec( id="obbba_a", @@ -192,25 +192,31 @@ def test_obbba_components_score_against_pre_obbba_baseline(monkeypatch): ) def simulate(reform): - # Reform keys are the provisions still turned off. The full pre-OBBBA - # baseline has both patches applied. Component A is scored with only B - # still off, and component B with only A still off. + # Reform keys are the provisions still repealed. Pre-OBBBA repeals both; + # the provisions are then enacted one at a time in order: A (only B still + # repealed), then B (nothing repealed → reform is None). totals = { - frozenset({"gov.example.a", "gov.example.b"}): 1_000.0, - frozenset({"gov.example.b"}): 900.0, - frozenset({"gov.example.a"}): 1_060.0, - None: 950.0, + frozenset({"gov.example.a", "gov.example.b"}): 1_000.0, # pre-OBBBA + frozenset({"gov.example.b"}): 900.0, # A enacted + None: 960.0, # A and B enacted } return _FakeSim({"income_tax": totals[reform]}) payload = reform_validation_payload(specs, period=2026, simulate=simulate) rows = {row["id"]: row for row in payload["reforms"]} + # A is scored against pre-OBBBA; B is scored against the post-A state, not + # against pre-OBBBA — that stacking is the whole point. assert rows["obbba_a"]["populace"]["baseline_total"] == pytest.approx(1_000.0) assert rows["obbba_a"]["populace"]["reform_total"] == pytest.approx(900.0) assert rows["obbba_a"]["populace"]["budget_effect"] == pytest.approx(-100.0) - assert rows["obbba_b"]["populace"]["baseline_total"] == pytest.approx(1_000.0) - assert rows["obbba_b"]["populace"]["reform_total"] == pytest.approx(1_060.0) + assert rows["obbba_b"]["populace"]["baseline_total"] == pytest.approx(900.0) + assert rows["obbba_b"]["populace"]["reform_total"] == pytest.approx(960.0) assert rows["obbba_b"]["populace"]["budget_effect"] == pytest.approx(60.0) + # Stacked line effects telescope to the true total OBBBA effect. + total = sum( + rows[i]["populace"]["budget_effect"] for i in ("obbba_a", "obbba_b") + ) + assert total == pytest.approx(960.0 - 1_000.0) def test_shipped_obbba_config_is_out_of_sample_counterfactual():