Skip to content

Commit 1d0e2ce

Browse files
MaxGhenisclaude
andauthored
Map pass-through inputs to QBID-bearing PolicyEngine variables (#1051)
* Map pass-through inputs to QBID-bearing PolicyEngine variables TAXSIM pass-through income sources produced wrong federal tax in the emulator's PolicyEngine column (Dan Feenberg's 2026-06-30 five-source comparison): pbusinc yielded zero AGI/tax, and psemp/scorp produced correct AGI but no qualified business income deduction. Root cause is in the emulator mapping layer, not policyengine-us. The emulator initializes every mapped PolicyEngine variable as a dataset input. pbusinc mapped to qualified_business_income — a COMPUTED variable derived from self_employment_income et al. via the § 199A income definition — so holding it as an input pinned it to whatever pbusinc supplied (0 on all other records), zeroing the non-SSTB QBI component for psemp and scorp too. scorp additionally mapped to the s_corp_income leaf rather than partnership_s_corp_income, the variable named in the QBI income definition. Fix: - psemp/ssemp and pbusinc/sbusinc -> self_employment_income (active participation, SECA, QBID without the SSTB phaseout; per Dan's spec businc is identical to semp). The mapper sums each spouse's own pair. - scorp -> partnership_s_corp_income (QBID, no SECA, NIIT-eligible). - pprofinc/sprofinc -> sstb_self_employment_income (QBID with the § 199A(d)(3) applicable-percentage phaseout) — unchanged, already right. Aligns both the single-household mapper (core/input_mapper.py) and the Microsimulation runner (runners/policyengine_runner.py), adds sbusinc to the recognized input columns, and updates the S-corp splitting test to the new mapping. New tests/test_passthrough_qbid.py reproduces Dan's five-source table (AGI/taxable/income_tax) and pins the SSTB phaseout behavior. Fixes #1004 Addresses #384 Addresses #762 Addresses #943 Addresses #1018 Addresses #1003 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Regenerate dashboard config for the new pass-through mappings Rerun extract-config so the dashboard's mapping documentation reflects the QBID fix: pbusinc now shows self_employment_income (was qualified_business_income) and pprofinc shows a proper sstb_self_employment_income link. Also correct the self_employment_income GitHub path (it lives in input/, not household/income/self_employment/) and add the sstb_self_employment_income path to the link map. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 42041b3 commit 1d0e2ce

11 files changed

Lines changed: 435 additions & 30 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Map TAXSIM pass-through inputs (psemp/ssemp, pbusinc/sbusinc, scorp, pprofinc/sprofinc) to QBID-bearing PolicyEngine variables so S-corp, active QBI, and self-employment income earn the qualified business income deduction.

dashboard/public/config-data.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,14 @@
7575
"policyengine": "self_employment_income",
7676
"description": "Self-employment income of primary taxpayer",
7777
"implemented": true,
78-
"githubLink": "https://github.com/PolicyEngine/policyengine-us/blob/master/policyengine_us/variables/household/income/self_employment/self_employment_income.py"
78+
"githubLink": "https://github.com/PolicyEngine/policyengine-us/blob/master/policyengine_us/variables/input/self_employment_income.py"
7979
},
8080
{
8181
"taxsim": "ssemp",
8282
"policyengine": "self_employment_income",
8383
"description": "Self-employment income of secondary taxpayer",
8484
"implemented": true,
85-
"githubLink": "https://github.com/PolicyEngine/policyengine-us/blob/master/policyengine_us/variables/household/income/self_employment/self_employment_income.py"
85+
"githubLink": "https://github.com/PolicyEngine/policyengine-us/blob/master/policyengine_us/variables/input/self_employment_income.py"
8686
},
8787
{
8888
"taxsim": "dividends",
@@ -149,17 +149,17 @@
149149
},
150150
{
151151
"taxsim": "pbusinc",
152-
"policyengine": "qualified_business_income",
153-
"description": "Primary and secondary Taxpayer's active QBI",
152+
"policyengine": "self_employment_income",
153+
"description": "Primary taxpayer's active QBI",
154154
"implemented": true,
155-
"githubLink": "https://github.com/PolicyEngine/policyengine-us/blob/master/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income.py"
155+
"githubLink": "https://github.com/PolicyEngine/policyengine-us/blob/master/policyengine_us/variables/input/self_employment_income.py"
156156
},
157157
{
158158
"taxsim": "pprofinc",
159159
"policyengine": "sstb_self_employment_income",
160160
"description": "Primary taxpayer's SSTB self-employment income",
161161
"implemented": true,
162-
"githubLink": null
162+
"githubLink": "https://github.com/PolicyEngine/policyengine-us/blob/master/policyengine_us/variables/input/sstb_self_employment_income.py"
163163
},
164164
{
165165
"taxsim": "rentpaid",
@@ -280,5 +280,5 @@
280280
}
281281
]
282282
},
283-
"lastUpdated": "2026-06-22T00:34:33.871Z"
283+
"lastUpdated": "2026-07-05T15:19:19.288Z"
284284
}

