Skip to content
Merged
1 change: 1 addition & 0 deletions changelog.d/state-mortgage-interest-federal-cap.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Limit Alabama's and North Carolina's mortgage interest deductions to the federally allowed amount after the 26 U.S.C. 163(h)(3) acquisition-debt limits, and Hawaii's to its own pre-TCJA limits ($1,000,000 of acquisition debt plus $100,000 of home equity debt) under HRS 235-2.4, instead of all interest paid.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
description: Hawaii limits the home acquisition debt on which mortgage interest is deductible to this amount, reduced by any grandfathered debt, based on filing status.
SINGLE:
2018-01-01: 1_000_000
JOINT:
2018-01-01: 1_000_000
SEPARATE:
2018-01-01: 500_000
HEAD_OF_HOUSEHOLD:
2018-01-01: 1_000_000
SURVIVING_SPOUSE:
2018-01-01: 1_000_000
metadata:
propagate_metadata_to_children: true
breakdown: filing_status
unit: currency-USD
period: year
label: Hawaii home acquisition debt limit
reference:
# HRS 235-2.4 makes IRC 163(h)(3)(F), which lowers the limit to $750,000
# for later debt, inoperative, so the IRC 163(h)(3)(B)(ii) limit applies
# whenever the debt was incurred.
- title: Hawaii Revised Statutes 235-2.4(j)(3)
href: https://data.capitol.hawaii.gov/hrscurrent/Vol04_Ch0201-0257/HRS0235/HRS_0235-0002_0004.htm
- title: Act 35, SLH 2026 (HB2329 CD1), HRS 235-2.4(i)(3), for taxable years beginning after December 31, 2025
href: https://data.capitol.hawaii.gov/sessions/session2026/bills/HB2329_CD1_.pdf#page=20
- title: 2018 Hawaii N-11 Instructions, Line 21c (Act 27, SLH 2018 does not lower the limit)
href: https://files.hawaii.gov/tax/forms/2018/n11ins.pdf#page=19
- title: 2025 Hawaii N-11 Instructions, Line 21c
href: https://files.hawaii.gov/tax/forms/2025/n11ins.pdf#page=17
- title: 2017 IRS Publication 936, Table 1, line 3
href: https://www.irs.gov/pub/irs-prior/p936--2017.pdf#page=11
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
description: Hawaii limits the home equity debt on which mortgage interest is deductible to this amount, based on filing status.
SINGLE:
2018-01-01: 100_000
JOINT:
2018-01-01: 100_000
SEPARATE:
2018-01-01: 50_000
HEAD_OF_HOUSEHOLD:
2018-01-01: 100_000
SURVIVING_SPOUSE:
2018-01-01: 100_000
metadata:
propagate_metadata_to_children: true
breakdown: filing_status
unit: currency-USD
period: year
label: Hawaii home equity debt limit
reference:
# HRS 235-2.4 makes IRC 163(h)(3)(F), which suspends the home equity
# interest deduction, inoperative, so the IRC 163(h)(3)(C)(ii) limit
# applies.
- title: Hawaii Revised Statutes 235-2.4(j)(3)
href: https://data.capitol.hawaii.gov/hrscurrent/Vol04_Ch0201-0257/HRS0235/HRS_0235-0002_0004.htm
- title: Act 35, SLH 2026 (HB2329 CD1), HRS 235-2.4(i)(3), for taxable years beginning after December 31, 2025
href: https://data.capitol.hawaii.gov/sessions/session2026/bills/HB2329_CD1_.pdf#page=20
- title: 2018 Hawaii N-11 Instructions, Line 21c (Act 27, SLH 2018 does not suspend home equity interest)
href: https://files.hawaii.gov/tax/forms/2018/n11ins.pdf#page=19
- title: 2025 Hawaii N-11 Instructions, Line 21c
href: https://files.hawaii.gov/tax/forms/2025/n11ins.pdf#page=17
- title: "2017 IRS Publication 936, Home equity debt limit and Table 1, line 7 #page 10-11"
href: https://www.irs.gov/pub/irs-prior/p936--2017.pdf#page=10
285 changes: 285 additions & 0 deletions policyengine_us/tests/core/test_hi_mortgage_debt_limit_invariants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
"""Invariants for Hawaii's mortgage debt limits.

HRS 235-2.4(j)(3) (235-2.4(i)(3) from 2026) makes IRC 163(h)(3)(F)
inoperative, so Hawaii keeps the pre-TCJA limits for debt incurred at any
time. IRS Publication 936 (2017), Table 1 (PDF page 11), computes them:
grandfathered debt, incurred on or before October 13, 1987, has no dollar
limit and reduces the $1,000,000 acquisition-debt limit ($500,000 married
filing separately); up to $100,000 ($50,000) of debt above that limit counts
as home equity debt (Figure A, note 3, PDF page 3).

The law does not prescribe allocation rounding. PE uses the N-11 / 2017
Publication 936 worksheet as a gap-fill and separately applies a federal
deduction floor as its chosen approximation. The floor is a PE safeguard,
not part of worksheet line 12.

Hypothesis draws pairs of loans around every cutoff and limit and checks
_hi_qualified_loan_limit against two independent restatements, one line by
line from Table 1 and one in the statute's order, and against the federal
helper:

1. Differential: the helper equals both restatements.
2. Bounds: grandfathered debt <= Hawaii limit <= total debt, and the Hawaii
limit is at least the federal limit for the same loans. PE's chosen
approximation separately preserves the federal deduction floor below.
3. Order: swapping the two loans never changes the limit.
4. Vintage: Hawaii has one limit for debt incurred after October 13, 1987, so
moving a loan between 2017 and 2018 never changes the limit, while moving
it into the grandfathered years never lowers it.
5. Monotone: a larger balance never lowers the limit.
6. Share: line 11, the limit over total debt "rounded to three places", lies
in [0, 1], within half a thousandth of the exact ratio and, for the drawn
balances, at or above the federal (exact) share. It is the same whether
the balances arrive in single precision, as in a simulation, or double.
7. PE safeguard: the Hawaii deduction lies between the federal deduction and
the interest paid, equals the federal deduction when federal law disallows
nothing (so a deduction supplied directly is not limited again), and
otherwise equals worksheet interest paid times line 11, floored by PE at
the federal amount.

The limits are read from the Hawaii and federal parameters for every filing
status in 2018, 2025 and 2026, so the properties hold for the values the model
uses. A separate check ties the Hawaii parameters to the statute's dollar
amounts.

These are for-all properties of the helpers, which YAML cannot state. The
policy outcomes and interest proration are checked in
hi_mortgage_interest_deduction.yaml.
"""

