From a01a52d259a385d583cdea870413b6e8a55e3925 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 21:44:06 -0700 Subject: [PATCH 01/13] Add contributed owners draw SECA reform kernel --- ...orm-pass-through-owners-draw-seca.added.md | 1 + docs/contributed_reforms/owners_draw.md | 163 +++++++++ .../ssa/owners_draw/benefit_credit.yaml | 7 + .../ssa/owners_draw/half_seca_deduction.yaml | 10 + .../contrib/ssa/owners_draw/in_effect.yaml | 10 + .../contrib/ssa/owners_draw/include_hi.yaml | 7 + .../material_participation_only.yaml | 10 + .../ssa/owners_draw/net_earnings_factor.yaml | 10 + .../ssa/owners_draw/phase_in_share.yaml | 7 + .../ssa/owners_draw/phase_in_years.yaml | 7 + .../ssa/owners_draw/room_cap_basis.yaml | 6 + .../contrib/ssa/owners_draw/start_year.yaml | 7 + policyengine_us/reforms/reforms.py | 3 + policyengine_us/reforms/ssa/owners_draw.py | 331 ++++++++++++++++++ policyengine_us/system.py | 10 + .../gov/ssa/social_security/ss_aime.py | 6 +- 16 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 changelog.d/reform-pass-through-owners-draw-seca.added.md create mode 100644 docs/contributed_reforms/owners_draw.md create mode 100644 policyengine_us/parameters/gov/contrib/ssa/owners_draw/benefit_credit.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/owners_draw/half_seca_deduction.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/owners_draw/in_effect.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/owners_draw/include_hi.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/owners_draw/material_participation_only.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/owners_draw/net_earnings_factor.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/owners_draw/phase_in_share.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/owners_draw/phase_in_years.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/owners_draw/room_cap_basis.yaml create mode 100644 policyengine_us/parameters/gov/contrib/ssa/owners_draw/start_year.yaml create mode 100644 policyengine_us/reforms/ssa/owners_draw.py diff --git a/changelog.d/reform-pass-through-owners-draw-seca.added.md b/changelog.d/reform-pass-through-owners-draw-seca.added.md new file mode 100644 index 00000000000..a343da656a3 --- /dev/null +++ b/changelog.d/reform-pass-through-owners-draw-seca.added.md @@ -0,0 +1 @@ +Add an opt-in contributed reform applying SECA to owners' draws from pass-through profits, with configurable phase-in, cap, HI, deduction, and benefit-credit treatment. diff --git a/docs/contributed_reforms/owners_draw.md b/docs/contributed_reforms/owners_draw.md new file mode 100644 index 00000000000..f85f7e1c8c0 --- /dev/null +++ b/docs/contributed_reforms/owners_draw.md @@ -0,0 +1,163 @@ +# Owners' draw SECA reform + +This contributed reform brings eligible pass-through profits into the SECA +OASDI base. It is off by default. The example configuration phases in a share +of 0.1 in 2027, rising by 0.1 annually to 1 in 2036. These are custom example +settings, not enacted law or a complete implementation of an OACT provision. +Population scoring uses the tax side only. + +## Parameters + +All settings are under `gov.contrib.ssa.owners_draw`. + +| Parameter | Default | Meaning | +| --- | --- | --- | +| `in_effect` | `false` | Enable the reform. | +| `start_year` | `2027` | First affected calendar year. | +| `phase_in_years` | `10` | Positive duration of the linear phase-in. | +| `phase_in_share` | `-1` | Derive the linear share; an annual value in [0, 1] overrides it. | +| `net_earnings_factor` | `0` | Derive the standard SECA factor; a value in (0, 1] overrides it. | +| `material_participation_only` | `true` | Use nonpassive income as the participation proxy. | +| `include_hi` | `false` | Also tax adjusted draw earnings at the uncapped SECA HI rate. | +| `benefit_credit` | `true` | Expose adjusted annual earnings for future benefit-ledger integration. | +| `half_seca_deduction` | `true` | Apply the existing section 164(f) deductible share to added SECA. | +| `room_cap_basis` | `CURRENT_LAW` | Preserve the current-law OASDI cap; `NONE` removes the draw ceiling. | + +The derived share is `min(max((year - start_year + 1) / phase_in_years, 0), 1)`. +Activation and start-year checks still apply to explicit share overrides. +The current-law cap is copied before user reforms, so removing the general +payroll cap does not silently remove this draw ceiling. + +## Input contract and signed losses + +For each Person, define total signed entity income +`T = s_corp_income + partnership_income`, passive subset +`Q = passive_partnership_s_corp_income`, and already-SE partnership subset +`K = partnership_self_employment_net_earnings`. Both Q and K are already in T; +K is a gross source amount before SECA's net-earnings adjustment. + +With disjoint passive and already-SE subsets, the signed active residual is +`G = T - Q - K`. If both subsets are nonzero, either supply +`owners_draw_subsets_are_disjoint: true` or the signed intersection in +`owners_draw_passive_se_overlap`. The latter yields +`K_active = K - overlap` and `G = T - Q - K_active`. Ambiguous classifications +raise an error. If either subset is zero, disjointness follows without an +additional assertion. With `material_participation_only: false`, include the +passive subset and subtract the entire already-SE K instead. + +Signed profits and losses net within the owner's supplied entity totals +before flooring the residual. This is an approximation without entity-level +detail. A negative residual does not offset wages or existing SE earnings. +[26 U.S.C. 1402(a)](https://www.law.cornell.edu/uscode/text/26/1402#a) +provides the comparable income-minus-deductions approach for net SE earnings; +its existing exclusions are not a legal basis for this contributed expansion. +Nonpassive status is a material-participation proxy and does not identify +every legal limited-partner class. + +## Gross draw, adjusted earnings, and cap priority + +The planned gross draw is `D_planned = share * max(G, 0)`. The default +netting factor is the existing model expression: + +```text +q = 1 - gov.irs.ald.misc.employer_share + * (gov.irs.self_employment.rate.social_security + + gov.irs.self_employment.rate.medicare) +``` + +Current rates produce 0.9235. Rate reforms flow through q, including when HI +on the incremental draw is off. An explicit factor override is available. +The planned adjusted draw `q * D_planned` must independently meet the existing +`gov.irs.self_employment.net_earnings_exemption` ($400 at current settings). +Otherwise actual gross draw D and adjusted earnings N_D are both zero. +This independent floor preserves existing SE computations; it is a specific +modeling choice rather than an integrated new SECA statute. + +Let W be existing taxable wages, S the already adjusted and capped existing +`social_security_taxable_self_employment_income`, and C the preserved cap. +For the default cap basis: + +```text +room = max(C - W - S, 0) +draw_oasdi_base = min(N_D, room) +added_OASDI = draw_oasdi_base * SECA_OASDI_rate +``` + +Wages have priority, then existing SE earnings, then the draw. The independent +draw outputs leave the existing SE base intact. Added tax enters the existing +SECA liability variables, so payroll and OASDI accounting include it once. +HI inclusion adds `N_D * SECA_HI_rate` without the OASDI ceiling. The deduction +uses `gov.irs.ald.self_employment_tax.percent_deductible`, distinct from the +employer-share parameter used to derive q, and retains nondependent aggregation. +No second copy of the source income is added to gross income or AGI. + +Outputs include `owners_draw_active_income`, `owners_draw_planned_gross`, +`owners_draw_gross`, `owners_draw_net_earnings_factor`, +`owners_draw_net_earnings`, `owners_draw_oasdi_room`, +`owners_draw_oasdi_base`, `owners_draw_oasdi_tax`, and `owners_draw_hi_tax`. + +For the current-law cap basis, the taxed draw is nonnegative and no larger +than remaining room, adjusted earnings, or actual gross draw. Adding the +draw cannot push otherwise capped covered earnings beyond C. Increasing the +phase-in share cannot reduce draw tax when other inputs and settings stay +fixed. Already-SE income is removed before computing the draw, and disabled +years retain baseline tax and deduction behavior. The `NONE` cap option +intentionally permits earnings above C. + +## Benefit credit and active NIIT interaction + +`owners_draw_credited_earnings` exposes N_D when benefit credit is enabled, +and zero otherwise. The flag has no tax effect. A future annual-history +benefit ledger must add these earnings before applying that year's benefit +cap; this reform does not append an amount to final AIME or alter reported +benefits. Conventional and mini-PIA history integration remain separate work. + +`owners_draw_niit_exclusion` is the matching gross-profit exclusion for a +subsequent active-NIIT reform. With OASDI only it is +`draw_oasdi_base / q`: only profits whose adjusted earnings actually face +SECA are excluded. With HI enabled it is the entire actual gross draw D, +because N_D faces uncapped HI. Profits beyond OASDI room remain in the active +base when HI is off. The undrawn share and any floor-rejected planned draw +also remain. Existing already-SE gross K_active must separately be excluded +regardless of the OASDI cap because existing SE earnings face uncapped HI. +No active-NIIT reform is integrated here; its subsequent PR must consume +this output and reconcile its signed residual and thresholds. + +## Data limitations + +The intended field contract is separate S-corporation and partnership totals, +with the combined section 469 passive subset subtracted once and the +already-SE partnership subset removed without double counting. + +Current production exports force `s_corp_income` to zero and put combined +nonpassive S-corporation and partnership donor income into `partnership_income`. +Calibration carries passive income mass into that combined column, while +`passive_partnership_s_corp_income` is absent and defaults to zero. +`partnership_self_employment_net_earnings` is present. An absent passive +column does not establish that passive income is zero. This can overstate the +active residual and makes population results provisional. + +[Microcosm #1251](https://github.com/PolicyEngine/microcosm/pull/1251) repairs +the total/passive contract, and +[#1261](https://github.com/PolicyEngine/microcosm/pull/1261) separates entity +totals. Population scoring requires a pinned artifact containing these +repairs and a receipt validating the stored columns and their meanings. +No population impact is measured by this PR; the hub will measure it. + +## Comparable OACT provisions + +OACT's current [coverage provision F12](https://www.ssa.gov/OACT/solvency/provisions/coverage.html) +says, “make all distributions to all pass-through business owners up to the +earnings cap subject to the SECA tax”. Its current table starts in 2029 and +requires material participation. This reform's example 2027–2036 phase-in +and default OASDI-only treatment are distinct choices. + +OACT's current [payroll-tax provisions](https://www.ssa.gov/OACT/solvency/provisions/payrolltax.html) +separate rate increases, taxation above the taxable maximum, and partial +taxation above it. The older E5 label belongs to the +[legacy contribution-and-benefit-base table](https://www.ssa.gov/OACT/solvency/provisions/wagebase.html), +which uses the 2008 Trustees assumptions. E5 says, “Raise the taxable maximum +amount (the contribution and benefit base) to include 90 percent of total +OASDI covered earnings.” Its phased higher cap also earns benefit credit. +It is not a current 2026 E5 category. Cap changes and benefit-credit choices +are distinct from expanding the owners' draw base. diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/benefit_credit.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/benefit_credit.yaml new file mode 100644 index 00000000000..7bb3f6f9e37 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/benefit_credit.yaml @@ -0,0 +1,7 @@ +description: Whether adjusted owners' draw earnings are available for annual covered-earnings benefit computation; this flag has no tax effect. +values: + 0000-01-01: true +metadata: + unit: bool + label: Owners' draw benefit credit + economy: false diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/half_seca_deduction.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/half_seca_deduction.yaml new file mode 100644 index 00000000000..39e9d8730d9 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/half_seca_deduction.yaml @@ -0,0 +1,10 @@ +description: Whether added owners' draw SECA liability qualifies for the deduction at the existing section 164(f) deductible share. +values: + 0000-01-01: true +metadata: + unit: bool + label: Owners' draw SECA deduction + economy: false + reference: + - title: 26 U.S.C. 164(f) + href: https://www.law.cornell.edu/uscode/text/26/164#f diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/in_effect.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/in_effect.yaml new file mode 100644 index 00000000000..a3078eadb25 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/in_effect.yaml @@ -0,0 +1,10 @@ +description: Whether the contributed owners' draw reform subjects eligible pass-through profits to SECA. +values: + 0000-01-01: false +metadata: + unit: bool + label: Owners' draw reform in effect + economy: false + reference: + - title: SSA OACT pass-through business distributions provision F12 + href: https://www.ssa.gov/OACT/solvency/provisions/coverage.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/include_hi.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/include_hi.yaml new file mode 100644 index 00000000000..422b13425d9 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/include_hi.yaml @@ -0,0 +1,7 @@ +description: Whether adjusted owners' draw earnings also face uncapped SECA HI tax. +values: + 0000-01-01: false +metadata: + unit: bool + label: Owners' draw HI inclusion + economy: false diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/material_participation_only.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/material_participation_only.yaml new file mode 100644 index 00000000000..178bb205af1 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/material_participation_only.yaml @@ -0,0 +1,10 @@ +description: Whether owners' draw eligibility is restricted to nonpassive income, used as the material-participation proxy. +values: + 0000-01-01: true +metadata: + unit: bool + label: Owners' draw material-participation restriction + economy: false + reference: + - title: SSA OACT pass-through business distributions provision F12 + href: https://www.ssa.gov/OACT/solvency/provisions/coverage.html diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/net_earnings_factor.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/net_earnings_factor.yaml new file mode 100644 index 00000000000..a8429263300 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/net_earnings_factor.yaml @@ -0,0 +1,10 @@ +description: An owners' draw net-earnings factor override in (0, 1]; zero derives one minus the employer share times the sum of the SECA OASDI and HI rates. +values: + 0000-01-01: 0 +metadata: + unit: /1 + label: Owners' draw net-earnings factor override + economy: false + reference: + - title: 26 U.S.C. 1402(a)(12) + href: https://www.law.cornell.edu/uscode/text/26/1402#a_12 diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/phase_in_share.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/phase_in_share.yaml new file mode 100644 index 00000000000..49ad2be64ee --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/phase_in_share.yaml @@ -0,0 +1,7 @@ +description: An annual owners' draw share override in [0, 1]; -1 uses the linear schedule from start_year and phase_in_years. +values: + 0000-01-01: -1 +metadata: + unit: /1 + label: Owners' draw phase-in share override + economy: false diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/phase_in_years.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/phase_in_years.yaml new file mode 100644 index 00000000000..01012f1851e --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/phase_in_years.yaml @@ -0,0 +1,7 @@ +description: The positive number of years over which the owners' draw share rises linearly to one, including the start year. +values: + 0000-01-01: 10 +metadata: + unit: year + label: Owners' draw phase-in years + economy: false diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/room_cap_basis.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/room_cap_basis.yaml new file mode 100644 index 00000000000..50efcc934d0 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/room_cap_basis.yaml @@ -0,0 +1,6 @@ +description: "The owners' draw OASDI ceiling: CURRENT_LAW uses the cap preserved before reforms, and NONE imposes no ceiling." +values: + 0000-01-01: CURRENT_LAW +metadata: + label: Owners' draw OASDI cap basis + economy: false diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/start_year.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/start_year.yaml new file mode 100644 index 00000000000..f0721096f14 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/start_year.yaml @@ -0,0 +1,7 @@ +description: The first calendar year of the contributed owners' draw phase-in when enabled. +values: + 0000-01-01: 2027 +metadata: + unit: year + label: Owners' draw start year + economy: false diff --git a/policyengine_us/reforms/reforms.py b/policyengine_us/reforms/reforms.py index 61fcd3050de..5979e292660 100644 --- a/policyengine_us/reforms/reforms.py +++ b/policyengine_us/reforms/reforms.py @@ -11,6 +11,7 @@ from .cbo.payroll import ( create_increase_taxable_earnings_for_social_security_reform, ) +from .ssa.owners_draw import create_owners_draw_reform from .congress.wyden_smith import create_ctc_expansion_reform from .federal import create_abolish_federal_income_tax_reform from .federal import create_abolish_payroll_tax_reform @@ -367,6 +368,7 @@ def create_structural_reforms_from_parameters(parameters, period): + owners_draw = create_owners_draw_reform(parameters, period) afa_reform = create_american_family_act_with_baby_bonus_reform(parameters, period) winship_reform = create_eitc_winship_reform(parameters, period) dc_kccatc_reform = create_dc_kccatc_reform(parameters, period) @@ -602,6 +604,7 @@ def create_structural_reforms_from_parameters(parameters, period): ) reforms = [ + owners_draw, afa_reform, winship_reform, dc_kccatc_reform, diff --git a/policyengine_us/reforms/ssa/owners_draw.py b/policyengine_us/reforms/ssa/owners_draw.py new file mode 100644 index 00000000000..4be608cd203 --- /dev/null +++ b/policyengine_us/reforms/ssa/owners_draw.py @@ -0,0 +1,331 @@ +"""Optional SECA treatment of owners' signed pass-through residual profits. + +The parallel draw base leaves the existing SE base available for cap ordering +and adds liability to the ordinary SECA tax leaves exactly once. See +docs/contributed_reforms/owners_draw.md for the input and classification contract. +""" + +from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.self_employment.self_employment_social_security_tax import ( + self_employment_social_security_tax as baseline_oasdi_tax, +) +from policyengine_us.variables.gov.irs.tax.self_employment.self_employment_medicare_tax import ( + self_employment_medicare_tax as baseline_hi_tax, +) +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.self_employment_tax_ald_person import ( + self_employment_tax_ald_person as baseline_se_deduction, +) +from policyengine_us.variables.gov.ssa.social_security.ss_aime import ( + ss_aime as baseline_aime, + _compute_aime, +) + + +def _enabled(period, parameters): + p = parameters(period).gov.contrib.ssa.owners_draw + return p.in_effect and period.start.year >= p.start_year + + +def _share(period, parameters): + p = parameters(period).gov.contrib.ssa.owners_draw + if not _enabled(period, parameters): + return 0 + if p.phase_in_years < 1 or int(p.phase_in_years) != p.phase_in_years: + raise ValueError("Owners' draw phase_in_years must be a positive integer") + if p.phase_in_share == -1: + return min(1, (period.start.year - p.start_year + 1) / p.phase_in_years) + if not np.isfinite(p.phase_in_share) or not 0 <= p.phase_in_share <= 1: + raise ValueError("Owners' draw phase_in_share must be -1 or in [0, 1]") + return p.phase_in_share + + +def create_owners_draw(): + class owners_draw_subsets_are_disjoint(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Assert passive and already-SE pass-through subsets are disjoint" + documentation = ( + "Caller-validated assertion for the supplied signed entity totals. " + "Otherwise supply owners_draw_passive_se_overlap when both subsets " + "are nonzero." + ) + + class owners_draw_passive_se_overlap(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + default_value = np.nan + label = "Signed overlap of passive and already-SE partnership income" + documentation = ( + "Intersection already included in both passive pass-through income " + "and partnership SE net earnings, not an additional income source." + ) + + class owners_draw_active_income(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Signed pass-through residual eligible for owners' draw" + reference = "https://www.ssa.gov/OACT/solvency/provisions/coverage.html" + + def formula(person, period, parameters): + if not _enabled(period, parameters): + return 0 + total = add(person, period, ["s_corp_income", "partnership_income"]) + already_se = person("partnership_self_employment_net_earnings", period) + if not parameters( + period + ).gov.contrib.ssa.owners_draw.material_participation_only: + return total - already_se + passive = person("passive_partnership_s_corp_income", period) + disjoint = person("owners_draw_subsets_are_disjoint", period) + overlap = person("owners_draw_passive_se_overlap", period) + unknown_overlap = ~np.isfinite(overlap) + ambiguous = (passive != 0) & (already_se != 0) & ~disjoint & unknown_overlap + if np.any(ambiguous): + raise ValueError( + "Owners' draw needs a validated disjoint-subset assertion " + "or explicit passive/SE overlap when both subsets are nonzero" + ) + if np.any(disjoint & ~unknown_overlap & (overlap != 0)): + raise ValueError( + "Owners' draw disjoint assertion contradicts supplied overlap" + ) + # Net signed entity profits/losses before flooring. The residual + # does not offset wages or the preexisting SE earnings base. + return total - passive - already_se + where(unknown_overlap, 0, overlap) + + class owners_draw_planned_gross(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Planned gross owners' draw before the separate reporting floor" + + def formula(person, period, parameters): + return _share(period, parameters) * max_( + 0, person("owners_draw_active_income", period) + ) + + class owners_draw_net_earnings_factor(Variable): + value_type = float + entity = Person + definition_period = YEAR + label = "Net earnings factor applied to owners' draw" + + def formula(person, period, parameters): + if not _enabled(period, parameters): + return 0 + p = parameters(period).gov + factor = p.contrib.ssa.owners_draw.net_earnings_factor + if factor == 0: + factor = 1 - p.irs.ald.misc.employer_share * ( + p.irs.self_employment.rate.social_security + + p.irs.self_employment.rate.medicare + ) + if not np.isfinite(factor) or not 0 < factor <= 1: + raise ValueError("Owners' draw net earnings factor must be in (0, 1]") + return factor + + class owners_draw_gross(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Actual gross owners' draw accepted by the reporting floor" + + def formula(person, period, parameters): + planned = person("owners_draw_planned_gross", period) + adjusted = planned * person("owners_draw_net_earnings_factor", period) + floor = parameters(period).gov.irs.self_employment.net_earnings_exemption + return where(adjusted >= floor, planned, 0) + + class owners_draw_net_earnings(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Adjusted owners' draw before the OASDI room cap" + + def formula(person, period, parameters): + return person("owners_draw_gross", period) * person( + "owners_draw_net_earnings_factor", period + ) + + class owners_draw_oasdi_room(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "OASDI room after wages and existing adjusted SE earnings" + + def formula(person, period, parameters): + if not _enabled(period, parameters): + return 0 + p = parameters(period).gov.contrib.ssa.owners_draw + if p.room_cap_basis == "NONE": + return np.inf + if p.room_cap_basis != "CURRENT_LAW": + raise ValueError( + "Owners' draw room_cap_basis must be CURRENT_LAW or NONE" + ) + wages = person("taxable_earnings_for_social_security", period) + existing_se = person( + "social_security_taxable_self_employment_income", period + ) + # The existing base is already adjusted and capped; #10376 also + # floors its room when another reform taxes above-cap wages. + return max_(0, p.current_law_cap - wages - existing_se) + + class owners_draw_oasdi_base(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Owners' draw subject to SECA OASDI" + + def formula(person, period, parameters): + return min_( + person("owners_draw_net_earnings", period), + person("owners_draw_oasdi_room", period), + ) + + class owners_draw_oasdi_tax(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Incremental SECA OASDI tax on owners' draw" + + def formula(person, period, parameters): + rate = parameters(period).gov.irs.self_employment.rate.social_security + return rate * person("owners_draw_oasdi_base", period) + + class owners_draw_hi_tax(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Optional incremental SECA HI tax on owners' draw" + + def formula(person, period, parameters): + if not parameters(period).gov.contrib.ssa.owners_draw.include_hi: + return 0 + rate = parameters(period).gov.irs.self_employment.rate.medicare + return rate * person("owners_draw_net_earnings", period) + + class owners_draw_niit_exclusion(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Gross owners' profits subject to SECA for active NIIT exclusion" + documentation = ( + "Subtract from a broadened active NIIT residual: OASDI base divided " + "by the netting factor, or the whole actual gross draw with HI on. " + "Existing gross active partnership SE earnings are excluded separately." + ) + + def formula(person, period, parameters): + if not _enabled(period, parameters): + return 0 + if parameters(period).gov.contrib.ssa.owners_draw.include_hi: + return person("owners_draw_gross", period) + return person("owners_draw_oasdi_base", period) / person( + "owners_draw_net_earnings_factor", period + ) + + class owners_draw_credited_earnings(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Adjusted owners' draw available to an annual benefit ledger" + documentation = ( + "Add to annual covered earnings before the ordinary benefit cap. " + "This flag has no effect on tax liability or reported benefits." + ) + + def formula(person, period, parameters): + if not parameters(period).gov.contrib.ssa.owners_draw.benefit_credit: + return 0 + return person("owners_draw_net_earnings", period) + + class self_employment_social_security_tax(baseline_oasdi_tax): + def formula(person, period, parameters): + return baseline_oasdi_tax.formula(person, period, parameters) + person( + "owners_draw_oasdi_tax", period + ) + + class self_employment_medicare_tax(baseline_hi_tax): + def formula(person, period, parameters): + return baseline_hi_tax.formula(person, period, parameters) + person( + "owners_draw_hi_tax", period + ) + + class self_employment_tax_ald_person(baseline_se_deduction): + def formula(person, period, parameters): + if not _enabled(period, parameters): + return baseline_se_deduction.formula(person, period, parameters) + p = parameters(period).gov + deductible_tax = person("self_employment_tax", period) + if not p.contrib.ssa.owners_draw.half_seca_deduction: + deductible_tax = max_( + 0, + deductible_tax + - add( + person, period, ["owners_draw_oasdi_tax", "owners_draw_hi_tax"] + ), + ) + return deductible_tax * p.irs.ald.self_employment_tax.percent_deductible + + class ss_aime(baseline_aime): + def formula(person, period, parameters): + supplied = person("ss_aime_input", period) + computed = _compute_aime( + person, + period, + parameters, + additional_earnings_variable="owners_draw_credited_earnings", + ) + return where(supplied > 0, supplied, computed) + + class reform(Reform): + def apply(self): + for variable in ( + owners_draw_subsets_are_disjoint, + owners_draw_passive_se_overlap, + owners_draw_active_income, + owners_draw_planned_gross, + owners_draw_net_earnings_factor, + owners_draw_gross, + owners_draw_net_earnings, + owners_draw_oasdi_room, + owners_draw_oasdi_base, + owners_draw_oasdi_tax, + owners_draw_hi_tax, + owners_draw_niit_exclusion, + owners_draw_credited_earnings, + ): + self.update_variable(variable) + self.update_variable(self_employment_social_security_tax) + self.update_variable(self_employment_medicare_tax) + self.update_variable(self_employment_tax_ald_person) + self.update_variable(ss_aime) + + return reform + + +def create_owners_draw_reform(parameters, period, bypass=False): + if bypass or any( + value.value + for value in parameters.gov.contrib.ssa.owners_draw.in_effect.values_list + ): + return create_owners_draw() + return None + + +owners_draw = create_owners_draw_reform(None, None, bypass=True) diff --git a/policyengine_us/system.py b/policyengine_us/system.py index 1b8a070ec6b..2346e4acd15 100644 --- a/policyengine_us/system.py +++ b/policyengine_us/system.py @@ -137,6 +137,16 @@ def __init__( self.parameters, first_instant=f"{FIRST_MODELED_YEAR}-01-01" ) + # Preserve the processed current-law path before user reforms can + # remove/change the payroll cap. Owners' draw can keep this ceiling + # independently or explicitly select no ceiling. + draw_cap = self.parameters.gov.irs.payroll.social_security.cap.clone() + draw_cap.name = "gov.contrib.ssa.owners_draw.current_law_cap" + draw_cap.metadata = {"unit": "currency-USD", "economy": False} + self.parameters.gov.contrib.ssa.owners_draw.add_child( + "current_law_cap", draw_cap + ) + if reform: # Applied after the parameter processing pipeline so that values # a reform inserts at future dates cannot act as defined values diff --git a/policyengine_us/variables/gov/ssa/social_security/ss_aime.py b/policyengine_us/variables/gov/ssa/social_security/ss_aime.py index 6578620d639..b0d1bc89c81 100644 --- a/policyengine_us/variables/gov/ssa/social_security/ss_aime.py +++ b/policyengine_us/variables/gov/ssa/social_security/ss_aime.py @@ -30,7 +30,7 @@ def formula(person, period, parameters): return where(has_input, aime_input, computed_aime) -def _compute_aime(person, period, parameters): +def _compute_aime(person, period, parameters, additional_earnings_variable=None): age = person("age", period) current_year = period.start.year @@ -55,6 +55,10 @@ def _compute_aime(person, period, parameters): employment_income = person("employment_income", year_period) self_employment_income = person("total_self_employment_income", year_period) total_earnings = employment_income + self_employment_income + if additional_earnings_variable is not None: + total_earnings = total_earnings + person( + additional_earnings_variable, year_period + ) wage_base = parameters(year_period).gov.ssa.social_security.wage_base covered_earnings = min_(total_earnings, wage_base) From b16cda9d20973b191ef60ae00e279565b086cc35 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 22:09:04 -0700 Subject: [PATCH 02/13] Test owners draw invariants and enable bounded SSA YAML coverage --- Makefile | 8 +- docs/contributed_reforms/owners_draw.md | 19 +- .../ssa/owners_draw/room_cap_basis.yaml | 5 +- policyengine_us/reforms/ssa/owners_draw.py | 6 +- policyengine_us/system.py | 5 +- .../tests/policy/contrib/ssa/owners_draw.yaml | 368 ++++++++++++++++++ .../ssa/owners_draw_credited_earnings.yaml | 72 ++++ .../contrib/ssa/owners_draw_net_earnings.yaml | 114 ++++++ .../tests/reforms/test_owners_draw.py | 363 +++++++++++++++++ 9 files changed, 943 insertions(+), 17 deletions(-) create mode 100644 policyengine_us/tests/policy/contrib/ssa/owners_draw.yaml create mode 100644 policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml create mode 100644 policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml create mode 100644 policyengine_us/tests/reforms/test_owners_draw.py diff --git a/Makefile b/Makefile index 69c9172f058..2a8c1e88443 100644 --- a/Makefile +++ b/Makefile @@ -166,10 +166,10 @@ test-yaml-structural-heavy-shard-4: test-yaml-structural-other: # Per-subdir so every remaining contrib folder runs in its own subprocess, # instead of stacking ~20 light files into one ~13-min catch-all batch that - # risked the 30-min per-batch timeout on slow runners. ssa is excluded (it - # has no YAML tests — only a pytest .py run elsewhere). --workers 1 so a - # single ~1-5 GB peak is resident at a time, leaving headroom for growth. - $(BATCH) $(TESTS)/policy/contrib --exclude states,ctc,ubi_center,federal,harris,treasury,crfb,congress,refundable_credit_conversion,ssa --mode per-subdir --workers 1 + # risked the 30-min per-batch timeout on slow runners. SSA owners' draw + # cases use this runner, split by the existing reform-combination budget. + # --workers 1 leaves headroom between bounded subprocesses. + $(BATCH) $(TESTS)/policy/contrib --exclude states,ctc,ubi_center,federal,harris,treasury,crfb,congress,refundable_credit_conversion --mode per-subdir --workers 1 test-yaml-structural-other-shard-2a: # ctc is microsim-heavy: per-file isolation frees each ~5 GB peak between # files, and --workers 1 keeps only one peak resident at a time. diff --git a/docs/contributed_reforms/owners_draw.md b/docs/contributed_reforms/owners_draw.md index f85f7e1c8c0..10820767655 100644 --- a/docs/contributed_reforms/owners_draw.md +++ b/docs/contributed_reforms/owners_draw.md @@ -19,9 +19,9 @@ All settings are under `gov.contrib.ssa.owners_draw`. | `net_earnings_factor` | `0` | Derive the standard SECA factor; a value in (0, 1] overrides it. | | `material_participation_only` | `true` | Use nonpassive income as the participation proxy. | | `include_hi` | `false` | Also tax adjusted draw earnings at the uncapped SECA HI rate. | -| `benefit_credit` | `true` | Expose adjusted annual earnings for future benefit-ledger integration. | +| `benefit_credit` | `true` | Add adjusted annual draw earnings to the existing AIME earnings history before its annual cap. | | `half_seca_deduction` | `true` | Apply the existing section 164(f) deductible share to added SECA. | -| `room_cap_basis` | `CURRENT_LAW` | Preserve the current-law OASDI cap; `NONE` removes the draw ceiling. | +| `room_cap_basis` | `0` | Preserve the current-law OASDI cap; `1` removes the draw ceiling. | The derived share is `min(max((year - start_year + 1) / phase_in_years, 0), 1)`. Activation and start-year checks still apply to explicit share overrides. @@ -101,16 +101,21 @@ than remaining room, adjusted earnings, or actual gross draw. Adding the draw cannot push otherwise capped covered earnings beyond C. Increasing the phase-in share cannot reduce draw tax when other inputs and settings stay fixed. Already-SE income is removed before computing the draw, and disabled -years retain baseline tax and deduction behavior. The `NONE` cap option +years retain baseline tax and deduction behavior. The `1` cap option intentionally permits earnings above C. ## Benefit credit and active NIIT interaction `owners_draw_credited_earnings` exposes N_D when benefit credit is enabled, -and zero otherwise. The flag has no tax effect. A future annual-history -benefit ledger must add these earnings before applying that year's benefit -cap; this reform does not append an amount to final AIME or alter reported -benefits. Conventional and mini-PIA history integration remain separate work. +and zero otherwise. The flag has no tax effect. The reform passes these +annual earnings to the existing `ss_aime` earnings-history helper and adds +them before applying each year's wage-base cap. A supplied positive +`ss_aime_input` retains its baseline precedence. This reform does not append +an amount to final AIME or alter reported benefits. This bridge inherits +main's AIME lookback and age rules and its separate +`gov.ssa.social_security.wage_base` path, last authored for 2026; it does not +synchronize that path with the current tax cap. Mini-PIA integration and a +complete annual-history ledger remain separate future work. `owners_draw_niit_exclusion` is the matching gross-profit exclusion for a subsequent active-NIIT reform. With OASDI only it is diff --git a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/room_cap_basis.yaml b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/room_cap_basis.yaml index 50efcc934d0..5a913520cad 100644 --- a/policyengine_us/parameters/gov/contrib/ssa/owners_draw/room_cap_basis.yaml +++ b/policyengine_us/parameters/gov/contrib/ssa/owners_draw/room_cap_basis.yaml @@ -1,6 +1,7 @@ -description: "The owners' draw OASDI ceiling: CURRENT_LAW uses the cap preserved before reforms, and NONE imposes no ceiling." +description: "The owners' draw OASDI ceiling: 0 uses the current-law cap preserved before reforms, and 1 imposes no ceiling." values: - 0000-01-01: CURRENT_LAW + 0000-01-01: 0 metadata: + unit: int label: Owners' draw OASDI cap basis economy: false diff --git a/policyengine_us/reforms/ssa/owners_draw.py b/policyengine_us/reforms/ssa/owners_draw.py index 4be608cd203..6893479ddd7 100644 --- a/policyengine_us/reforms/ssa/owners_draw.py +++ b/policyengine_us/reforms/ssa/owners_draw.py @@ -166,11 +166,11 @@ def formula(person, period, parameters): if not _enabled(period, parameters): return 0 p = parameters(period).gov.contrib.ssa.owners_draw - if p.room_cap_basis == "NONE": + if p.room_cap_basis == 1: return np.inf - if p.room_cap_basis != "CURRENT_LAW": + if p.room_cap_basis != 0: raise ValueError( - "Owners' draw room_cap_basis must be CURRENT_LAW or NONE" + "Owners' draw room_cap_basis must be 0 (current law) or 1 (no ceiling)" ) wages = person("taxable_earnings_for_social_security", period) existing_se = person( diff --git a/policyengine_us/system.py b/policyengine_us/system.py index 2346e4acd15..9bd6e6ee452 100644 --- a/policyengine_us/system.py +++ b/policyengine_us/system.py @@ -142,7 +142,10 @@ def __init__( # independently or explicitly select no ceiling. draw_cap = self.parameters.gov.irs.payroll.social_security.cap.clone() draw_cap.name = "gov.contrib.ssa.owners_draw.current_law_cap" - draw_cap.metadata = {"unit": "currency-USD", "economy": False} + draw_cap.metadata.update( + label="Current-law OASDI cap preserved for owners' draw", economy=False + ) + draw_cap.metadata.pop("uprating", None) self.parameters.gov.contrib.ssa.owners_draw.add_child( "current_law_cap", draw_cap ) diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw.yaml new file mode 100644 index 00000000000..d706b3c60df --- /dev/null +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw.yaml @@ -0,0 +1,368 @@ +# Synthetic caps are fixed in both the ordinary tax parameter and the captured +# current-law room parameter, so these examples do not depend on projections. +# Unless overridden, q = 1 - 0.5 * (0.124 + 0.029) = 0.9235. +# Policy cases are grouped to retain at most five cached reform combinations. +- name: Full draw for an S-corporation owner without wages + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 150_000 + output: + owners_draw_active_income: 150_000 + owners_draw_planned_gross: 150_000 + owners_draw_gross: 150_000 + owners_draw_net_earnings: 138_525 + owners_draw_oasdi_room: 200_000 + owners_draw_oasdi_base: 138_525 + # 138,525 * 12.4% = 17,177.10; HI defaults off. + owners_draw_oasdi_tax: 17_177.10 + owners_draw_hi_tax: 0 + self_employment_social_security_tax: 17_177.10 + self_employment_medicare_tax: 0 + self_employment_tax_ald_person: 8_588.55 + owners_draw_credited_earnings: 138_525 + owners_draw_niit_exclusion: 150_000 + # The source remains in gross income exactly once. + irs_gross_income: 150_000 + + +- name: Wages leave only 100000 of room for 200000 of S-corporation profits + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + payroll_tax_gross_wages: 100_000 + s_corp_income: 200_000 + output: + owners_draw_gross: 200_000 + owners_draw_net_earnings: 184_700 + owners_draw_oasdi_room: 100_000 + owners_draw_oasdi_base: 100_000 + owners_draw_oasdi_tax: 12_400 + owners_draw_hi_tax: 0 + self_employment_tax_ald_person: 6_200 + # M7: only profits whose net earnings face OASDI leave active NIIT. + # 100,000 / 0.9235 = 108,283.7033, rather than the whole draw. + owners_draw_niit_exclusion: 108_283.7033 + owners_draw_credited_earnings: 184_700 + + +- name: Nonpassive limited-partner profits enter the draw while general-partner SE profits do not + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + partnership_income: [100_000, 100_000] + partnership_self_employment_net_earnings: [0, 100_000] + output: + owners_draw_active_income: [100_000, 0] + owners_draw_gross: [100_000, 0] + owners_draw_net_earnings: [92_350, 0] + taxable_self_employment_income: [0, 92_350] + social_security_taxable_self_employment_income: [0, 92_350] + owners_draw_oasdi_base: [92_350, 0] + owners_draw_oasdi_tax: [11_451.40, 0] + # Both have the same OASDI liability; the general partner has it already. + self_employment_social_security_tax: [11_451.40, 11_451.40] + + +- name: The third default phase-in year reclassifies 30 percent + period: 2029 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 150_000 + output: + owners_draw_gross: 45_000 + owners_draw_net_earnings: 41_557.50 + owners_draw_oasdi_base: 41_557.50 + owners_draw_oasdi_tax: 5_153.13 + owners_draw_niit_exclusion: 45_000 + + +- name: An above-cap worker has a gross draw but no incremental OASDI or default NIIT exclusion + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + payroll_tax_gross_wages: 250_000 + s_corp_income: 150_000 + output: + owners_draw_gross: 150_000 + owners_draw_net_earnings: 138_525 + owners_draw_oasdi_room: 0 + owners_draw_oasdi_base: 0 + owners_draw_oasdi_tax: 0 + owners_draw_niit_exclusion: 0 + owners_draw_credited_earnings: 138_525 + + +- name: Signed entity profits and subsets net before the draw floor + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + # T-Q-K: 100-20-30=50; 100-(-20)-30=90; 50-20=30; + # -50+20=-30; and (-20-10)-(-10)=-20, all in thousands. + s_corp_income: [50_000, 50_000, 50_000, -50_000, -20_000] + partnership_income: [50_000, 50_000, -20_000, 20_000, -10_000] + passive_partnership_s_corp_income: [20_000, -20_000, 0, 0, 0] + partnership_self_employment_net_earnings: [30_000, 30_000, 0, 0, -10_000] + owners_draw_subsets_are_disjoint: true + self_employment_income: [0, 0, 0, 50_000, 50_000] + output: + owners_draw_active_income: [50_000, 90_000, 30_000, -30_000, -20_000] + owners_draw_gross: [50_000, 90_000, 30_000, 0, 0] + owners_draw_net_earnings: [46_175, 83_115, 27_705, 0, 0] + # E1 residual losses do not offset preexisting SE income. + taxable_self_employment_income: [27_705, 27_705, 0, 46_175, 36_940] + + +- name: A supplied passive and already-SE intersection is subtracted only once + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + partnership_income: 100_000 + passive_partnership_s_corp_income: 30_000 + partnership_self_employment_net_earnings: 40_000 + owners_draw_passive_se_overlap: 10_000 + output: + # 100,000 - 30,000 - (40,000 - 10,000) = 40,000. + owners_draw_active_income: 40_000 + owners_draw_gross: 40_000 + owners_draw_net_earnings: 36_940 + + +- name: Material participation defaults to excluding the combined passive subset once + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: [60_000, 0] + partnership_income: [40_000, 100_000] + passive_partnership_s_corp_income: [25_000, 100_000] + output: + owners_draw_active_income: [75_000, 0] + owners_draw_net_earnings: [69_262.50, 0] + + +- name: Disabling the material-participation filter includes passive profits and still removes existing SE + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.material_participation_only: false + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 60_000 + partnership_income: 40_000 + passive_partnership_s_corp_income: 25_000 + partnership_self_employment_net_earnings: 10_000 + output: + owners_draw_active_income: 90_000 + owners_draw_net_earnings: 83_115 + + +- name: The independent draw floor rejects adjusted earnings below 400 + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: [433, 434] + output: + # 433 * .9235 = 399.8755; 434 * .9235 = 400.799. + owners_draw_planned_gross: [433, 434] + owners_draw_gross: [0, 434] + owners_draw_net_earnings: [0, 400.799] + owners_draw_oasdi_tax: [0, 49.699076] + owners_draw_niit_exclusion: [0, 434] + + +- name: The reporting floor includes equality and is independent of existing SE earnings + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.net_earnings_factor: 1 + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: [399, 400, 401, 800, 399] + self_employment_income: [0, 0, 0, 400, 1_000] + output: + owners_draw_gross: [0, 400, 401, 800, 0] + owners_draw_net_earnings: [0, 400, 401, 800, 0] + taxable_self_employment_income: [0, 0, 0, 0, 923.50] + owners_draw_oasdi_tax: [0, 49.60, 49.724, 99.20, 0] + + +- name: Existing SE uses its adjusted capped base before allocating draw room + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + payroll_tax_gross_wages: [100_000, 100_000, 250_000] + self_employment_income: [50_000, 200_000, 50_000] + s_corp_income: [100_000, 100_000, 100_000] + output: + taxable_self_employment_income: [46_175, 184_700, 46_175] + social_security_taxable_self_employment_income: [46_175, 100_000, 0] + owners_draw_oasdi_room: [53_825, 0, 0] + owners_draw_oasdi_base: [53_825, 0, 0] + owners_draw_oasdi_tax: [6_674.30, 0, 0] + # 12.4% of (46,175 + 53,825) = 12,400, not a second net adjustment. + self_employment_social_security_tax: [12_400, 12_400, 0] + + +- name: No-ceiling draw option taxes all net earnings even after wages exhaust the ordinary cap + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.contrib.ssa.owners_draw.room_cap_basis: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + payroll_tax_gross_wages: 300_000 + s_corp_income: 400_000 + output: + owners_draw_gross: 400_000 + owners_draw_net_earnings: 369_400 + owners_draw_oasdi_base: 369_400 + owners_draw_oasdi_tax: 45_805.60 + owners_draw_niit_exclusion: 400_000 + + +- name: The reform is inactive before the configured start year + period: 2026 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 150_000 + output: + owners_draw_gross: 0 + owners_draw_net_earnings: 0 + owners_draw_oasdi_tax: 0 + + +- name: The default linear phase-in reaches one in 2036 and stays there + period: 2037 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 100_000 + output: + owners_draw_gross: 100_000 + owners_draw_net_earnings: 92_350 + owners_draw_oasdi_tax: 11_451.40 + + +- name: Nondependent tax-unit aggregation leaves the dependent's draw deduction off the parents' return + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + people: + parent: + age: 45 + s_corp_income: 10_000 + child: + age: 16 + is_tax_unit_dependent: true + s_corp_income: 20_000 + tax_units: + unit: + members: [parent, child] + output: + self_employment_tax_ald_person: [572.57, 1_145.14] + self_employment_tax_ald: 572.57 + + +- name: Net draw earnings enter the ordinary AIME history and respect supplied AIME + period: 2036 + absolute_error_margin: 0.001 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + age: [62, 62] + s_corp_income: [150_000, 150_000] + ss_aime_input: [0, 500] + output: + owners_draw_credited_earnings: [138_525, 138_525] + # One year's adjusted draw / (35 years * 12 months). + ss_aime: [329.8214286, 500] + diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml new file mode 100644 index 00000000000..7897b11b038 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml @@ -0,0 +1,72 @@ +# Synthetic caps are fixed in both the ordinary tax parameter and the captured +# current-law room parameter, so these examples do not depend on projections. +# Unless overridden, q = 1 - 0.5 * (0.124 + 0.029) = 0.9235. +# Policy cases are grouped to retain at most five cached reform combinations. +- name: Disabling benefit credit leaves draw taxes unchanged + period: 2036 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.benefit_credit: false + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 150_000 + output: + owners_draw_net_earnings: 138_525 + owners_draw_oasdi_base: 138_525 + owners_draw_oasdi_tax: 17_177.10 + owners_draw_credited_earnings: 0 + + +- name: An explicit zero phase-in share produces no draw + period: 2027 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 0 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 150_000 + output: + owners_draw_gross: 0 + owners_draw_net_earnings: 0 + owners_draw_oasdi_tax: 0 + + +- name: A configurable linear phase-in starts at one quarter in its new start year + period: 2030 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_years: 4 + gov.contrib.ssa.owners_draw.start_year: 2030 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 100_000 + output: + owners_draw_gross: 25_000 + owners_draw_net_earnings: 23_087.50 + owners_draw_oasdi_tax: 2_862.85 + + +- name: Disabling benefit credit removes the draw from AIME and leaves taxes unchanged + period: 2036 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.benefit_credit: false + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + age: 62 + s_corp_income: 150_000 + output: + owners_draw_credited_earnings: 0 + ss_aime: 0 + owners_draw_oasdi_tax: 17_177.10 diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml new file mode 100644 index 00000000000..9734320f08c --- /dev/null +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml @@ -0,0 +1,114 @@ +# Synthetic caps are fixed in both the ordinary tax parameter and the captured +# current-law room parameter, so these examples do not depend on projections. +# Unless overridden, q = 1 - 0.5 * (0.124 + 0.029) = 0.9235. +# Policy cases are grouped to retain at most five cached reform combinations. +- name: The standard netting factor follows changed OASDI and HI rates + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.ald.self_employment_tax.percent_deductible: 0.25 + gov.irs.payroll.social_security.cap: 200_000 + gov.irs.self_employment.rate.medicare: 0.04 + gov.irs.self_employment.rate.social_security: 0.20 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 100_000 + output: + # q = 1 - .5 * (.20 + .04) = .88, even with incremental HI off. + owners_draw_net_earnings_factor: 0.88 + owners_draw_net_earnings: 88_000 + owners_draw_oasdi_tax: 17_600 + owners_draw_hi_tax: 0 + # The deduction share is separate from the netting employer share. + self_employment_tax_ald_person: 4_400 + + +- name: The netting employer share follows reforms independently of the deduction share + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.ald.misc.employer_share: 0.25 + gov.irs.payroll.social_security.cap: 200_000 + gov.irs.self_employment.rate.medicare: 0.04 + gov.irs.self_employment.rate.social_security: 0.20 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 100_000 + output: + owners_draw_net_earnings_factor: 0.94 + owners_draw_net_earnings: 94_000 + owners_draw_oasdi_tax: 18_800 + self_employment_tax_ald_person: 9_400 + + +- name: An explicit netting factor overrides the rate-derived default + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.net_earnings_factor: 0.8 + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.irs.self_employment.rate.medicare: 0.04 + gov.irs.self_employment.rate.social_security: 0.20 + gov.simulation.aime_lookback_years: 0 + s_corp_income: 100_000 + output: + owners_draw_net_earnings_factor: 0.8 + owners_draw_net_earnings: 80_000 + owners_draw_oasdi_tax: 16_000 + + +- name: HI taxes all adjusted draw earnings and makes M7 exclude the full actual gross draw + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.include_hi: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + payroll_tax_gross_wages: [100_000, 250_000] + s_corp_income: [200_000, 150_000] + output: + owners_draw_gross: [200_000, 150_000] + owners_draw_net_earnings: [184_700, 138_525] + owners_draw_oasdi_base: [100_000, 0] + owners_draw_oasdi_tax: [12_400, 0] + owners_draw_hi_tax: [5_356.30, 4_017.225] + self_employment_medicare_tax: [5_356.30, 4_017.225] + self_employment_tax_ald_person: [8_878.15, 2_008.6125] + owners_draw_niit_exclusion: [200_000, 150_000] + + +- name: Disabling the added deduction preserves the deduction for preexisting SE taxes + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.half_seca_deduction: false + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.include_hi: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + self_employment_income: 10_000 + s_corp_income: 100_000 + output: + taxable_self_employment_income: 9_235 + owners_draw_oasdi_tax: 11_451.40 + owners_draw_hi_tax: 2_678.15 + # Only 9,235 * (.124 + .029) / 2 = 706.4775 is deductible. + self_employment_tax_ald_person: 706.4775 + diff --git a/policyengine_us/tests/reforms/test_owners_draw.py b/policyengine_us/tests/reforms/test_owners_draw.py new file mode 100644 index 00000000000..c7ffc6713fe --- /dev/null +++ b/policyengine_us/tests/reforms/test_owners_draw.py @@ -0,0 +1,363 @@ +"""Structural activation, isolation, and vector invariants for owners' draw. + +Dollar examples live in owners_draw.yaml. These tests capture the installed +formula classes and run small deterministic vectors without copying model +trees. One integration test creates a reformed simulation against the shared +read-only baseline to exercise the public parameter-only activation route. +""" + +from types import SimpleNamespace + +import numpy as np +import pytest +from policyengine_core.periods import period +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation +from policyengine_us.reforms.ssa.owners_draw import ( + create_owners_draw, + create_owners_draw_reform, +) +from policyengine_us.system import system +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.self_employment_tax_ald_person import ( + self_employment_tax_ald_person, +) +from policyengine_us.variables.gov.irs.tax.payroll.social_security.taxable_earnings_for_social_security import ( + taxable_earnings_for_social_security, +) +from policyengine_us.variables.gov.irs.tax.self_employment.self_employment_medicare_tax import ( + self_employment_medicare_tax, +) +from policyengine_us.variables.gov.irs.tax.self_employment.self_employment_social_security_tax import ( + self_employment_social_security_tax, +) +from policyengine_us.variables.gov.irs.tax.self_employment.self_employment_tax import ( + self_employment_tax, +) +from policyengine_us.variables.gov.irs.tax.self_employment.social_security_taxable_self_employment_income import ( + social_security_taxable_self_employment_income, +) +from policyengine_us.variables.gov.irs.tax.self_employment.taxable_self_employment_income import ( + taxable_self_employment_income, +) + + +BASELINE_VARIABLES = { + variable.__name__: variable + for variable in ( + taxable_earnings_for_social_security, + taxable_self_employment_income, + social_security_taxable_self_employment_income, + self_employment_social_security_tax, + self_employment_medicare_tax, + self_employment_tax, + self_employment_tax_ald_person, + ) +} +PERIOD = period("2027") +CAP = 200_000 + + +@pytest.fixture(scope="module") +def reform_variables(): + installed = [] + create_owners_draw().apply(SimpleNamespace(update_variable=installed.append)) + return BASELINE_VARIABLES | { + variable.__name__: variable + for variable in installed + if variable.__name__ != "ss_aime" + } + + +def _parameters(**overrides): + draw = SimpleNamespace( + in_effect=True, + start_year=2027, + phase_in_years=10, + phase_in_share=1, + material_participation_only=True, + net_earnings_factor=0, + include_hi=False, + half_seca_deduction=True, + benefit_credit=True, + room_cap_basis=0, + current_law_cap=CAP, + ) + for name, value in overrides.items(): + setattr(draw, name, value) + root = SimpleNamespace( + gov=SimpleNamespace( + contrib=SimpleNamespace( + ssa=SimpleNamespace(owners_draw=draw), + ubi_center=SimpleNamespace( + flat_tax=SimpleNamespace(abolish_self_emp_tax=False) + ), + ), + irs=SimpleNamespace( + payroll=SimpleNamespace(social_security=SimpleNamespace(cap=CAP)), + self_employment=SimpleNamespace( + rate=SimpleNamespace(social_security=0.124, medicare=0.029), + net_earnings_exemption=400, + ), + ald=SimpleNamespace( + misc=SimpleNamespace(employer_share=0.5), + self_employment_tax=SimpleNamespace(percent_deductible=0.5), + ), + ), + ) + ) + return lambda instant: root + + +def _calculate(variables, parameters, inputs, outputs=None): + values = { + "s_corp_income": 0, + "partnership_income": 0, + "passive_partnership_s_corp_income": 0, + "partnership_self_employment_net_earnings": 0, + "owners_draw_subsets_are_disjoint": True, + "owners_draw_passive_se_overlap": np.nan, + "self_employment_income": 0, + "sstb_self_employment_income": 0, + "farm_operations_income": 0, + "payroll_tax_gross_wages": 0, + **inputs, + } + + values = {name: np.asarray(value) for name, value in values.items()} + + def person(name, instant, options=None): + if name not in values: + values[name] = variables[name].formula(person, instant, parameters) + return values[name] + + # Core's add() checks entity metadata before summing Person variables. + person.entity = SimpleNamespace(is_person=True, key="person") + person.entity.get_variable = lambda name: SimpleNamespace(entity=person.entity) + + for name in outputs or variables: + if name not in ( + "owners_draw_subsets_are_disjoint", + "owners_draw_passive_se_overlap", + ): + person(name, PERIOD) + return values + + +VECTOR_INPUTS = { + "payroll_tax_gross_wages": np.array( + [0, 100_000, 200_000, 300_000, 0, 90_000, 199_999, 0], dtype=float + ), + "s_corp_income": np.array( + [150_000, 200_000, 300_000, 150_000, -50_000, 100_000, 500_000, 433], + dtype=float, + ), + "self_employment_income": np.array( + [0, 50_000, 0, 50_000, 80_000, 100_000, 1_000, 0], dtype=float + ), + "partnership_income": np.array( + [0, 30_000, 40_000, 0, 20_000, 20_000, 0, 0], dtype=float + ), + "partnership_self_employment_net_earnings": np.array( + [0, 30_000, 0, 0, 0, 20_000, 0, 0], dtype=float + ), +} + + +def test_vector_invariants_and_phase_monotonicity(reform_variables): + prior_base = np.zeros(8) + prior_tax = np.zeros(8) + for share in (0, 0.1, 0.3, 0.7, 1): + values = _calculate( + reform_variables, _parameters(phase_in_share=share), VECTOR_INPUTS + ) + base = values["owners_draw_oasdi_base"] + gross = values["owners_draw_gross"] + room = values["owners_draw_oasdi_room"] + tax = values["owners_draw_oasdi_tax"] + assert np.all(base >= 0) + assert np.all(base <= room) + assert np.all(base <= gross) + assert np.all(base >= prior_base) + assert np.all(tax >= prior_tax) + total_earnings = ( + values["taxable_earnings_for_social_security"] + + values["social_security_taxable_self_employment_income"] + + base + ) + assert np.all(total_earnings <= CAP) + # M7 cannot exclude untaxed or undrawn profits from the active base. + exclusion = values["owners_draw_niit_exclusion"] + assert np.all(exclusion >= 0) + assert np.all(exclusion <= gross + 1e-8) + np.testing.assert_allclose(exclusion * 0.9235, base) + prior_base, prior_tax = base, tax + + +def test_disabled_formulas_equal_baseline_on_the_same_vectors(reform_variables): + parameters = _parameters(in_effect=False) + baseline = _calculate(BASELINE_VARIABLES, parameters, VECTOR_INPUTS) + reformed = _calculate(reform_variables, parameters, VECTOR_INPUTS) + for name in BASELINE_VARIABLES: + np.testing.assert_array_equal(reformed[name], baseline[name], err_msg=name) + for name in reform_variables.keys() - BASELINE_VARIABLES.keys(): + if name not in ( + "owners_draw_subsets_are_disjoint", + "owners_draw_passive_se_overlap", + ): + assert np.all(np.asarray(reformed[name]) == 0), name + + +def test_already_se_partnership_income_cannot_add_a_second_draw(reform_variables): + already_se = np.array([0, 399, 1_000, 100_000, 500_000], dtype=float) + inputs = { + "partnership_income": already_se, + "partnership_self_employment_net_earnings": already_se, + } + parameters = _parameters() + baseline = _calculate(BASELINE_VARIABLES, parameters, inputs) + reformed = _calculate(reform_variables, parameters, inputs) + assert np.all(reformed["owners_draw_gross"] == 0) + assert np.all(reformed["owners_draw_oasdi_base"] == 0) + for name in BASELINE_VARIABLES: + np.testing.assert_array_equal(reformed[name], baseline[name], err_msg=name) + + +@pytest.mark.parametrize( + "inputs, message", + [ + ( + { + "owners_draw_subsets_are_disjoint": False, + "owners_draw_passive_se_overlap": np.nan, + }, + "validated disjoint", + ), + ( + { + "owners_draw_subsets_are_disjoint": True, + "owners_draw_passive_se_overlap": 1_000, + }, + "contradicts", + ), + ], +) +def test_ambiguous_or_contradictory_classification_is_rejected( + reform_variables, inputs, message +): + with pytest.raises(ValueError, match=message): + _calculate( + reform_variables, + _parameters(), + { + "partnership_income": np.array([100_000, 50_000]), + "partnership_self_employment_net_earnings": np.array([40_000, 0]), + "passive_partnership_s_corp_income": np.array([20_000, 0]), + **inputs, + }, + outputs=["owners_draw_active_income"], + ) + + +@pytest.mark.parametrize( + "overrides, output, message", + [ + ({"phase_in_years": 0}, "owners_draw_gross", "positive integer"), + ({"phase_in_years": 1.5}, "owners_draw_gross", "positive integer"), + ({"phase_in_share": 1.01}, "owners_draw_gross", "phase_in_share"), + ({"phase_in_share": -0.1}, "owners_draw_gross", "phase_in_share"), + ({"net_earnings_factor": 1.1}, "owners_draw_gross", "net earnings factor"), + ({"net_earnings_factor": -0.1}, "owners_draw_gross", "net earnings factor"), + ({"room_cap_basis": 2}, "owners_draw_oasdi_room", "room_cap_basis"), + ], +) +def test_invalid_policy_settings_fail_explicitly( + reform_variables, overrides, output, message +): + with pytest.raises(ValueError, match=message): + _calculate( + reform_variables, + _parameters(**overrides), + {"s_corp_income": 100_000}, + outputs=[output], + ) + + +def test_future_parameter_activation_captures_cap_and_preserves_baseline(): + years = (2026, 2036, 2037) + situation = { + "people": { + "owner": { + "age": {year: 45 for year in years}, + "payroll_tax_gross_wages": {year: 150_000 for year in years}, + "self_employment_income": {year: 50_000 for year in years}, + "s_corp_income": {year: 400_000 for year in years}, + }, + "past_owner": { + "age": {year: year - 1975 for year in years}, + "s_corp_income": {2026: 0, 2036: 150_000, 2037: 0}, + }, + } + } + parameter_reform = Reform.from_dict( + { + "gov.contrib.ssa.owners_draw.in_effect": { + "2036-01-01.2036-12-31": True, + "2037-01-01.2100-12-31": False, + }, + "gov.irs.payroll.social_security.cap": {"2036-01-01.2100-12-31": np.inf}, + "gov.simulation.aime_lookback_years": {"2026-01-01.2100-12-31": 1}, + }, + country_id="us", + ) + baseline = Simulation(tax_benefit_system=system, situation=situation) + reformed = Simulation( + tax_benefit_system=system, situation=situation, reform=parameter_reform + ) + + # Parameter-only activation must discover a finite active interval twelve + # years beyond the default 2024 factory start, including its later end. + assert create_owners_draw_reform(system.parameters, "2024-01-01") is None + unaffected_outputs = ( + *BASELINE_VARIABLES, + "irs_gross_income", + "adjusted_gross_income", + "niit_magi", + "net_investment_income_tax", + "income_tax", + ) + for year in (2026, 2037): + for name in unaffected_outputs: + np.testing.assert_array_equal( + reformed.calculate(name, year), + baseline.calculate(name, year), + err_msg=f"{name} in inactive {year}", + ) + assert reformed.calculate("owners_draw_gross", year)[0] == 0 + + cap = system.parameters("2036-01-01").gov.irs.payroll.social_security.cap + reformed_parameters = reformed.tax_benefit_system.parameters("2036-01-01") + assert np.isinf(reformed_parameters.gov.irs.payroll.social_security.cap) + assert reformed_parameters.gov.contrib.ssa.owners_draw.current_law_cap == cap + expected_room = max(0, cap - 150_000 - 46_175) + assert reformed.calculate("owners_draw_oasdi_room", 2036)[0] == pytest.approx( + expected_room + ) + assert reformed.calculate("owners_draw_oasdi_base", 2036)[0] == pytest.approx( + min(369_400, expected_room) + ) + + # Covered earnings retain the earning year's classification when the + # calculation/payment year is inactive. The 62-year-old's sole draw was + # earned at age 61, after the age-60 indexing year, so no wage indexing + # applies: 138,525 / (35 * 12) = 329.8214286. + assert reformed.calculate("owners_draw_credited_earnings", 2036)[ + 1 + ] == pytest.approx(138_525) + assert reformed.calculate("owners_draw_credited_earnings", 2037)[1] == 0 + assert reformed.calculate("ss_aime", 2037)[1] == pytest.approx(138_525 / 420) + + # Neither the captured current-law path nor the shared baseline was mutated + # by removing the tax ceiling in the reformed simulation. + assert system.parameters("2036-01-01").gov.irs.payroll.social_security.cap == cap + assert not system.parameters("2036-01-01").gov.contrib.ssa.owners_draw.in_effect From 6ad1e7e380f220e1329066bcb5ef9b0989c34d0d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 26 Oct 2026 22:26:45 -0700 Subject: [PATCH 03/13] Clarify provisional owners draw scoring data requirements --- docs/contributed_reforms/owners_draw.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contributed_reforms/owners_draw.md b/docs/contributed_reforms/owners_draw.md index 10820767655..927f283782c 100644 --- a/docs/contributed_reforms/owners_draw.md +++ b/docs/contributed_reforms/owners_draw.md @@ -145,8 +145,8 @@ active residual and makes population results provisional. [Microcosm #1251](https://github.com/PolicyEngine/microcosm/pull/1251) repairs the total/passive contract, and [#1261](https://github.com/PolicyEngine/microcosm/pull/1261) separates entity -totals. Population scoring requires a pinned artifact containing these -repairs and a receipt validating the stored columns and their meanings. +totals. Nonprovisional population scoring requires a pinned artifact containing +these repairs and a receipt validating the stored columns and their meanings. No population impact is measured by this PR; the hub will measure it. ## Comparable OACT provisions From 4c2b70c004f31438ab84a158d0574db4208997f3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 27 Oct 2026 00:07:16 -0700 Subject: [PATCH 04/13] Match owners draw vector assertion to person count --- policyengine_us/tests/policy/contrib/ssa/owners_draw.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw.yaml index d706b3c60df..0131bc10ab8 100644 --- a/policyengine_us/tests/policy/contrib/ssa/owners_draw.yaml +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw.yaml @@ -138,7 +138,7 @@ partnership_income: [50_000, 50_000, -20_000, 20_000, -10_000] passive_partnership_s_corp_income: [20_000, -20_000, 0, 0, 0] partnership_self_employment_net_earnings: [30_000, 30_000, 0, 0, -10_000] - owners_draw_subsets_are_disjoint: true + owners_draw_subsets_are_disjoint: [true, true, true, true, true] self_employment_income: [0, 0, 0, 50_000, 50_000] output: owners_draw_active_income: [50_000, 90_000, 30_000, -30_000, -20_000] @@ -365,4 +365,3 @@ owners_draw_credited_earnings: [138_525, 138_525] # One year's adjusted draw / (35 years * 12 months). ss_aime: [329.8214286, 500] - From 1aeb9e497fb84310ed1c8a17cc64e9af930648d1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 27 Oct 2026 01:34:21 -0700 Subject: [PATCH 05/13] Remove trailing blank line from owners draw tests --- .../tests/policy/contrib/ssa/owners_draw_net_earnings.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml index 9734320f08c..5175eedc34b 100644 --- a/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml @@ -111,4 +111,3 @@ owners_draw_hi_tax: 2_678.15 # Only 9,235 * (.124 + .029) / 2 = 706.4775 is deductible. self_employment_tax_ald_person: 706.4775 - From 410e8304cea2379c319110691f9de470219197dd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 04:13:11 -0400 Subject: [PATCH 06/13] Add shrinking owners' draw invariants and historical AIME regression Cover signed subsets, phase-in, cap preservation, disabled equality, reporting floor, HI, deduction, and benefit credit with generated batches and explicit edge examples. Keep private simulation policies isolated and supply explicit earlier earnings history for the default AIME lookback. Co-Authored-By: OpenAI Codex --- .../ssa/owners_draw_credited_earnings.yaml | 74 +- .../tests/reforms/test_owners_draw.py | 667 +++++++++++++++++- 2 files changed, 730 insertions(+), 11 deletions(-) diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml index 7897b11b038..418e19aac76 100644 --- a/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml @@ -1,5 +1,5 @@ -# Synthetic caps are fixed in both the ordinary tax parameter and the captured -# current-law room parameter, so these examples do not depend on projections. +# Where overridden, synthetic caps are fixed in both the ordinary tax parameter +# and the captured current-law room parameter. # Unless overridden, q = 1 - 0.5 * (0.124 + 0.029) = 0.9235. # Policy cases are grouped to retain at most five cached reform combinations. - name: Disabling benefit credit leaves draw taxes unchanged @@ -70,3 +70,73 @@ owners_draw_credited_earnings: 0 ss_aime: 0 owners_draw_oasdi_tax: 17_177.10 + + +- name: Default AIME lookback skips inactive draw years before the reporting floor exists + period: 2027 + absolute_error_margin: 0.001 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.in_effect: true + age: 62 + s_corp_income: 100_000 + # Explicit zero labor history isolates the draw from historical labor- + # response parameter coverage, while retaining the default 45-year lookback. + employment_income: &zero_labor_history + 1982: 0 + 1983: 0 + 1984: 0 + 1985: 0 + 1986: 0 + 1987: 0 + 1988: 0 + 1989: 0 + 1990: 0 + 1991: 0 + 1992: 0 + 1993: 0 + 1994: 0 + 1995: 0 + 1996: 0 + 1997: 0 + 1998: 0 + 1999: 0 + 2000: 0 + 2001: 0 + 2002: 0 + 2003: 0 + 2004: 0 + 2005: 0 + 2006: 0 + 2007: 0 + 2008: 0 + 2009: 0 + 2010: 0 + 2011: 0 + 2012: 0 + 2013: 0 + 2014: 0 + 2015: 0 + 2016: 0 + 2017: 0 + 2018: 0 + 2019: 0 + 2020: 0 + 2021: 0 + 2022: 0 + 2023: 0 + 2024: 0 + 2025: 0 + 2026: 0 + 2027: 0 + total_self_employment_income: *zero_labor_history + output: + owners_draw_gross: + 2012: 0 + 2027: 10_000 + owners_draw_net_earnings: 9_235 + owners_draw_credited_earnings: 9_235 + owners_draw_oasdi_tax: 1_145.14 + # Default 10% phase-in: 100,000 * .1 * .9235 = 9,235. + # Only the current year has earnings, after age-60 indexing: 9,235 / 420. + ss_aime: 21.98809524 diff --git a/policyengine_us/tests/reforms/test_owners_draw.py b/policyengine_us/tests/reforms/test_owners_draw.py index c7ffc6713fe..88b25f2a549 100644 --- a/policyengine_us/tests/reforms/test_owners_draw.py +++ b/policyengine_us/tests/reforms/test_owners_draw.py @@ -1,15 +1,18 @@ """Structural activation, isolation, and vector invariants for owners' draw. Dollar examples live in owners_draw.yaml. These tests capture the installed -formula classes and run small deterministic vectors without copying model -trees. One integration test creates a reformed simulation against the shared -read-only baseline to exercise the public parameter-only activation route. +formula classes for fast deterministic and shrinking generated vectors. Public +Simulation tests batch households under one private policy per test to exercise +constructor activation, baseline equality, cap preservation and AIME history. """ +import gc from types import SimpleNamespace import numpy as np import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st from policyengine_core.periods import period from policyengine_core.reforms import Reform @@ -69,7 +72,15 @@ def reform_variables(): } -def _parameters(**overrides): +def _parameters( + *, + oasdi_rate=0.124, + hi_rate=0.029, + employer_share=0.5, + cap=CAP, + deductible_share=0.5, + **overrides, +): draw = SimpleNamespace( in_effect=True, start_year=2027, @@ -81,7 +92,7 @@ def _parameters(**overrides): half_seca_deduction=True, benefit_credit=True, room_cap_basis=0, - current_law_cap=CAP, + current_law_cap=cap, ) for name, value in overrides.items(): setattr(draw, name, value) @@ -94,14 +105,16 @@ def _parameters(**overrides): ), ), irs=SimpleNamespace( - payroll=SimpleNamespace(social_security=SimpleNamespace(cap=CAP)), + payroll=SimpleNamespace(social_security=SimpleNamespace(cap=cap)), self_employment=SimpleNamespace( - rate=SimpleNamespace(social_security=0.124, medicare=0.029), + rate=SimpleNamespace(social_security=oasdi_rate, medicare=hi_rate), net_earnings_exemption=400, ), ald=SimpleNamespace( - misc=SimpleNamespace(employer_share=0.5), - self_employment_tax=SimpleNamespace(percent_deductible=0.5), + misc=SimpleNamespace(employer_share=employer_share), + self_employment_tax=SimpleNamespace( + percent_deductible=deductible_share + ), ), ), ) @@ -361,3 +374,639 @@ def test_future_parameter_activation_captures_cap_and_preserves_baseline(): # by removing the tax ceiling in the reformed simulation. assert system.parameters("2036-01-01").gov.irs.payroll.social_security.cap == cap assert not system.parameters("2036-01-01").gov.contrib.ssa.owners_draw.in_effect + + +# Formula-level generators keep shrinking cheap: all owners in an example +# share one policy and are calculated together as NumPy arrays. +SETTINGS = dict( + max_examples=40, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +_RATE_POLICY = st.tuples( + st.integers(1, 250), st.integers(1, 100), st.integers(0, 1_000) +) +_SIGNED_OWNER_BATCH = st.lists( + st.tuples( + st.integers(0, 1_000_000), # wages + st.integers(-500_000, 500_000), # other existing SE income + st.integers(-500_000, 500_000), # active income outside SE + st.integers(-500_000, 500_000), # passive-only income + st.integers(-500_000, 500_000), # SE-only partnership income + st.integers(-500_000, 500_000), # passive/SE intersection + st.integers(-500_000, 500_000), # allocation to S corporations + ), + min_size=1, + max_size=12, +) + + +def _policy_rates(rates): + oasdi, hi, employer = rates + return dict( + oasdi_rate=oasdi / 1_000, + hi_rate=hi / 1_000, + employer_share=employer / 1_000, + ) + + +def _signed_owner_inputs(rows): + wages, other_se, active, passive_only, se_only, overlap, s_corp = np.asarray( + rows, dtype=float + ).T + # Four signed atoms supply an independent inclusion/exclusion oracle: + # T = A + Q_only + K_only + Q_intersect_K, Q = Q_only + overlap, + # K = K_only + overlap, so T - Q - K + overlap = A. + total = active + passive_only + se_only + overlap + return { + "payroll_tax_gross_wages": wages, + "self_employment_income": other_se, + "s_corp_income": s_corp, + "partnership_income": total - s_corp, + "passive_partnership_s_corp_income": passive_only + overlap, + "partnership_self_employment_net_earnings": se_only + overlap, + "owners_draw_subsets_are_disjoint": False, + "owners_draw_passive_se_overlap": overlap, + }, active + + +@settings(**SETTINGS) +@given( + rows=_SIGNED_OWNER_BATCH, + rates=_RATE_POLICY, + cap=st.integers(1_000, 1_000_000), + preserved_cap=st.integers(1_000, 1_000_000), + share=st.integers(0, 1_000), + override_factor=st.sampled_from([0, 0.5, 0.8, 1]), +) +@example( + rows=[ + (0, 0, 500, 0, 0, 0, 500), # adjusted draw exactly $400 + (0, 0, 1_000, 0, 0, 0, 1_000), # exactly the cap room + (1_000, 0, 2_000, 0, 0, 0, 2_000), # no room + (2_000, 0, 2_000, 0, 0, 0, 2_000), # above the preserved cap + ], + rates=(124, 29, 500), + cap=1_000, + preserved_cap=800, + share=1_000, + override_factor=0.8, +) +def test_generated_draw_bounds_and_adjustment( + reform_variables, rows, rates, cap, preserved_cap, share, override_factor +): + inputs, _ = _signed_owner_inputs(rows) + policy = _policy_rates(rates) + parameters = _parameters( + **policy, + cap=cap, + current_law_cap=preserved_cap, + phase_in_share=share / 1_000, + net_earnings_factor=override_factor, + ) + baseline = _calculate(BASELINE_VARIABLES, parameters, inputs) + values = _calculate(reform_variables, parameters, inputs) + q = override_factor or 1 - policy["employer_share"] * ( + policy["oasdi_rate"] + policy["hi_rate"] + ) + expected_room = np.maximum( + 0, + preserved_cap + - baseline["taxable_earnings_for_social_security"] + - baseline["social_security_taxable_self_employment_income"], + ) + gross = values["owners_draw_gross"] + net = values["owners_draw_net_earnings"] + base = values["owners_draw_oasdi_base"] + assert np.all(gross >= 0) + np.testing.assert_allclose(net, gross * q) + np.testing.assert_allclose(values["owners_draw_oasdi_room"], expected_room) + assert np.all(base >= 0) + assert np.all(base <= expected_room) + assert np.all(base <= gross) + np.testing.assert_allclose(base, np.minimum(net, expected_room)) + + +@settings(**SETTINGS) +@given( + rows=_SIGNED_OWNER_BATCH, + rates=_RATE_POLICY, + cap=st.integers(1_000, 1_000_000), + shares=st.tuples(st.integers(0, 1_000), st.integers(0, 1_000)), +) +@example( + rows=[(0, 0, 100_000, 0, 0, 0, 100_000)], + rates=(124, 29, 500), + cap=200_000, + shares=(200, 300), # an annual phase-in step +) +@example( + rows=[(0, 0, 800, 0, 0, 0, 800)], + rates=(124, 29, 500), + cap=200_000, + shares=(0, 1_000), +) +def test_generated_phase_in_tax_is_monotone(reform_variables, rows, rates, cap, shares): + inputs, _ = _signed_owner_inputs(rows) + lower, upper = sorted(shares) + taxes = [ + _calculate( + reform_variables, + _parameters(**_policy_rates(rates), cap=cap, phase_in_share=share / 1_000), + inputs, + )["owners_draw_oasdi_tax"] + for share in (lower, upper) + ] + assert np.all(taxes[0] <= taxes[1]) + + +@settings(**SETTINGS) +@given( + rows=_SIGNED_OWNER_BATCH, + rates=_RATE_POLICY, + share=st.integers(0, 1_000), +) +@example( + rows=[ + (0, 0, 0, 0, 50_000, 0, 0), # all income already SE + (0, 0, 0, -20_000, 30_000, -10_000, -20_000), # signed net zero + (0, 10_000, 5_000, -5_000, 10_000, -2_000, 15_000), + ], + rates=(124, 29, 500), + share=1_000, +) +def test_generated_signed_residual_never_double_counts_se( + reform_variables, rows, rates, share +): + inputs, active_not_se = _signed_owner_inputs(rows) + parameters = _parameters(**_policy_rates(rates), phase_in_share=share / 1_000) + baseline = _calculate(BASELINE_VARIABLES, parameters, inputs) + values = _calculate(reform_variables, parameters, inputs) + np.testing.assert_array_equal(values["owners_draw_active_income"], active_not_se) + planned = np.maximum(0, active_not_se) * share / 1_000 + np.testing.assert_allclose(values["owners_draw_planned_gross"], planned) + assert np.all(values["owners_draw_gross"] <= planned + 1e-8) + for name in ( + "payroll_tax_gross_wages", + "self_employment_income", + "partnership_self_employment_net_earnings", + "taxable_earnings_for_social_security", + "taxable_self_employment_income", + "social_security_taxable_self_employment_income", + ): + np.testing.assert_array_equal(values[name], baseline[name], err_msg=name) + + +@settings(**SETTINGS) +@given( + rows=st.lists( + st.tuples( + st.one_of( + st.sampled_from([0, 399, 399.99, 400, 400.01, 401]), + st.integers(0, 10_000), + ), + st.integers(-100_000, 100_000), + ), + min_size=1, + max_size=12, + ), + factor=st.sampled_from([0.5, 1]), +) +@example(rows=[(399.99, 100_000), (400, 0), (400.01, 100_000)], factor=0.5) +@example(rows=[(399, 0), (400, 399), (401, -100_000)], factor=1) +def test_generated_reporting_floor_is_separate_from_existing_se( + reform_variables, rows, factor +): + adjusted, existing_se = np.asarray(rows, dtype=float).T + planned = adjusted / factor + values = _calculate( + reform_variables, + _parameters(net_earnings_factor=factor), + {"s_corp_income": planned, "self_employment_income": existing_se}, + ) + expected_gross = np.where(adjusted >= 400, planned, 0) + np.testing.assert_array_equal(values["owners_draw_gross"], expected_gross) + np.testing.assert_array_equal( + values["owners_draw_net_earnings"], expected_gross * factor + ) + + +@settings(**SETTINGS) +@given( + rows=_SIGNED_OWNER_BATCH, + rates=_RATE_POLICY, + cap=st.integers(1_000, 1_000_000), +) +@example( + rows=[ + (200_000, 0, 500_000, 0, 0, 0, 500_000), # no OASDI room + (0, 0, 500_000, 0, 0, 0, 500_000), # draw exceeds the cap + ], + rates=(124, 29, 500), + cap=100_000, +) +def test_generated_hi_defaults_off_and_is_uncapped_when_enabled( + reform_variables, rows, rates, cap +): + inputs, _ = _signed_owner_inputs(rows) + policy = dict(**_policy_rates(rates), cap=cap) + baseline = _calculate(BASELINE_VARIABLES, _parameters(**policy), inputs) + default = _calculate(reform_variables, _parameters(**policy), inputs) + assert np.all(np.asarray(default["owners_draw_hi_tax"]) == 0) + np.testing.assert_array_equal( + default["self_employment_medicare_tax"], + baseline["self_employment_medicare_tax"], + ) + enabled = _calculate( + reform_variables, _parameters(**policy, include_hi=True), inputs + ) + added_hi = policy["hi_rate"] * enabled["owners_draw_net_earnings"] + np.testing.assert_allclose(enabled["owners_draw_hi_tax"], added_hi) + np.testing.assert_allclose( + enabled["self_employment_medicare_tax"], + baseline["self_employment_medicare_tax"] + added_hi, + ) + + +@settings(**SETTINGS) +@given( + rows=_SIGNED_OWNER_BATCH, + rates=_RATE_POLICY, + deductible=st.integers(0, 1_000), + include_hi=st.booleans(), +) +@example( + rows=[(0, 50_000, 100_000, 0, 0, 0, 100_000)], + rates=(124, 29, 500), + deductible=250, + include_hi=True, +) +@example( + rows=[(0, 0, 100_000, 0, 0, 0, 100_000)], + rates=(124, 29, 500), + deductible=1_000, + include_hi=False, +) +def test_generated_deduction_tracks_existing_deductible_share( + reform_variables, rows, rates, deductible, include_hi +): + inputs, _ = _signed_owner_inputs(rows) + policy = dict( + **_policy_rates(rates), + deductible_share=deductible / 1_000, + include_hi=include_hi, + ) + baseline = _calculate(BASELINE_VARIABLES, _parameters(**policy), inputs) + enabled = _calculate(reform_variables, _parameters(**policy), inputs) + added_seca = enabled["owners_draw_oasdi_tax"] + enabled["owners_draw_hi_tax"] + np.testing.assert_allclose( + enabled["self_employment_tax"] - baseline["self_employment_tax"], added_seca + ) + np.testing.assert_allclose( + enabled["self_employment_tax_ald_person"], + baseline["self_employment_tax_ald_person"] + + policy["deductible_share"] * added_seca, + ) + disabled = _calculate( + reform_variables, + _parameters(**policy, half_seca_deduction=False), + inputs, + ) + np.testing.assert_allclose( + disabled["self_employment_tax_ald_person"], + baseline["self_employment_tax_ald_person"], + ) + + +@pytest.fixture +def private_draw_system(): + """Keep one independent registry/parameter tree for a complete property. + + Hypothesis reuses a function fixture across examples intentionally: policy + stays fixed, while every example gets new Simulation holders. + A mutable owner releases even pytest's fixture reference after the test. + """ + gc.collect() + owner = SimpleNamespace(system=None) + try: + yield owner + finally: + if owner.system is not None: + owner.system.simulation = None + owner.system = None + gc.collect() + + +SIMULATION_SETTINGS = { + **SETTINGS, + "max_examples": 5, + "suppress_health_check": [ + *SETTINGS["suppress_health_check"], + HealthCheck.function_scoped_fixture, + ], +} +SIMULATION_YEAR = 2027 +SIMULATION_WINDOW = "2027-01-01.2100-12-31" +SIMULATION_DRAW = "gov.contrib.ssa.owners_draw." +SIMULATION_CAP = "gov.irs.payroll.social_security.cap" +simulation_amount = st.integers(0, 600_000) +simulation_person = st.tuples( + st.integers(20, 85), + simulation_amount, + st.integers(-40_000, 250_000), + st.integers(-100_000, 600_000), + st.integers(-100_000, 300_000), + st.integers(0, 80_000), + st.integers(0, 80_000), +) + + +def _simulation_situation(batch, year=SIMULATION_YEAR, supplied=None): + """Build separate single-person tax units in one vectorized simulation.""" + result = { + entity: {} + for entity in ( + "people", + "tax_units", + "families", + "spm_units", + "marital_units", + "households", + ) + } + input_names = ( + "age", + "employment_income", + "self_employment_income", + "s_corp_income", + "partnership_income", + "partnership_self_employment_net_earnings", + "passive_partnership_s_corp_income", + ) + years = (year,) if isinstance(year, int) else year + for i, inputs in enumerate(batch): + name = f"owner_{i}" + result["people"][name] = { + key: {year: value for year in years} + for key, value in zip(input_names, inputs) + } + if supplied is not None: + result["people"][name]["ss_aime_input"] = { + year: supplied[i] for year in years + } + for entity in result.keys() - {"people"}: + result[entity][f"unit_{i}"] = {"members": [name]} + result["households"][f"unit_{i}"]["state_code"] = {year: "TX" for year in years} + return result + + +def _private_simulation(owner, situation, reform=None): + # The public US constructor clones a supplied system when given a reform. + # Adopt that one private clone, then share its unchanged policy across new + # holders for every generated batch. Repeated constructors with a reform + # would copy the entire country tree on every example. + if owner.system is None: + simulation = Simulation( + tax_benefit_system=system, situation=situation, reform=reform + ) + owner.system = simulation.tax_benefit_system + return simulation + return Simulation(tax_benefit_system=owner.system, situation=situation) + + +def _zero_earlier_earnings(simulation, year): + # Supply a complete zero history through the public API after construction. + # This keeps the default lookback and isolates draw history from historical + # labor-response parameter coverage in the ordinary earnings formulas. + zeros = np.zeros(simulation.populations["person"].count) + for historic_year in range(year - 45, year): + simulation.set_input("employment_income", historic_year, zeros) + simulation.set_input("total_self_employment_income", historic_year, zeros) + + +@pytest.mark.parametrize("route", ["parametric", "class"]) +@settings(**SIMULATION_SETTINGS) +@given(batch=st.lists(simulation_person, min_size=3, max_size=8)) +@example( + batch=[ + (62, 0, 0, 600_000, 0, 0, 0), + (62, 200_000, 0, 600_000, 0, 0, 0), + (62, 600_000, 250_000, 600_000, 0, 0, 0), + ], +) +def test_generated_raised_cap_paths_preserve_current_law_room( + private_draw_system, reform_variables, route, batch +): + """Property 2: current-law, parameter and class cap paths share ordering.""" + # The same invariant is checked cheaply first, so a source mutant can be + # rejected under the mutation runner's 2 GiB cap before cloning a system. + raised_cap = 400_000 + probe = _calculate( + reform_variables, + _parameters(cap=raised_cap, current_law_cap=200_000), + {"s_corp_income": 1_000_000}, + ) + assert probe["owners_draw_oasdi_base"] == 200_000 + # These subsets can be arbitrary in the off differential below. Here they + # are explicitly disjoint, so install that classification only in the + # private reformed simulation (the baseline has no such input variable). + situation = _simulation_situation(batch, (2027, 2028)) + policy = { + SIMULATION_DRAW + "in_effect": {SIMULATION_WINDOW: True}, + SIMULATION_DRAW + "phase_in_share": {SIMULATION_WINDOW: 1}, + SIMULATION_CAP: {"2027-01-01.2027-12-31": raised_cap}, + } + + class RaisedCap(Reform): + def apply(self): + activation = Reform.from_dict( + {key: value for key, value in policy.items() if key != SIMULATION_CAP}, + country_id="us", + ) + activation.apply(self) + self.parameters.gov.irs.payroll.social_security.cap.update( + start="2027-01-01", stop="2027-12-31", value=raised_cap + ) + + simulation = _private_simulation( + private_draw_system, situation, policy if route == "parametric" else RaisedCap + ) + for year in (2027, 2028): + simulation.set_input( + "owners_draw_subsets_are_disjoint", year, np.ones(len(batch), bool) + ) + cap = system.parameters(f"{year}-01-01").gov.irs.payroll.social_security.cap + active_cap = raised_cap if year == 2027 else cap + p = simulation.tax_benefit_system.parameters(f"{year}-01-01") + assert p.gov.irs.payroll.social_security.cap == active_cap + assert p.gov.contrib.ssa.owners_draw.current_law_cap == cap + wages = simulation.calculate("taxable_earnings_for_social_security", year) + existing = simulation.calculate( + "social_security_taxable_self_employment_income", year + ) + room = np.maximum(0, cap - wages - existing) + draw = simulation.calculate("owners_draw_oasdi_base", year) + np.testing.assert_allclose( + simulation.calculate("owners_draw_oasdi_room", year), room, atol=0.02 + ) + adjusted = simulation.calculate("owners_draw_net_earnings", year) + np.testing.assert_allclose(draw, np.minimum(adjusted, room), atol=0.02) + assert np.all(wages + existing + draw <= active_cap + 0.02) + assert ( + system.parameters(f"{year}-01-01").gov.irs.payroll.social_security.cap + == cap + ) + + +@settings(**SIMULATION_SETTINGS) +@given(batch=st.lists(simulation_person, min_size=3, max_size=8)) +@example( + batch=[ + (62, 0, 0, 150_000, 0, 0, 0), + (45, 300_000, 50_000, -50_000, 50_000, 0, 0), + (85, 100_000, -20_000, 0, 100_000, 40_000, 20_000), + ] +) +def test_generated_disabled_reform_matches_public_baseline( + private_draw_system, reform_variables, batch +): + """Property 3: disabled formulas agree through complete public tax paths.""" + probe_inputs = {"s_corp_income": 150_000, "self_employment_income": 50_000} + parameters = _parameters(in_effect=False) + probe = _calculate(reform_variables, parameters, probe_inputs) + reference = _calculate(BASELINE_VARIABLES, parameters, probe_inputs) + for output in BASELINE_VARIABLES: + np.testing.assert_array_equal(probe[output], reference[output], err_msg=output) + situation = _simulation_situation(batch) + baseline = Simulation(tax_benefit_system=system, situation=situation) + disabled = _private_simulation(private_draw_system, situation, create_owners_draw()) + _zero_earlier_earnings(baseline, 2027) + _zero_earlier_earnings(disabled, 2027) + assert ( + disabled.tax_benefit_system.parameters( + "2027-01-01" + ).gov.simulation.aime_lookback_years + == 45 + ) + for output in ( + *BASELINE_VARIABLES, + "payroll_tax_gross_wages", + "total_self_employment_income", + "irs_gross_income", + "adjusted_gross_income", + "income_tax", + "ss_aime", + "niit_magi", + "net_investment_income_tax", + ): + np.testing.assert_array_equal( + disabled.calculate(output, 2027), + baseline.calculate(output, 2027), + err_msg=output, + ) + + +@settings(**SIMULATION_SETTINGS) +@given( + amounts=st.lists( + st.tuples(simulation_amount, st.integers(0, 250_000), simulation_amount), + min_size=3, + max_size=8, + ), + year=st.sampled_from([2027, 2036]), +) +@example( + amounts=[(0, 0, 600_000), (600_000, 0, 600_000), (0, 0, 433)], + year=2027, +) +@example(amounts=[(0, 0, 0), (0, 0, 100_000), (100_000, 50_000, 100_000)], year=2036) +def test_generated_benefit_credit_cap_override_and_historical_lookback( + private_draw_system, reform_variables, amounts, year +): + """Property 9 and regression: default 45-year histories are always valid.""" + probe = _calculate( + reform_variables, + _parameters(benefit_credit=False), + {"s_corp_income": 150_000}, + ) + assert probe["owners_draw_credited_earnings"] == 0 + historical_parameters = _parameters(in_effect=False) + # Historical disabled years may lack the modern reporting-floor node. + del historical_parameters(PERIOD).gov.irs.self_employment.net_earnings_exemption + historical = _calculate( + reform_variables, + historical_parameters, + {"s_corp_income": 150_000}, + outputs=["owners_draw_gross"], + ) + assert historical["owners_draw_gross"] == 0 + batch = [(62, wages, se, draw, 0, 0, 0) for wages, se, draw in amounts] + # Both supplied and computed AIMEs occur in every generated batch. + supplied = np.array( + [ + 123.45 if i % 4 == 1 else 1_234.5 if i % 4 == 3 else 0 + for i in range(len(batch)) + ] + ) + situation = _simulation_situation(batch, year, supplied) + policy = { + SIMULATION_DRAW + "in_effect": {SIMULATION_WINDOW: True}, + SIMULATION_DRAW + "phase_in_share": {SIMULATION_WINDOW: 1}, + SIMULATION_DRAW + "benefit_credit": { + "2027-01-01.2027-12-31": True, + "2028-01-01.2028-12-31": False, + "2036-01-01.2036-12-31": True, + "2037-01-01.2037-12-31": False, + }, + } + credited = _private_simulation(private_draw_system, situation, policy) + _zero_earlier_earnings(credited, year) + p = credited.tax_benefit_system.parameters(f"{year}-01-01") + assert p.gov.simulation.aime_lookback_years == 45 + wages, se, gross = np.array(amounts, dtype=float).T + q = 1 - p.gov.irs.ald.misc.employer_share * ( + p.gov.irs.self_employment.rate.social_security + + p.gov.irs.self_employment.rate.medicare + ) + adjusted = gross * q + adjusted = np.where( + adjusted >= p.gov.irs.self_employment.net_earnings_exemption, adjusted, 0 + ) + np.testing.assert_allclose( + credited.calculate("owners_draw_credited_earnings", year), adjusted, atol=0.02 + ) + annual_cap = p.gov.ssa.social_security.wage_base + # At age 62 the current year's earnings are after the indexing year. All + # earlier supplied earnings default to zero, so the annual ledger gives a + # hand-computable AIME with no projection or wage-indexing dependency. + covered = np.minimum(wages + se + adjusted, annual_cap) + expected = np.where(supplied > 0, supplied, covered / 420) + np.testing.assert_allclose(credited.calculate("ss_aime", year), expected, atol=0.02) + assert np.all(covered <= annual_cap) + # Calling every lookback year explicitly catches a disabled-year formula + # reading the net-earnings floor before its first defined year (2013). + for historic_year in range(year - 45, year + 1): + value = credited.calculate("owners_draw_gross", historic_year) + assert np.isfinite(value).all() + if historic_year < 2027: + np.testing.assert_array_equal(value, np.zeros(len(batch))) + + # A separate batch in a configured credit-off year avoids copying the + # parameter tree to toggle a flag between examples. It is compared with + # the public baseline for that same year and exactly those earnings. + off_year = year + 1 + off_situation = _simulation_situation(batch, off_year, supplied) + no_credit = _private_simulation(private_draw_system, off_situation) + baseline_off = Simulation(tax_benefit_system=system, situation=off_situation) + _zero_earlier_earnings(no_credit, off_year) + _zero_earlier_earnings(baseline_off, off_year) + np.testing.assert_array_equal( + no_credit.calculate("ss_aime", off_year), + baseline_off.calculate("ss_aime", off_year), + ) + np.testing.assert_array_equal( + no_credit.calculate("owners_draw_credited_earnings", off_year), + np.zeros(len(batch)), + ) From 4877a973f20f01788983e875811086be7b2d16ba Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 04:27:17 -0400 Subject: [PATCH 07/13] Skip owners' draw reporting floor in inactive historical years Avoid reading a post-2012 SE parameter while computing default-lookback AIME before the reform starts. Co-Authored-By: OpenAI Codex --- changelog.d/reform-pass-through-owners-draw-seca.added.md | 2 ++ policyengine_us/reforms/ssa/owners_draw.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/changelog.d/reform-pass-through-owners-draw-seca.added.md b/changelog.d/reform-pass-through-owners-draw-seca.added.md index a343da656a3..33d78b80acf 100644 --- a/changelog.d/reform-pass-through-owners-draw-seca.added.md +++ b/changelog.d/reform-pass-through-owners-draw-seca.added.md @@ -1 +1,3 @@ Add an opt-in contributed reform applying SECA to owners' draws from pass-through profits, with configurable phase-in, cap, HI, deduction, and benefit-credit treatment. + +Return zero draw in inactive historical years so the default AIME lookback does not read reporting-floor parameters before they exist. diff --git a/policyengine_us/reforms/ssa/owners_draw.py b/policyengine_us/reforms/ssa/owners_draw.py index 6893479ddd7..604f324a271 100644 --- a/policyengine_us/reforms/ssa/owners_draw.py +++ b/policyengine_us/reforms/ssa/owners_draw.py @@ -138,6 +138,8 @@ class owners_draw_gross(Variable): label = "Actual gross owners' draw accepted by the reporting floor" def formula(person, period, parameters): + if not _enabled(period, parameters): + return 0 planned = person("owners_draw_planned_gross", period) adjusted = planned * person("owners_draw_net_earnings_factor", period) floor = parameters(period).gov.irs.self_employment.net_earnings_exemption From 0aec2b9d3c206b6f7ae83a0170e356a84e71a96b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 11:53:18 -0400 Subject: [PATCH 08/13] Connect owners' draw exclusions to active business NIIT Use the calculated cap-, floor- and factor-aware exclusion while owners' draw is active, and preserve the existing manual hooks otherwise. Add joint examples and generated inactive-path equivalence; reject passive-inclusive joint draws until their active/NII allocation is supplied. Co-Authored-By: OpenAI Codex --- ...orm-pass-through-owners-draw-seca.added.md | 2 + docs/contributed_reforms/owners_draw.md | 17 +- .../contrib/ssa/owners_draw_net_earnings.yaml | 107 +++++-- .../tests/reforms/test_owners_draw.py | 296 ++++++++++++++++++ .../s770/s770_active_business_income.py | 44 ++- 5 files changed, 432 insertions(+), 34 deletions(-) diff --git a/changelog.d/reform-pass-through-owners-draw-seca.added.md b/changelog.d/reform-pass-through-owners-draw-seca.added.md index 33d78b80acf..a55ca5b5ff2 100644 --- a/changelog.d/reform-pass-through-owners-draw-seca.added.md +++ b/changelog.d/reform-pass-through-owners-draw-seca.added.md @@ -1,3 +1,5 @@ Add an opt-in contributed reform applying SECA to owners' draws from pass-through profits, with configurable phase-in, cap, HI, deduction, and benefit-credit treatment. Return zero draw in inactive historical years so the default AIME lookback does not read reporting-floor parameters before they exist. + +Connect the actual owners' draw exclusion to the active-business NIIT reform, respecting OASDI room, optional HI and explicit net-earnings factors. diff --git a/docs/contributed_reforms/owners_draw.md b/docs/contributed_reforms/owners_draw.md index 927f283782c..4118ea5d0f4 100644 --- a/docs/contributed_reforms/owners_draw.md +++ b/docs/contributed_reforms/owners_draw.md @@ -118,15 +118,26 @@ synchronize that path with the current tax cap. Mini-PIA integration and a complete annual-history ledger remain separate future work. `owners_draw_niit_exclusion` is the matching gross-profit exclusion for a -subsequent active-NIIT reform. With OASDI only it is +S.770 active-business NIIT option. With OASDI only it is `draw_oasdi_base / q`: only profits whose adjusted earnings actually face SECA are excluded. With HI enabled it is the entire actual gross draw D, because N_D faces uncapped HI. Profits beyond OASDI room remain in the active base when HI is off. The undrawn share and any floor-rejected planned draw also remain. Existing already-SE gross K_active must separately be excluded regardless of the OASDI cap because existing SE earnings face uncapped HI. -No active-NIIT reform is integrated here; its subsequent PR must consume -this output and reconcile its signed residual and thresholds. +When both reforms are active, `s770_active_business_income` consumes this +calculated exclusion, including an explicit net-factor override. When owners +draw is disabled, before its start year, or uninstalled, the S.770 manual +draw hooks retain their original behavior. + +The automatic joint route requires `material_participation_only: true`. +Passive-inclusive draws remain supported by the standalone owners' draw +reform, but the joint active-business calculation raises an explicit error +for that setting: the aggregate draw does not identify which taxed profits +belong in the active residual or in NII. Supporting that combination requires +a validated source allocation, including NII's separate +`s770_nii_fica_seca_income` hook. Subtracting the entire passive-inclusive draw +from active profits would misallocate the remaining bases between tax rates. ## Data limitations diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml index 5175eedc34b..6bcc7fe9616 100644 --- a/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml @@ -1,7 +1,7 @@ # Synthetic caps are fixed in both the ordinary tax parameter and the captured # current-law room parameter, so these examples do not depend on projections. # Unless overridden, q = 1 - 0.5 * (0.124 + 0.029) = 0.9235. -# Policy cases are grouped to retain at most five cached reform combinations. +# Joint policy cases explicitly install both structural reforms. - name: The standard netting factor follows changed OASDI and HI rates period: 2027 absolute_error_margin: 0.02 @@ -49,47 +49,67 @@ - name: An explicit netting factor overrides the rate-derived default period: 2027 - absolute_error_margin: 0.02 - reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + absolute_error_margin: 0.03 + reforms: + - policyengine_us.reforms.ssa.owners_draw.owners_draw + - policyengine_us.reforms.congress.s770.section_8.section_8 input: gov.contrib.ssa.owners_draw.current_law_cap: 200_000 gov.contrib.ssa.owners_draw.in_effect: true gov.contrib.ssa.owners_draw.net_earnings_factor: 0.8 gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.contrib.congress.s770.section_8.active_business.in_effect: true gov.irs.payroll.social_security.cap: 200_000 gov.irs.self_employment.rate.medicare: 0.04 gov.irs.self_employment.rate.social_security: 0.20 gov.simulation.aime_lookback_years: 0 - s_corp_income: 100_000 + payroll_tax_gross_wages: [0, 100_000] + s_corp_income: [100_000, 400_000] + filing_status: [SINGLE, SINGLE] + niit_magi: [1_000_000, 1_000_000] + net_investment_income: [0, 0] output: owners_draw_net_earnings_factor: 0.8 - owners_draw_net_earnings: 80_000 - owners_draw_oasdi_tax: 16_000 - + owners_draw_net_earnings: [80_000, 320_000] + owners_draw_oasdi_base: [80_000, 100_000] + owners_draw_oasdi_tax: [16_000, 20_000] + # The explicit .8 factor, rather than the rate-derived .88, controls M7. + owners_draw_niit_exclusion: [100_000, 125_000] + s770_active_business_income: [0, 275_000] + s770_active_taxable_base: [0, 275_000] + s770_active_business_income_tax: [0, 44_550] - name: HI taxes all adjusted draw earnings and makes M7 exclude the full actual gross draw period: 2027 - absolute_error_margin: 0.02 - reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + absolute_error_margin: 0.03 + reforms: + - policyengine_us.reforms.ssa.owners_draw.owners_draw + - policyengine_us.reforms.congress.s770.section_8.section_8 input: gov.contrib.ssa.owners_draw.current_law_cap: 200_000 gov.contrib.ssa.owners_draw.in_effect: true gov.contrib.ssa.owners_draw.include_hi: true gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.contrib.congress.s770.section_8.active_business.in_effect: true gov.irs.payroll.social_security.cap: 200_000 gov.simulation.aime_lookback_years: 0 - payroll_tax_gross_wages: [100_000, 250_000] - s_corp_income: [200_000, 150_000] + payroll_tax_gross_wages: [100_000, 250_000, 100_000] + s_corp_income: [200_000, 150_000, 400_000] + filing_status: [SINGLE, SINGLE, SINGLE] + niit_magi: [1_000_000, 1_000_000, 1_000_000] + net_investment_income: [0, 0, 0] output: - owners_draw_gross: [200_000, 150_000] - owners_draw_net_earnings: [184_700, 138_525] - owners_draw_oasdi_base: [100_000, 0] - owners_draw_oasdi_tax: [12_400, 0] - owners_draw_hi_tax: [5_356.30, 4_017.225] - self_employment_medicare_tax: [5_356.30, 4_017.225] - self_employment_tax_ald_person: [8_878.15, 2_008.6125] - owners_draw_niit_exclusion: [200_000, 150_000] - + owners_draw_gross: [200_000, 150_000, 400_000] + owners_draw_net_earnings: [184_700, 138_525, 369_400] + owners_draw_oasdi_base: [100_000, 0, 100_000] + owners_draw_oasdi_tax: [12_400, 0, 12_400] + owners_draw_hi_tax: [5_356.30, 4_017.225, 10_712.60] + self_employment_medicare_tax: [5_356.30, 4_017.225, 10_712.60] + self_employment_tax_ald_person: [8_878.15, 2_008.6125, 11_556.30] + owners_draw_niit_exclusion: [200_000, 150_000, 400_000] + s770_active_business_income: [0, 0, 0] + s770_active_taxable_base: [0, 0, 0] + s770_active_business_income_tax: [0, 0, 0] - name: Disabling the added deduction preserves the deduction for preexisting SE taxes period: 2027 @@ -111,3 +131,50 @@ owners_draw_hi_tax: 2_678.15 # Only 9,235 * (.124 + .029) / 2 = 706.4775 is deductible. self_employment_tax_ald_person: 706.4775 + +- name: Joint active NIIT excludes only the actual OASDI taxed owners draw + period: 2027 + absolute_error_margin: 0.03 + reforms: + - policyengine_us.reforms.ssa.owners_draw.owners_draw + - policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + people: + room: + age: 45 + payroll_tax_gross_wages: 100_000 + s_corp_income: 400_000 + no_room: + age: 45 + payroll_tax_gross_wages: 250_000 + s_corp_income: 400_000 + tax_units: + room: + members: [room] + filing_status: SINGLE + niit_magi: 1_000_000 + net_investment_income: 0 + no_room: + members: [no_room] + filing_status: SINGLE + niit_magi: 1_000_000 + net_investment_income: 0 + households: + household: + members: [room, no_room] + state_code: TX + output: + owners_draw_gross: [400_000, 400_000] + owners_draw_net_earnings: [369_400, 369_400] + owners_draw_oasdi_base: [100_000, 0] + # Default q=.9235: 100,000 / .9235 = 108,283.7033 of gross profits. + owners_draw_niit_exclusion: [108_283.7033, 0] + s770_active_business_income: [291_716.2967, 400_000] + s770_active_taxable_base: [291_716.2967, 400_000] + s770_active_business_income_tax: [47_258.0401, 64_800] diff --git a/policyengine_us/tests/reforms/test_owners_draw.py b/policyengine_us/tests/reforms/test_owners_draw.py index 88b25f2a549..9039eb4894b 100644 --- a/policyengine_us/tests/reforms/test_owners_draw.py +++ b/policyengine_us/tests/reforms/test_owners_draw.py @@ -22,6 +22,9 @@ create_owners_draw_reform, ) from policyengine_us.system import system +from policyengine_us.variables.gov.contrib.congress.s770.s770_active_business_income import ( + s770_active_business_income, +) from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.self_employment_tax_ald_person import ( self_employment_tax_ald_person, ) @@ -1010,3 +1013,296 @@ def test_generated_benefit_credit_cap_override_and_historical_lookback( no_credit.calculate("owners_draw_credited_earnings", off_year), np.zeros(len(batch)), ) + + +@settings(**SETTINGS) +@given( + rows=st.lists( + st.tuples( + st.integers(0, 1_000_000), + st.integers(-100_000, 1_000_000), + st.integers(0, 100_000), + st.integers(0, 100_000), + st.integers(-100_000, 100_000), + ), + min_size=1, + max_size=12, + ), + rates=_RATE_POLICY, + cap=st.integers(1_000, 1_000_000), + share=st.integers(0, 1_000), + override_factor=st.sampled_from([0, 0.5, 0.8, 1]), + include_hi=st.booleans(), +) +@example( + rows=[(250_000, 400_000, 0, 0, 0), (100_000, 400_000, 0, 0, 0), (0, 0, 0, 0, 0)], + rates=(124, 29, 500), + cap=200_000, + share=1_000, + override_factor=0, + include_hi=False, +) +@example( + rows=[ + (0, 499.99, 0, 0, 0), + (0, 500, 0, 0, 0), + (0, 500.01, 0, 0, 0), + (100_000, 400_000, 0, 0, 0), + ], + rates=(124, 29, 500), + cap=200_000, + share=1_000, + override_factor=0.8, + include_hi=False, +) +@example( + rows=[(250_000, 400_000, 0, 0, 0), (100_000, 400_000, 0, 0, 0), (0, -100, 0, 0, 0)], + rates=(124, 29, 500), + cap=200_000, + share=1_000, + override_factor=0, + include_hi=True, +) +@example( + rows=[(100_000, 400_000, 50_000, 30_000, 20_000)], + rates=(124, 29, 500), + cap=200_000, + share=300, + override_factor=0, + include_hi=False, +) +def test_generated_s770_excludes_actual_owners_draw( + reform_variables, rows, rates, cap, share, override_factor, include_hi +): + """The active NIIT residual consumes the draw's actual M7 classification.""" + wages, active, passive, already_se, other_se = np.asarray(rows, dtype=float).T + inputs = { + "payroll_tax_gross_wages": wages, + "s_corp_income": active, + "partnership_income": passive + already_se, + "passive_partnership_s_corp_income": passive, + "partnership_self_employment_net_earnings": already_se, + "self_employment_income": other_se, + } + policy = dict( + **_policy_rates(rates), + cap=cap, + phase_in_share=share / 1_000, + net_earnings_factor=override_factor, + include_hi=include_hi, + ) + values = _calculate(reform_variables, _parameters(**policy), inputs) + member_values = { + **values, + "partnership_s_corp_income": active + passive + already_se, + "s770_passive_se_income": 0, + "s770_draw_gross": 0, + "s770_draw_oasdi_base": 0, + "s770_draw_hi_in_effect": False, + "s770_active_fica_income": 0, + "is_tax_unit_dependent": False, + } + + def person(name, instant): + return np.asarray(member_values[name]) + + registry = {"owners_draw_niit_exclusion": object()} + person.simulation = SimpleNamespace( + tax_benefit_system=SimpleNamespace(variables=registry) + ) + # One tax unit per owner preserves the vector batch without a model clone. + tax_unit = SimpleNamespace(members=person, sum=lambda array: array) + result = s770_active_business_income.formula( + tax_unit, PERIOD, _parameters(**policy) + ) + np.testing.assert_allclose(result, active - values["owners_draw_niit_exclusion"]) + + # Installed-but-disabled, pre-start, and uninstalled draws retain the + # independent manual integration hooks, including their ordinary factor. + member_values["s770_draw_oasdi_base"] = 1_234.5 + q = 1 - policy["employer_share"] * (policy["oasdi_rate"] + policy["hi_rate"]) + for installed, enabled, start_year in ( + (True, False, 2027), + (True, True, 2028), + (False, True, 2027), + ): + person.simulation.tax_benefit_system.variables = registry if installed else {} + result = s770_active_business_income.formula( + tax_unit, + PERIOD, + _parameters(**policy, in_effect=enabled, start_year=start_year), + ) + np.testing.assert_allclose(result, active - 1_234.5 / q) + + +@settings(**SETTINGS) +@given( + rows=st.lists( + st.tuples( + st.integers(-1_000_000, 1_000_000), # total pass-through income + st.integers(-1_000_000, 1_000_000), # passive subset + st.integers(-1_000_000, 1_000_000), # already-SE subset + st.integers(-1_000_000, 1_000_000), # passive/SE intersection + st.integers(-1_000_000, 1_000_000), # manual actual gross draw + st.integers(-1_000_000, 1_000_000), # manual taxed OASDI draw + st.booleans(), # manual uncapped-HI indicator + st.integers(-1_000_000, 1_000_000), # active FICA exclusion + st.booleans(), # dependent exclusion + st.integers(0, 3), # tax-unit membership + ), + min_size=1, + max_size=12, + ), + rates=_RATE_POLICY, + override_factor=st.sampled_from([0, 0.5, 0.8, 1]), + include_hi=st.booleans(), +) +@example( + rows=[ + (400_000, 20_000, 30_000, 5_000, 50_000, 1_234.5, False, 6_000, False, 0), + (-10_000, -5_000, -3_000, -1_000, 2_000, 9_000, True, 1_000, False, 0), + (400_000, 0, 0, 0, 50_000, 12_345, False, 0, True, 0), + (100_000, 0, 0, 0, 8_000, 50_000, True, 2_000, False, 1), + ], + rates=(124, 29, 500), + override_factor=0.8, + include_hi=True, +) +@example( + rows=[(0, 0, 0, 0, 0, 0, False, 0, False, 0)], + rates=(200, 40, 500), + override_factor=0, + include_hi=False, +) +def test_generated_disabled_owners_draw_preserves_main_s770( + rows, rates, override_factor, include_hi +): + """Inactive draws preserve main's full signed residual and manual hooks.""" + columns = list(zip(*rows)) + ( + total, + passive, + already_se, + overlap, + manual_gross, + manual_oasdi, + manual_hi, + active_fica, + dependent, + tax_unit_index, + ) = [np.asarray(column) for column in columns] + policy = _policy_rates(rates) + q = 1 - policy["employer_share"] * (policy["oasdi_rate"] + policy["hi_rate"]) + # Independent oracle for main fe9ec1270f / #10402: net signed sources, + # subtract the manual M7 exclusion using main's rate-derived factor, then + # exclude dependents and aggregate head/spouse amounts within tax units. + # An owners-draw factor override must have no effect while it is inactive. + expected_person = np.zeros(len(rows), dtype=float) + for index, row in enumerate(rows): + t, p, se, intersection, gross, base, hi, fica, dep, _ = row + if not dep: + manual_exclusion = gross if hi else base / q + expected_person[index] = t - p - se + intersection - manual_exclusion - fica + expected = np.bincount(tax_unit_index, weights=expected_person, minlength=4) + values = { + "partnership_s_corp_income": total, + "passive_partnership_s_corp_income": passive, + "partnership_self_employment_net_earnings": already_se, + "s770_passive_se_income": overlap, + "s770_draw_gross": manual_gross, + "s770_draw_oasdi_base": manual_oasdi, + "s770_draw_hi_in_effect": manual_hi, + "s770_active_fica_income": active_fica, + "is_tax_unit_dependent": dependent, + # This is the actual exclusion for a disabled or pre-start draw. If + # the bridge accidentally consumes it, the manual hooks disappear. + "owners_draw_niit_exclusion": np.zeros(len(rows)), + } + + def person(name, instant): + return values[name] + + person.simulation = SimpleNamespace( + tax_benefit_system=SimpleNamespace(variables={}) + ) + tax_unit = SimpleNamespace( + members=person, + sum=lambda array: np.bincount(tax_unit_index, weights=array, minlength=4), + ) + for installed, enabled, start_year in ( + (True, False, 2027), + (True, True, 2028), + (False, True, 2027), + ): + person.simulation.tax_benefit_system.variables = ( + {"owners_draw_niit_exclusion": object()} if installed else {} + ) + parameters = _parameters( + **policy, + in_effect=enabled, + start_year=start_year, + net_earnings_factor=override_factor, + include_hi=include_hi, + ) + if not installed: + # The main-only path must not require the draw parameter subtree. + del parameters(PERIOD).gov.contrib.ssa.owners_draw + actual = s770_active_business_income.formula(tax_unit, PERIOD, parameters) + np.testing.assert_allclose(actual, expected, rtol=1e-12, atol=1e-8) + + +@pytest.mark.parametrize( + "installed, enabled, start_year", + [(True, True, 2027), (True, False, 2027), (True, True, 2028), (False, True, 2027)], +) +def test_s770_rejects_unclassified_passive_inclusive_draw( + reform_variables, installed, enabled, start_year +): + """Only the new active joint route needs an active/NII source allocation.""" + inputs = { + "s_corp_income": [200_000], + "passive_partnership_s_corp_income": [100_000], + } + standalone = _calculate( + reform_variables, + _parameters(material_participation_only=False), + inputs, + outputs=["owners_draw_active_income"], + ) + # Passive-inclusive treatment remains available for the standalone draw. + np.testing.assert_array_equal(standalone["owners_draw_active_income"], [200_000]) + values = { + "partnership_s_corp_income": 200_000, + "passive_partnership_s_corp_income": 100_000, + "partnership_self_employment_net_earnings": 0, + "s770_passive_se_income": 0, + "s770_draw_gross": 50_000, + "s770_draw_oasdi_base": 0, + "s770_draw_hi_in_effect": True, + "s770_active_fica_income": 0, + "is_tax_unit_dependent": False, + "owners_draw_niit_exclusion": 100_000, + } + + def person(name, instant): + return np.asarray(values[name]) + + person.simulation = SimpleNamespace( + tax_benefit_system=SimpleNamespace( + variables={"owners_draw_niit_exclusion": object()} if installed else {} + ) + ) + tax_unit = SimpleNamespace(members=person, sum=lambda array: array) + parameters = _parameters( + material_participation_only=False, + in_effect=enabled, + start_year=start_year, + ) + if installed and enabled and start_year <= PERIOD.start.year: + with pytest.raises(ValueError, match="validated active/NII allocation"): + s770_active_business_income.formula(tax_unit, PERIOD, parameters) + else: + np.testing.assert_array_equal( + s770_active_business_income.formula(tax_unit, PERIOD, parameters), + 50_000, + ) diff --git a/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income.py b/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income.py index 3e33405ff62..18263e9accf 100644 --- a/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income.py +++ b/policyengine_us/variables/gov/contrib/congress/s770/s770_active_business_income.py @@ -25,19 +25,41 @@ class s770_active_business_income(Variable): def formula(tax_unit, period, parameters): person = tax_unit.members - p = parameters(period).gov.irs - q = 1 - p.ald.misc.employer_share * ( - p.self_employment.rate.social_security + p.self_employment.rate.medicare + draw_installed = ( + "owners_draw_niit_exclusion" + in person.simulation.tax_benefit_system.variables ) - if q <= 0: - raise ValueError("S.770 owners-draw netting factor must be positive") - # M7: OASDI-only draws exclude only profits actually taxed after cap - # room. Uncapped HI draws exclude all actual gross draw, never planned D. - draw_exclusion = where( - person("s770_draw_hi_in_effect", period), - person("s770_draw_gross", period), - person("s770_draw_oasdi_base", period) / q, + draw_policy = ( + parameters(period).gov.contrib.ssa.owners_draw if draw_installed else None ) + if ( + draw_policy is not None + and draw_policy.in_effect + and period.start.year >= draw_policy.start_year + ): + if not draw_policy.material_participation_only: + raise ValueError( + "Joint owners-draw and active-business NIIT requires " + "material_participation_only=True; passive-inclusive draws " + "need a validated active/NII allocation" + ) + # Use the actual floor-, cap- and factor-aware exclusion. An + # explicit draw factor can differ from the standard SE factor. + draw_exclusion = person("owners_draw_niit_exclusion", period) + else: + p = parameters(period).gov.irs + q = 1 - p.ald.misc.employer_share * ( + p.self_employment.rate.social_security + p.self_employment.rate.medicare + ) + if q <= 0: + raise ValueError("S.770 owners-draw netting factor must be positive") + # M7: OASDI-only excludes only profits actually taxed after cap + # room. HI draws exclude all actual gross draw, never planned D. + draw_exclusion = where( + person("s770_draw_hi_in_effect", period), + person("s770_draw_gross", period), + person("s770_draw_oasdi_base", period) / q, + ) active_se = person("partnership_self_employment_net_earnings", period) - person( "s770_passive_se_income", period ) From b0c7f373940bff6635769e6104b2d75f707bf5a9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 12:55:08 -0400 Subject: [PATCH 09/13] Keep owners' draw review tests within the existing CI budget Use Core instants in the raised-cap class fixture and retain every YAML case while grouping each existing file within five reform combinations. Keep three generated public-simulation batches per route, all explicit edges, and forty fast formula examples to reduce measured test cost. Co-Authored-By: OpenAI Codex --- .../ssa/owners_draw_credited_earnings.yaml | 50 +++++++++++++++++++ .../contrib/ssa/owners_draw_net_earnings.yaml | 47 ----------------- .../tests/reforms/test_owners_draw.py | 4 +- 3 files changed, 52 insertions(+), 49 deletions(-) diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml index 418e19aac76..0e913adc146 100644 --- a/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml @@ -140,3 +140,53 @@ # Default 10% phase-in: 100,000 * .1 * .9235 = 9,235. # Only the current year has earnings, after age-60 indexing: 9,235 / 420. ss_aime: 21.98809524 + + +- name: Joint active NIIT excludes only the actual OASDI taxed owners draw + period: 2027 + absolute_error_margin: 0.03 + reforms: + - policyengine_us.reforms.ssa.owners_draw.owners_draw + - policyengine_us.reforms.congress.s770.section_8.section_8 + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.contrib.congress.s770.section_8.active_business.in_effect: true + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + people: + room: + age: 45 + payroll_tax_gross_wages: 100_000 + s_corp_income: 400_000 + no_room: + age: 45 + payroll_tax_gross_wages: 250_000 + s_corp_income: 400_000 + tax_units: + room: + members: [room] + filing_status: SINGLE + niit_magi: 1_000_000 + net_investment_income: 0 + no_room: + members: [no_room] + filing_status: SINGLE + niit_magi: 1_000_000 + net_investment_income: 0 + households: + household: + members: [room, no_room] + state_code: TX + output: + owners_draw_gross: [400_000, 400_000] + owners_draw_net_earnings: [369_400, 369_400] + owners_draw_oasdi_base: [100_000, 0] + # Benefit credit uses adjusted draw earnings independently of OASDI room. + owners_draw_credited_earnings: [369_400, 369_400] + # Default q=.9235: 100,000 / .9235 = 108,283.7033 of gross profits. + owners_draw_niit_exclusion: [108_283.7033, 0] + s770_active_business_income: [291_716.2967, 400_000] + s770_active_taxable_base: [291_716.2967, 400_000] + s770_active_business_income_tax: [47_258.0401, 64_800] diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml index 6bcc7fe9616..2232cc390d4 100644 --- a/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw_net_earnings.yaml @@ -131,50 +131,3 @@ owners_draw_hi_tax: 2_678.15 # Only 9,235 * (.124 + .029) / 2 = 706.4775 is deductible. self_employment_tax_ald_person: 706.4775 - -- name: Joint active NIIT excludes only the actual OASDI taxed owners draw - period: 2027 - absolute_error_margin: 0.03 - reforms: - - policyengine_us.reforms.ssa.owners_draw.owners_draw - - policyengine_us.reforms.congress.s770.section_8.section_8 - input: - gov.contrib.ssa.owners_draw.current_law_cap: 200_000 - gov.contrib.ssa.owners_draw.in_effect: true - gov.contrib.ssa.owners_draw.phase_in_share: 1 - gov.contrib.congress.s770.section_8.active_business.in_effect: true - gov.irs.payroll.social_security.cap: 200_000 - gov.simulation.aime_lookback_years: 0 - people: - room: - age: 45 - payroll_tax_gross_wages: 100_000 - s_corp_income: 400_000 - no_room: - age: 45 - payroll_tax_gross_wages: 250_000 - s_corp_income: 400_000 - tax_units: - room: - members: [room] - filing_status: SINGLE - niit_magi: 1_000_000 - net_investment_income: 0 - no_room: - members: [no_room] - filing_status: SINGLE - niit_magi: 1_000_000 - net_investment_income: 0 - households: - household: - members: [room, no_room] - state_code: TX - output: - owners_draw_gross: [400_000, 400_000] - owners_draw_net_earnings: [369_400, 369_400] - owners_draw_oasdi_base: [100_000, 0] - # Default q=.9235: 100,000 / .9235 = 108,283.7033 of gross profits. - owners_draw_niit_exclusion: [108_283.7033, 0] - s770_active_business_income: [291_716.2967, 400_000] - s770_active_taxable_base: [291_716.2967, 400_000] - s770_active_business_income_tax: [47_258.0401, 64_800] diff --git a/policyengine_us/tests/reforms/test_owners_draw.py b/policyengine_us/tests/reforms/test_owners_draw.py index 9039eb4894b..b738caa8a67 100644 --- a/policyengine_us/tests/reforms/test_owners_draw.py +++ b/policyengine_us/tests/reforms/test_owners_draw.py @@ -704,7 +704,7 @@ def private_draw_system(): SIMULATION_SETTINGS = { **SETTINGS, - "max_examples": 5, + "max_examples": 3, "suppress_health_check": [ *SETTINGS["suppress_health_check"], HealthCheck.function_scoped_fixture, @@ -830,7 +830,7 @@ def apply(self): ) activation.apply(self) self.parameters.gov.irs.payroll.social_security.cap.update( - start="2027-01-01", stop="2027-12-31", value=raised_cap + start=PERIOD.start, stop=PERIOD.stop, value=raised_cap ) simulation = _private_simulation( From 5d1d622855922167472a7b1beaba47186c918f0e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 13:58:57 -0400 Subject: [PATCH 10/13] Allow one float32 rounding step in the joint NIIT case Co-authored-by: OpenAI Codex --- .../policy/contrib/ssa/owners_draw_credited_earnings.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml index 0e913adc146..43bbd1dab26 100644 --- a/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw_credited_earnings.yaml @@ -144,7 +144,8 @@ - name: Joint active NIIT excludes only the actual OASDI taxed owners draw period: 2027 - absolute_error_margin: 0.03 + # One float32 rounding step near the active base is $0.03125. + absolute_error_margin: 0.05 reforms: - policyengine_us.reforms.ssa.owners_draw.owners_draw - policyengine_us.reforms.congress.s770.section_8.section_8 From 4b0a0aedf303c93854d29188e67166fc4f4841a7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 14:12:03 -0400 Subject: [PATCH 11/13] Bound owners' draw test history memory and runtime Stage the independent baseline and disabled calculations so their full historical parameter caches do not coexist. Retain the 45-year checks, use an exact hand baseline for credit-off AIME, and cover the inactive NIIT factor validation path. Co-authored-by: OpenAI Codex --- .../tests/reforms/test_owners_draw.py | 106 ++++++++++++++---- 1 file changed, 84 insertions(+), 22 deletions(-) diff --git a/policyengine_us/tests/reforms/test_owners_draw.py b/policyengine_us/tests/reforms/test_owners_draw.py index b738caa8a67..ca2fb4fc15d 100644 --- a/policyengine_us/tests/reforms/test_owners_draw.py +++ b/policyengine_us/tests/reforms/test_owners_draw.py @@ -7,6 +7,7 @@ """ import gc +from itertools import chain from types import SimpleNamespace import numpy as np @@ -704,7 +705,7 @@ def private_draw_system(): SIMULATION_SETTINGS = { **SETTINGS, - "max_examples": 3, + "max_examples": 2, "suppress_health_check": [ *SETTINGS["suppress_health_check"], HealthCheck.function_scoped_fixture, @@ -789,6 +790,21 @@ def _zero_earlier_earnings(simulation, year): simulation.set_input("total_self_employment_income", historic_year, zeros) +def _release_simulation_caches(simulation): + """Release derived history caches without changing policy or input values.""" + policy = simulation.tax_benefit_system + if getattr(policy, "simulation", None) is simulation: + policy.simulation = None + policy._parameters_at_instant_cache.clear() + root = policy.parameters + # Core eagerly materializes each full parameter tree at every instant. + # Children retain their own caches, so clearing only the root is not enough. + for node in chain((root,), root.get_descendants()): + cache = getattr(node, "_at_instant_cache", None) + if cache is not None: + cache.clear() + + @pytest.mark.parametrize("route", ["parametric", "class"]) @settings(**SIMULATION_SETTINGS) @given(batch=st.lists(simulation_person, min_size=3, max_size=8)) @@ -883,17 +899,7 @@ def test_generated_disabled_reform_matches_public_baseline( for output in BASELINE_VARIABLES: np.testing.assert_array_equal(probe[output], reference[output], err_msg=output) situation = _simulation_situation(batch) - baseline = Simulation(tax_benefit_system=system, situation=situation) - disabled = _private_simulation(private_draw_system, situation, create_owners_draw()) - _zero_earlier_earnings(baseline, 2027) - _zero_earlier_earnings(disabled, 2027) - assert ( - disabled.tax_benefit_system.parameters( - "2027-01-01" - ).gov.simulation.aime_lookback_years - == 45 - ) - for output in ( + outputs = ( *BASELINE_VARIABLES, "payroll_tax_gross_wages", "total_self_employment_income", @@ -903,12 +909,46 @@ def test_generated_disabled_reform_matches_public_baseline( "ss_aime", "niit_magi", "net_investment_income_tax", - ): - np.testing.assert_array_equal( - disabled.calculate(output, 2027), - baseline.calculate(output, 2027), - err_msg=output, + ) + # Calculate the real baseline first, then release its 45-year parameter + # caches before the independent disabled policy builds its own history. + # Keeping both full histories resident can exceed the file's memory cap. + baseline = Simulation(tax_benefit_system=system, situation=situation) + try: + _zero_earlier_earnings(baseline, 2027) + assert ( + baseline.tax_benefit_system.parameters( + "2027-01-01" + ).gov.simulation.aime_lookback_years + == 45 + ) + baseline_outputs = { + output: baseline.calculate(output, 2027).copy() for output in outputs + } + finally: + _release_simulation_caches(baseline) + del baseline + gc.collect() + + disabled = _private_simulation(private_draw_system, situation, create_owners_draw()) + try: + _zero_earlier_earnings(disabled, 2027) + assert ( + disabled.tax_benefit_system.parameters( + "2027-01-01" + ).gov.simulation.aime_lookback_years + == 45 ) + for output in outputs: + np.testing.assert_array_equal( + disabled.calculate(output, 2027), + baseline_outputs[output], + err_msg=output, + ) + finally: + _release_simulation_caches(disabled) + del disabled + gc.collect() @settings(**SIMULATION_SETTINGS) @@ -1002,13 +1042,21 @@ def test_generated_benefit_credit_cap_override_and_historical_lookback( off_year = year + 1 off_situation = _simulation_situation(batch, off_year, supplied) no_credit = _private_simulation(private_draw_system, off_situation) - baseline_off = Simulation(tax_benefit_system=system, situation=off_situation) _zero_earlier_earnings(no_credit, off_year) - _zero_earlier_earnings(baseline_off, off_year) - np.testing.assert_array_equal( - no_credit.calculate("ss_aime", off_year), - baseline_off.calculate("ss_aime", off_year), + # Every owner is 62 in this separate scoring-year fixture, with zero + # earnings in all 45 prior years. Main's baseline AIME therefore uses + # only current wages plus gross SE earnings, capped at that year's wage + # base, divided by 35 * 12; a positive supplied AIME still takes priority. + # This independent baseline oracle avoids a third simulation. The + # disabled-reform differential above also checks main's public helper. + off_cap = no_credit.tax_benefit_system.parameters( + f"{off_year}-01-01" + ).gov.ssa.social_security.wage_base + expected_off = np.where( + supplied > 0, supplied, np.minimum(wages + se, off_cap) / 420 ) + actual_off = no_credit.calculate("ss_aime", off_year) + np.testing.assert_array_equal(actual_off, expected_off.astype(actual_off.dtype)) np.testing.assert_array_equal( no_credit.calculate("owners_draw_credited_earnings", off_year), np.zeros(len(batch)), @@ -1306,3 +1354,17 @@ def person(name, instant): s770_active_business_income.formula(tax_unit, PERIOD, parameters), 50_000, ) + # Main rejects a nonpositive standard factor even for a manual HI + # hook. Inactive and absent draws must preserve that error as well. + invalid_q = _parameters( + oasdi_rate=1, + hi_rate=1, + employer_share=0.5, + material_participation_only=False, + in_effect=enabled, + start_year=start_year, + ) + if not installed: + del invalid_q(PERIOD).gov.contrib.ssa.owners_draw + with pytest.raises(ValueError, match="netting factor must be positive"): + s770_active_business_income.formula(tax_unit, PERIOD, invalid_q) From 4640c0c630d85a16223406f7d3e2efe8c76d9c17 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 15:09:19 -0400 Subject: [PATCH 12/13] Reuse private parameter histories in the disabled draw differential Compare fresh public baseline and disabled simulations with exact outputs while sharing one privately owned, fingerprinted parameter history. Restore the complete baseline variable registry between examples to avoid repeated cold 45-year histories. Co-authored-by: OpenAI Codex --- .../tests/reforms/test_owners_draw.py | 147 +++++++++++++----- 1 file changed, 105 insertions(+), 42 deletions(-) diff --git a/policyengine_us/tests/reforms/test_owners_draw.py b/policyengine_us/tests/reforms/test_owners_draw.py index ca2fb4fc15d..b6548c8d317 100644 --- a/policyengine_us/tests/reforms/test_owners_draw.py +++ b/policyengine_us/tests/reforms/test_owners_draw.py @@ -7,7 +7,9 @@ """ import gc +from hashlib import sha256 from itertools import chain +import pickle from types import SimpleNamespace import numpy as np @@ -700,6 +702,13 @@ def private_draw_system(): if owner.system is not None: owner.system.simulation = None owner.system = None + for name in ( + "baseline_variables", + "baseline_data_modified", + "parameter_root", + "parameter_fingerprint", + ): + owner.__dict__.pop(name, None) gc.collect() @@ -790,19 +799,32 @@ def _zero_earlier_earnings(simulation, year): simulation.set_input("total_self_employment_income", historic_year, zeros) -def _release_simulation_caches(simulation): - """Release derived history caches without changing policy or input values.""" +def _release_simulation_reference(simulation): + """Release holder ownership while retaining the test's readonly policy cache.""" policy = simulation.tax_benefit_system if getattr(policy, "simulation", None) is simulation: policy.simulation = None - policy._parameters_at_instant_cache.clear() - root = policy.parameters - # Core eagerly materializes each full parameter tree at every instant. - # Children retain their own caches, so clearing only the root is not enough. + + +def _parameter_policy_fingerprint(root): + """Fingerprint policy histories and scale structure, excluding instant caches.""" + digest = sha256() + # Core's descendants include every scale bracket and its component leaves. + # Stream one small record per node; never serialize a node or its caches. for node in chain((root,), root.get_descendants()): - cache = getattr(node, "_at_instant_cache", None) - if cache is not None: - cache.clear() + record = ( + type(node).__name__, + node.name, + tuple(getattr(node, "children", {})), + tuple(bracket.name for bracket in getattr(node, "brackets", ())), + getattr(node, "metadata", {}).get("type"), + tuple( + (value.instant_str, value.value) + for value in getattr(node, "values_list", ()) + ), + ) + digest.update(pickle.dumps(record, protocol=5)) + return digest.digest() @pytest.mark.parametrize("route", ["parametric", "class"]) @@ -910,45 +932,86 @@ def test_generated_disabled_reform_matches_public_baseline( "niit_magi", "net_investment_income_tax", ) - # Calculate the real baseline first, then release its 45-year parameter - # caches before the independent disabled policy builds its own history. - # Keeping both full histories resident can exceed the file's memory cap. - baseline = Simulation(tax_benefit_system=system, situation=situation) - try: - _zero_earlier_earnings(baseline, 2027) - assert ( - baseline.tax_benefit_system.parameters( - "2027-01-01" - ).gov.simulation.aime_lookback_years - == 45 + # This installer changes only the registry. One privately cloned policy + # can therefore retain its readonly 45-year parameter views for both real + # public simulations while their holders and arrays stay independent. + # Constructor activation/isolation remain covered by the original tests + # and both generated raised-cap constructor routes above. + if private_draw_system.system is None: + policy = system.clone() + policy.simulation = None + private_draw_system.system = policy + private_draw_system.baseline_variables = dict(policy.variables) + private_draw_system.baseline_data_modified = policy.data_modified + private_draw_system.parameter_root = policy.parameters + private_draw_system.parameter_fingerprint = _parameter_policy_fingerprint( + policy.parameters ) - baseline_outputs = { - output: baseline.calculate(output, 2027).copy() for output in outputs - } - finally: - _release_simulation_caches(baseline) - del baseline - gc.collect() - - disabled = _private_simulation(private_draw_system, situation, create_owners_draw()) + policy = private_draw_system.system + policy.variables = dict(private_draw_system.baseline_variables) + policy.data_modified = private_draw_system.baseline_data_modified + root = private_draw_system.parameter_root + assert policy.parameters is root + assert ( + _parameter_policy_fingerprint(root) == private_draw_system.parameter_fingerprint + ) + assert not policy.parameters("2027-01-01").gov.contrib.ssa.owners_draw.in_effect + assert "owners_draw_gross" not in policy.variables try: - _zero_earlier_earnings(disabled, 2027) + baseline = Simulation(tax_benefit_system=policy, situation=situation) + try: + _zero_earlier_earnings(baseline, 2027) + assert ( + baseline.tax_benefit_system.parameters( + "2027-01-01" + ).gov.simulation.aime_lookback_years + == 45 + ) + baseline_outputs = { + output: baseline.calculate(output, 2027).copy() for output in outputs + } + finally: + _release_simulation_reference(baseline) + del baseline + gc.collect() + + create_owners_draw().apply(policy) + assert policy.parameters is root assert ( - disabled.tax_benefit_system.parameters( - "2027-01-01" - ).gov.simulation.aime_lookback_years - == 45 + _parameter_policy_fingerprint(root) + == private_draw_system.parameter_fingerprint ) - for output in outputs: - np.testing.assert_array_equal( - disabled.calculate(output, 2027), - baseline_outputs[output], - err_msg=output, + assert not policy.parameters("2027-01-01").gov.contrib.ssa.owners_draw.in_effect + assert "owners_draw_gross" in policy.variables + disabled = Simulation(tax_benefit_system=policy, situation=situation) + try: + _zero_earlier_earnings(disabled, 2027) + assert ( + disabled.tax_benefit_system.parameters( + "2027-01-01" + ).gov.simulation.aime_lookback_years + == 45 ) + for output in outputs: + np.testing.assert_array_equal( + disabled.calculate(output, 2027), + baseline_outputs[output], + err_msg=output, + ) + assert policy.parameters is root + assert ( + _parameter_policy_fingerprint(root) + == private_draw_system.parameter_fingerprint + ) + finally: + _release_simulation_reference(disabled) + del disabled + gc.collect() finally: - _release_simulation_caches(disabled) - del disabled - gc.collect() + # Restore all original variables, including removing added draw names. + # A fresh public constructor rebinds entities and holders next example. + policy.variables = dict(private_draw_system.baseline_variables) + policy.data_modified = private_draw_system.baseline_data_modified @settings(**SIMULATION_SETTINGS) From b5fb5cabb2227fab8e00d40a9fc3bb964768f0cd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 16:02:21 -0400 Subject: [PATCH 13/13] Distinguish parameter children from scale fields in history fingerprints Use Core parameter types when reading child keys, scale brackets and scalar histories. A regular parameter node can expose a child named brackets; treating that child as a list raised KeyError in the disabled-reform differential. Co-authored-by: OpenAI Codex --- policyengine_us/tests/reforms/test_owners_draw.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/reforms/test_owners_draw.py b/policyengine_us/tests/reforms/test_owners_draw.py index b6548c8d317..a20f8c1e555 100644 --- a/policyengine_us/tests/reforms/test_owners_draw.py +++ b/policyengine_us/tests/reforms/test_owners_draw.py @@ -14,6 +14,7 @@ import numpy as np import pytest +from policyengine_core.parameters import Parameter, ParameterNode, ParameterScale from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_core.periods import period @@ -810,17 +811,22 @@ def _parameter_policy_fingerprint(root): """Fingerprint policy histories and scale structure, excluding instant caches.""" digest = sha256() # Core's descendants include every scale bracket and its component leaves. - # Stream one small record per node; never serialize a node or its caches. + # A regular node may also have a child named "brackets"; inspect scale + # fields only on their actual Core types. Never serialize nodes or caches. for node in chain((root,), root.get_descendants()): record = ( type(node).__name__, node.name, - tuple(getattr(node, "children", {})), - tuple(bracket.name for bracket in getattr(node, "brackets", ())), + tuple(node.children) if isinstance(node, ParameterNode) else (), + ( + tuple(bracket.name for bracket in node.brackets) + if isinstance(node, ParameterScale) + else () + ), getattr(node, "metadata", {}).get("type"), tuple( (value.instant_str, value.value) - for value in getattr(node, "values_list", ()) + for value in (node.values_list if isinstance(node, Parameter) else ()) ), ) digest.update(pickle.dumps(record, protocol=5))