dashboard/scripts/extract-config.cjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ function generatePolicyEngineGitHubLink(variableName) {
2929
const variablePathMap = {
3030
// Income variables
3131
'employment_income': 'input/employment_income.py',
32-
'self_employment_income': 'household/income/self_employment/self_employment_income.py',
32+
'self_employment_income': 'input/self_employment_income.py',
33+
'sstb_self_employment_income': 'input/sstb_self_employment_income.py',
3334
'qualified_dividend_income': 'household/income/person/dividends/qualified_dividend_income.py',
3435
'taxable_interest_income': 'household/income/person/interest/taxable_interest_income.py',
3536
'short_term_capital_gains': 'household/income/person/capital_gains/short_term_capital_gains.py',

policyengine_taxsim/api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"mortgage",
7777
"scorp",
7878
"pbusinc",
79+
"sbusinc",
7980
"pprofinc",
8081
"sprofinc",
8182
"idtl",

policyengine_taxsim/config/variable_mappings.yaml

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -796,13 +796,33 @@ taxsim_to_policyengine:
796796
- dividends
797797
- long_term_capital_gains:
798798
- ltcg
799+
# psemp/ssemp (self-employment) and pbusinc/sbusinc (active QBI) both
800+
# map to self_employment_income: active-participation income subject to
801+
# SECA that flows into the § 199A QBID without the SSTB phaseout. Per
802+
# Dan Feenberg's spec, businc is identical to semp (active + SECA +
803+
# QBID-without-phaseout), so they share one PolicyEngine variable.
804+
# Do NOT map to qualified_business_income: that PE variable is COMPUTED
805+
# (from self_employment_income et al. via the QBI income_definition), so
806+
# supplying it as a dataset input holds it at that value and zeroes every
807+
# other record's non-SSTB QBI. Per-person routing (psemp+pbusinc ->
808+
# primary, ssemp+sbusinc -> spouse) is handled in the mapper code.
799809
- self_employment_income:
800810
- psemp
801811
- ssemp
802-
- s_corp_income:
803-
- scorp
804-
- qualified_business_income:
805812
- pbusinc
813+
- sbusinc
814+
# scorp maps to partnership_s_corp_income, the variable named in the QBI
815+
# income_definition (it adds partnership_income + s_corp_income). Mapping
816+
# to the leaf s_corp_income instead held it as a dataset input, which
817+
# suppressed its uprating formula; and with qualified_business_income also
818+
# held at 0, S-corp income produced correct AGI but no QBID.
819+
- partnership_s_corp_income:
820+
- scorp
821+
# pprofinc/sprofinc (SSTB professional-service income) map to
822+
# sstb_self_employment_income: subject to SECA, QBID WITH the
823+
# § 199A(d)(3) applicable-percentage phaseout. PE tracks SSTB income in
824+
# this dedicated input so the phaseout reduces only the SSTB component;
825+
# no business_is_sstb flag is needed on this path.
806826
- sstb_self_employment_income:
807827
- pprofinc
808828
- sprofinc
@@ -894,7 +914,10 @@ taxsim_input_definition:
894914
- scorp:
895915
name: "26. S-Corp profits"
896916
- pbusinc:
897-
name: "27. Primary and secondary Taxpayer's active QBI"
917+
name: "27. Primary taxpayer's active QBI"
918+
pair: sbusinc
919+
- sbusinc:
920+
name: "27b. Spouse's active QBI"
898921
- pprofinc:
899922
name: "28. Primary taxpayer's SSTB self-employment income"
900923
pair: sprofinc

policyengine_taxsim/core/input_mapper.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def add_additional_units(state, year, situation, taxsim_vars):
2727
"taxable_interest_income",
2828
"qualified_dividend_income",
2929
"long_term_capital_gains",
30-
"s_corp_income",
30+
"partnership_s_corp_income",
3131
"taxable_private_pension_income",
3232
"short_term_capital_gains",
3333
"social_security_retirement",
@@ -69,12 +69,24 @@ def add_additional_units(state, year, situation, taxsim_vars):
6969
continue
7070

7171
if field == "self_employment_income":
72-
if "psemp" in taxsim_vars:
73-
people_unit["you"][field] = {str(year): taxsim_vars.get("psemp", 0)}
74-
if "your partner" in people_unit and "ssemp" in taxsim_vars:
75-
people_unit["your partner"][field] = {
76-
str(year): taxsim_vars.get("ssemp", 0)
77-
}
72+
# psemp (self-employment) and pbusinc (active QBI) both feed the
73+
# primary's self_employment_income; ssemp and sbusinc feed the
74+
# spouse's. Both income types are active-participation, SECA-
75+
# bearing, and QBID-eligible without the SSTB phaseout, so they
76+
# share one PolicyEngine variable (Dan Feenberg: businc is, by
77+
# his spec, identical to semp). Summing here — rather than
78+
# giving pbusinc a second field — keeps one input from
79+
# overwriting the other.
80+
primary_se = taxsim_vars.get("psemp", 0) + taxsim_vars.get("pbusinc", 0)
81+
if "psemp" in taxsim_vars or "pbusinc" in taxsim_vars:
82+
people_unit["you"][field] = {str(year): primary_se}
83+
if "your partner" in people_unit and (
84+
"ssemp" in taxsim_vars or "sbusinc" in taxsim_vars
85+
):
86+
spouse_se = taxsim_vars.get("ssemp", 0) + taxsim_vars.get(
87+
"sbusinc", 0
88+
)
89+
people_unit["your partner"][field] = {str(year): spouse_se}
7890

7991
elif field == "sstb_self_employment_income":
8092
if "pprofinc" in taxsim_vars:

policyengine_taxsim/core/yaml_generator.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,12 @@ def generate_yaml(
189189
"long_term_capital_gains",
190190
"short_term_capital_gains",
191191
"rental_income",
192-
"s_corp_income",
193-
"qualified_business_income",
192+
# scorp -> partnership_s_corp_income (QBID-bearing aggregate);
193+
# pprofinc/sprofinc -> sstb_self_employment_income (QBID with
194+
# SSTB phaseout). pbusinc now folds into self_employment_income,
195+
# so qualified_business_income is no longer set as an input.
196+
"partnership_s_corp_income",
197+
"sstb_self_employment_income",
194198
"w2_wages_from_qualified_business",
195199
"business_is_sstb",
196200
"business_is_qualified",

policyengine_taxsim/runners/policyengine_runner.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ def _initialize_dataset_structure(self) -> dict:
214214
"taxable_interest_income",
215215
"qualified_dividend_income",
216216
"long_term_capital_gains",
217-
"s_corp_income",
217+
"partnership_s_corp_income",
218218
"short_term_capital_gains",
219219
}
220220
)
@@ -290,6 +290,20 @@ def accessor(row):
290290

291291
return accessor
292292

293+
@staticmethod
294+
def _make_sum(*source_fields):
295+
"""Return a callable summing several TAXSIM columns for one person.
296+
297+
Used where two per-person TAXSIM inputs feed a single PolicyEngine
298+
variable — e.g. psemp (self-employment) + pbusinc (active QBI) both
299+
populate the primary's self_employment_income. Each column is already
300+
per-person in TAXSIM-35, so no MFJ splitting is applied here."""
301+
302+
def accessor(row):
303+
return sum(float(row.get(f, 0)) for f in source_fields)
304+
305+
return accessor
306+
293307
@classmethod
294308
def _make_age_gated_primary(cls, source_field):
295309
"""Allocate age-gated income (pension, gssi) to the primary filer's
@@ -383,6 +397,21 @@ def _get_taxsim_to_pe_variable_mapping(self) -> dict:
383397
if not taxsim_vars: # Skip empty mappings
384398
continue
385399

400+
if pe_var == "self_employment_income":
401+
# psemp + pbusinc feed the primary's self_employment_income;
402+
# ssemp + sbusinc feed the spouse's. Both are per-person
403+
# TAXSIM columns (no household splitting), so sum each
404+
# spouse's own pair. Handled explicitly because the
405+
# generic length-based branches below only read the first
406+
# one or two columns of the list.
407+
variable_mapping[pe_var] = {
408+
"primary": self._make_sum("psemp", "pbusinc"),
409+
"spouse": self._make_sum("ssemp", "sbusinc"),
410+
"dependent": 0.0,
411+
"default": 0.0,
412+
}
413+
continue
414+
386415
if len(taxsim_vars) == 1:
387416
# Single variable - check if it has a spouse pair
388417
taxsim_var = taxsim_vars[0]

policyengine_taxsim/runners/taxsim_runner.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ class TaxsimRunner(BaseTaxRunner):
6969
"childcare", # Child care expenses
7070
"mortgage", # Mortgage interest
7171
"scorp", # S-Corp profits
72+
"pbusinc", # Primary taxpayer's active QBI (QBID, no SSTB phaseout)
73+
"sbusinc", # Spouse's active QBI
74+
"pprofinc", # Primary taxpayer's SSTB self-employment income
75+
"sprofinc", # Spouse's SSTB self-employment income
7276
"idtl", # Output control
7377
]
7478

0 commit comments

Comments
 (0)