import numpy as np
from hypothesis import given, settings
from hypothesis import strategies as st

from policyengine_us.system import system
from policyengine_us.variables.gov.states.hi.tax.income.deductions.itemized.hi_mortgage_interest_deduction import (
_hi_allowed_interest,
_hi_deductible_share,
_hi_qualified_loan_limit,
)
from policyengine_us.variables.household.expense.tax_unit.mortgage_interest_structure import (
_debt_by_vintage,
_qualified_loan_limit,
)

GRANDFATHERED_YEAR = 1987
PRE_TCJA_YEAR = 2017
FILING_STATUSES = (
"SINGLE",
"JOINT",
"SEPARATE",
"HEAD_OF_HOUSEHOLD",
"SURVIVING_SPOUSE",
)
TAX_YEARS = (2018, 2025, 2026)


def parameter_limits(year, filing_status):
"""((Hawaii acquisition limit, Hawaii home equity limit), (federal limit
for debt from 2017 or earlier, federal limit for later debt))."""
p = system.parameters(f"{year}-01-01")
hawaii = p.gov.states.hi.tax.income.deductions.itemized.interest
federal = p.gov.irs.deductions.itemized.interest.mortgage
return (
(
float(hawaii.home_acquisition_debt_limit[filing_status]),
float(hawaii.home_equity_debt_limit[filing_status]),
),
(
float(federal.pre_tcja_cap[filing_status]),
float(federal.cap[filing_status]),
),
)


# (tax year, filing status) -> limits, read once from the parameter tree.
LIMITS = {
(year, filing_status): parameter_limits(year, filing_status)
for year in TAX_YEARS
for filing_status in FILING_STATUSES
}
TOLERANCE = 0.01

YEARS = (0, 1960, 1986, 1987, 1988, 2000, 2016, 2017, 2018, 2025)
BOUNDARY_BALANCES = (
0,
1,
50_000,
100_000,
375_000,
500_000,
550_000,
750_000,
1_000_000,
1_100_000,
)
balances = st.one_of(
st.sampled_from(BOUNDARY_BALANCES),
st.integers(0, 3_000_000),
).map(float)
loans = st.tuples(balances, st.sampled_from(YEARS))


def grandfathered(year):
return 0 < year <= GRANDFATHERED_YEAR


def split(loan_list):
old = sum(balance for balance, year in loan_list if grandfathered(year))
other = sum(balance for balance, year in loan_list if not grandfathered(year))
return old, other


def table_1(loan_list, limits):
"""Publication 936 (2017) Table 1, lines 1-8."""
acquisition_limit, home_equity_limit = limits
line_1, line_2 = split(loan_list)
line_3 = acquisition_limit
line_4 = max(line_1, line_3)
line_5 = line_1 + line_2
line_6 = min(line_4, line_5)
# Debt above the acquisition limit, up to the home equity limit.
line_7 = min(line_5 - line_6, home_equity_limit)
return line_6 + line_7


def statute(loan_list, limits):
"""26 U.S.C. 163(h)(3)(B)(ii), (C)(ii) and (D) without (F), in order."""
acquisition_limit, home_equity_limit = limits
old, other = split(loan_list)
acquisition = min(other, max(0.0, acquisition_limit - old))
home_equity = min(home_equity_limit, other - acquisition)
return old + acquisition + home_equity


