Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/add-srebate-net-of-rebates.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Emit PolicyEngine one-time state rebates in the srebate output column and add a --net-of-rebates compare option that scores state tax as siitax plus srebate on both sides, removing the TAXSIM payout-year vs PolicyEngine liability-year rebate timing difference.
19 changes: 19 additions & 0 deletions policyengine_taxsim/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,18 @@ def taxsim(input_file, output, sample, taxsim_path):
"Default 0 uses the flat $15 absolute tolerance."
),
)
@click.option(
"--net-of-rebates",
is_flag=True,
default=False,
help=(
"Score state tax net of one-time rebates: compare siitax + srebate "
"on both sides. Removes the timing-convention difference between "
"TAXSIM (rebates in the payout year) and PolicyEngine (rebates in "
"the liability year) without changing either engine. Federal "
"comparison is unaffected."
),
)
def compare(
input_file,
sample,
Expand All @@ -330,6 +342,7 @@ def compare(
logs,
assume_w2_wages,
rel_tolerance,
net_of_rebates,
):
"""Compare PolicyEngine and TAXSIM results"""
try:
Expand Down Expand Up @@ -390,11 +403,17 @@ def compare(
federal_tolerance=15.0,
state_tolerance=15.0,
relative_tolerance=rel_tolerance,
net_of_rebates=net_of_rebates,
)
if rel_tolerance > 0:
click.echo(
f"Using income-scaled tolerance: max($15, {rel_tolerance:.3%} of |AGI|)"
)
if net_of_rebates:
click.echo(
"Scoring state tax net of one-time rebates (siitax + srebate "
"on both sides)"
)

comparator = TaxComparator(taxsim_results, pe_results, config)
comparison_results = comparator.compare()
Expand Down
33 changes: 29 additions & 4 deletions policyengine_taxsim/comparison/comparator.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ class ComparisonConfig:
# behavior. `agi_col` is the column used for scaling (federal AGI, v10).
relative_tolerance: float = 0.0
agi_col: str = "v10"
# Score state tax net of one-time rebates: compare siitax + srebate on
# each side (missing/NaN srebate treated as 0). TAXSIM includes one-time
# rebates in siitax in the payout year and reports them in `srebate`;
# PE books them to the liability year inside state_income_tax. Adding
# srebate back on both sides removes this timing-convention difference
# without changing either engine (see taxsim #1068, convention #716).
# Federal comparison is unaffected. Composable with relative_tolerance.
net_of_rebates: bool = False
rebate_col: str = "srebate"
id_col: str = "taxsimid"


Expand Down Expand Up @@ -150,11 +159,20 @@ def _compare_tax_column(
agi = self.taxsim_results[self.config.agi_col].abs().fillna(0)
eff_tol = np.maximum(tolerance, rel * agi.to_numpy())

# Values to compare. For the state column with net_of_rebates, score
# siitax + srebate on each side so TAXSIM's payout-year rebate
# convention and PE's liability-year convention cancel out.
taxsim_values = self.taxsim_results[col_name]
pe_values = self.policyengine_results[col_name]
if tax_type == "state" and getattr(self.config, "net_of_rebates", False):
taxsim_values = taxsim_values + self._rebate_values(self.taxsim_results)
pe_values = pe_values + self._rebate_values(self.policyengine_results)

# Compare with tolerance. np.isclose applies atol elementwise when atol
# is an array, giving the per-record income-scaled tolerance.
matches = np.isclose(
self.taxsim_results[col_name],
self.policyengine_results[col_name],
taxsim_values,
pe_values,
atol=eff_tol,
rtol=0.0,
equal_nan=True,
Expand All @@ -163,8 +181,8 @@ def _compare_tax_column(
# Collect mismatches
for i, is_match in enumerate(matches):
if not is_match:
taxsim_val = self.taxsim_results.iloc[i][col_name]
pe_val = self.policyengine_results.iloc[i][col_name]
taxsim_val = taxsim_values.iloc[i]
pe_val = pe_values.iloc[i]

mismatch = MismatchRecord(
taxsimid=self.taxsim_results.iloc[i][self.config.id_col],
Expand All @@ -182,6 +200,13 @@ def _compare_tax_column(

return matches, mismatches

def _rebate_values(self, df: pd.DataFrame) -> pd.Series:
"""One-time rebate column as a numeric Series (missing/NaN -> 0)."""
rebate_col = getattr(self.config, "rebate_col", "srebate")
if rebate_col in df.columns:
return pd.to_numeric(df[rebate_col], errors="coerce").fillna(0.0)
return pd.Series(0.0, index=df.index)

def compare(self) -> "ComparisonResults":
"""Perform full comparison between TAXSIM and PolicyEngine results"""
print("Comparing TAXSIM and PolicyEngine results")
Expand Down
17 changes: 17 additions & 0 deletions policyengine_taxsim/config/variable_mappings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,23 @@ policyengine_to_taxsim:
group_order: 4
full_text_group: "State Tax Calculation"
group_column: 1
srebate:
# One-time state rebates that PE nets into state_income_tax for the
# year. TAXSIM reports these in `srebate` (payout-year convention,
# included in siitax); PE books them to the liability year inside
# state_income_tax. Computed as a difference — state_income_tax with the
# ONE_TIME_REBATE_VARIABLES (core/state_output_resolver.py) zeroed minus
# actual state_income_tax — so it equals exactly what PE netted into
# siitax. See taxsim #1068 / convention #716.
variable: srebate_computed
implemented: true
idtl:
- full: 2
- full_text: 5
text_description: "State One-Time Rebate"
group_order: 4
full_text_group: "State Tax Calculation"
group_column: 1
v41:
variable: na_pe
implemented: false
Expand Down
36 changes: 36 additions & 0 deletions policyengine_taxsim/core/output_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
to_roundedup_number,
)
from .state_output_resolver import (
ONE_TIME_REBATE_VARIABLES,
calculate_output_adapter,
get_state_mapped_variables,
get_state_specific_variable_name,
Expand All @@ -17,6 +18,35 @@
disable_salt_variable = False


def compute_srebate_single(simulation, year):
"""One-time state rebates PE netted into state_income_tax this year.

Computed as a difference: a twin simulation with the one-time rebate
variables forced to zero minus the actual simulation. This equals
exactly the rebate amount included in siitax (non-refundable caps,
credit pooling, and floors are handled automatically). TAXSIM reports
the same concept in `srebate` under its payout-year convention.
"""
try:
twin = Simulation(situation=simulation.situation_input)
if disable_salt_variable:
twin.set_input(
variable_name="state_and_local_sales_or_income_tax",
value=0.0,
period=year,
)
for variable in ONE_TIME_REBATE_VARIABLES:
if twin.tax_benefit_system.variables.get(variable) is None:
continue
twin.set_input(variable_name=variable, value=0.0, period=year)

actual_tax = float(simulation.calculate("state_income_tax", period=year)[0])
rebate_free_tax = float(twin.calculate("state_income_tax", period=year)[0])
return to_roundedup_number(rebate_free_tax - actual_tax)
except Exception:
return 0.00


def resolve_output_variable(simulation, variable, state_name):
if simulation.tax_benefit_system.variables.get(variable) is not None:
return variable
Expand Down Expand Up @@ -59,6 +89,10 @@ def generate_non_description_output(
for entry in each_item["idtl"]:
if output_type in entry.values():
taxsim_output[key] = mtr_results.get(key, 0.0)
elif each_item.get("variable") == "srebate_computed":
for entry in each_item["idtl"]:
if output_type in entry.values():
taxsim_output[key] = compute_srebate_single(simulation, year)
elif is_output_adapter(each_item.get("variable", "")):
for entry in each_item["idtl"]:
if output_type in entry.values():
Expand Down Expand Up @@ -186,6 +220,8 @@ def generate_text_description_output(
mtr_results = {"frate": 0.0, "srate": 0.0, "ficar": 0.0}
mtr_computed = True
value = mtr_results.get(var_name, 0.0)
elif each_item.get("variable") == "srebate_computed":
value = compute_srebate_single(simulation, year)
elif is_output_adapter(each_item.get("variable", "")):
value = to_roundedup_number(
calculate_output_adapter(
Expand Down
31 changes: 31 additions & 0 deletions policyengine_taxsim/core/state_output_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,37 @@
"taxsim_v36_taxable_income",
"taxsim_v37_property_tax_credit",
}
# PE-US one-time state rebate variables (tax_unit level). TAXSIM reports
# these in `srebate` (payout-year convention, included in siitax); PE books
# them to the liability year inside state_income_tax. The emulator's srebate
# is computed as a difference — state_income_tax with these zeroed minus
# actual state_income_tax — so it equals exactly the amount PE netted into
# siitax (wiring, non-refundable caps, and floors are handled automatically).
# Only one-time rebates belong here; recurring annual rebates (e.g.
# nm_low_income_comprehensive_tax_rebate, nm_property_tax_rebate,
# pa_property_tax_or_rent_rebate, mt_property_tax_rebate) are excluded.
# See taxsim #1068 / convention #716.
ONE_TIME_REBATE_VARIABLES = (
"az_families_tax_rebate",
"co_tabor_cash_back",
"ct_child_tax_rebate",
"de_relief_rebate",
"ga_surplus_tax_rebate",
"hi_act_115_rebate",
"id_2022_rebate",
"id_special_season_rebate",
"il_income_tax_rebate",
"in_automatic_refund_rebate",
"ma_taxpayer_refund_rebate",
"me_relief_rebate",
"mt_income_tax_rebate",
"nm_2021_income_rebate",
"nm_additional_2021_income_rebate",
"nm_supplemental_2021_income_rebate",
"ri_child_tax_rebate",
"sc_2022_rebate",
"va_rebate",
)
OUTPUT_ADAPTER_OVERRIDES = {
# MT: state_agi reads `gov.states.household.state_agis` which lists
# `mt_agi_indiv` (Person, defined only for MFS-on-same-return). For all
Expand Down
Loading
Loading