def model_limit(loan_list, limits):
debt = _debt_by_vintage(
[np.array([balance]) for balance, _ in loan_list],
[np.array([year]) for _, year in loan_list],
GRANDFATHERED_YEAR,
PRE_TCJA_YEAR,
)
old, pre, post = debt
return float(_hi_qualified_loan_limit(old, pre + post, *limits)[0])


def federal_limit(loan_list, limits):
debt = _debt_by_vintage(
[np.array([balance]) for balance, _ in loan_list],
[np.array([year]) for _, year in loan_list],
GRANDFATHERED_YEAR,
PRE_TCJA_YEAR,
)
return float(_qualified_loan_limit(*debt, *limits)[0])


SETTINGS = dict(max_examples=400, deadline=None, derandomize=True)


def test_hawaii_limits_are_the_statutory_amounts():
# IRC 163(h)(3)(B)(ii) and (C)(ii), kept by HRS 235-2.4(j)(3): $1,000,000
# of acquisition debt and $100,000 of home equity debt, halved for a
# married individual filing a separate return.
for (year, filing_status), (hawaii_limits, _) in LIMITS.items():
expected = (
(500_000, 50_000) if filing_status == "SEPARATE" else (1_000_000, 100_000)
)
assert hawaii_limits == expected, (year, filing_status)


@settings(**SETTINGS)
@given(first=loans, second=loans, status=st.sampled_from(sorted(LIMITS)))
def test_hawaii_qualified_loan_limit_properties(first, second, status):
hawaii_limits, federal_limits = LIMITS[status]
pair = [first, second]
limit = model_limit(pair, hawaii_limits)
old, other = split(pair)
total = old + other

# 1. Differential.
assert abs(limit - table_1(pair, hawaii_limits)) <= TOLERANCE
assert abs(limit - statute(pair, hawaii_limits)) <= TOLERANCE

# 2. Bounds.
assert old - TOLERANCE <= limit <= total + TOLERANCE
assert limit >= federal_limit(pair, federal_limits) - TOLERANCE

# 3. Order.
assert abs(model_limit([second, first], hawaii_limits) - limit) <= TOLERANCE

for i, (balance, year) in enumerate(pair):
# 4. Vintage.
for other_year in YEARS:
moved = list(pair)
moved[i] = (balance, other_year)
moved_limit = model_limit(moved, hawaii_limits)
if grandfathered(other_year) == grandfathered(year):
assert abs(moved_limit - limit) <= TOLERANCE
elif grandfathered(other_year):
assert moved_limit >= limit - TOLERANCE

# 5. Monotone.
bigger = list(pair)
bigger[i] = (balance + 100_000, year)
assert model_limit(bigger, hawaii_limits) >= limit - TOLERANCE

# 6. Share.
share = float(_hi_deductible_share(np.array([limit]), np.array([total]))[0])
assert 0 <= share <= 1
if total > 0:
assert abs(share - limit / total) <= 0.0005 + 1e-9
assert share >= federal_limit(pair, federal_limits) / total - 1e-9
else:
assert share == 1
single = _hi_deductible_share(
np.array([limit], dtype=np.float32), np.array([total], dtype=np.float32)
)
assert float(single[0]) == share


amounts = st.one_of(st.just(0.0), st.floats(0, 500_000, allow_nan=False))


@settings(**SETTINGS)
@given(
deductible=amounts,
non_deductible=amounts,
share=st.one_of(st.sampled_from((0.0, 0.5, 0.733, 1.0)), st.floats(0, 1)),
)
def test_hawaii_allowed_interest_properties(deductible, non_deductible, share):
allowed = float(
_hi_allowed_interest(
np.array([deductible]), np.array([non_deductible]), np.array([share])
)[0]
)
paid = deductible + non_deductible
# 7. PE federal-floor safeguard.
assert deductible - TOLERANCE <= allowed <= paid + TOLERANCE
if non_deductible == 0:
assert allowed == deductible
else:
assert abs(allowed - max(paid * share, deductible)) <= TOLERANCE


def test_hawaii_share_rounds_half_up_to_three_places():
limits = np.array([550_000.0, 1_100_000.0, 1_100_000.0, 0.0])
debt = np.array([1_760_000.0, 1_500_000.0, 1_300_000.0, 0.0])
# .3125 rounds up to .313; .7333 down to .733; .8462 down to .846; no
# balance gives one.
np.testing.assert_allclose(
_hi_deductible_share(limits, debt), [0.313, 0.733, 0.846, 1.0]
)
# .97449999 rounds down, in single precision too.
near_tie = _hi_deductible_share(
np.array([1_100_000], dtype=np.float32),
np.array([1_128_784], dtype=np.float32),
)
np.testing.assert_array_equal(near_tie, [0.974])
# The share stays 1.000 until total debt passes $1,100,000 / .9995 =
# $1,100,550.28, in single precision too.
for dtype in (np.float32, np.float64):
boundary = _hi_deductible_share(
np.full(4, 1_100_000, dtype=dtype),
np.array([1_100_000, 1_100_550, 1_100_551, 1_101_000], dtype=dtype),
)
np.testing.assert_array_equal(boundary, [1.0, 1.0, 0.999, 0.999])
Loading
Loading