diff --git a/src/__tests__/pages/AppPage.integration.test.js b/src/__tests__/pages/AppPage.integration.test.js new file mode 100644 index 000000000..d60f188eb --- /dev/null +++ b/src/__tests__/pages/AppPage.integration.test.js @@ -0,0 +1,233 @@ +import { render, act, waitFor } from "@testing-library/react"; +import { BrowserRouter } from "react-router-dom"; +import AppPage from "../../pages/AppPage"; + +// This integration test simulates the actual "one behind" issue +// where the iframe might be sending outdated parameters + +// Mock the apps data +jest.mock("../../apps/appTransformers", () => ({ + apps: [ + { + title: "OBBBA household-by-household analysis", + description: "Test description", + url: "https://policyengine.github.io/obbba-scatter", + slug: "obbba-household-by-household", + tags: ["us", "featured", "policy"], + }, + ], +})); + +// Track navigation calls +const navigationHistory = []; +const mockNavigate = jest.fn((url) => { + navigationHistory.push(url); +}); + +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useParams: () => ({ appName: "obbba-household-by-household" }), + useNavigate: () => mockNavigate, + useLocation: () => ({ + pathname: "/us/obbba-household-by-household", + search: + navigationHistory.length > 0 + ? navigationHistory[navigationHistory.length - 1].split("?")[1] || "" + : "?household=initial", + }), +})); + +// Mock the Header and Footer components +jest.mock("../../layout/Header", () => { + return function Header() { + return
Header
; + }; +}); + +jest.mock("../../layout/Footer", () => { + return function Footer() { + return
Footer
; + }; +}); + +describe("AppPage Integration - One Behind Issue", () => { + beforeEach(() => { + jest.clearAllMocks(); + navigationHistory.length = 0; + }); + + it("demonstrates the 'one behind' issue when iframe sends old state", async () => { + const consoleSpy = jest.spyOn(console, "log").mockImplementation(); + + render( + + + , + ); + + // Simulate the problematic sequence where iframe sends old state + const sequence = [ + { + iframeCurrentState: "household=12345", + iframeSendsState: "household=initial", // Sends old state + expectedParentUrl: "household=initial", + }, + { + iframeCurrentState: "household=67890", + iframeSendsState: "household=12345", // Sends previous state + expectedParentUrl: "household=12345", + }, + { + iframeCurrentState: "household=99999", + iframeSendsState: "household=67890", // Sends previous state + expectedParentUrl: "household=67890", + }, + ]; + + for (const step of sequence) { + console.log(`\n--- Iframe shows: ${step.iframeCurrentState} ---`); + console.log(`Iframe sends: ${step.iframeSendsState}`); + + // Simulate iframe sending the OLD state + const messageEvent = new MessageEvent("message", { + data: { + type: "urlUpdate", + params: step.iframeSendsState, + }, + origin: "https://policyengine.github.io", + }); + + act(() => { + window.dispatchEvent(messageEvent); + }); + + await waitFor(() => { + const lastCall = + mockNavigate.mock.calls[mockNavigate.mock.calls.length - 1]; + expect(lastCall).toBeTruthy(); + const urlParams = lastCall[0].split("?")[1]; + console.log(`Parent URL updated to: ${urlParams}`); + expect(urlParams).toBe(step.expectedParentUrl); + }); + } + + console.log("\n--- Summary ---"); + console.log( + "The parent URL is always one step behind what the iframe displays!", + ); + console.log( + "This happens because the iframe sends its OLD state before updating internally.", + ); + + consoleSpy.mockRestore(); + }); + + it("shows correct behavior when iframe sends current state", async () => { + const consoleSpy = jest.spyOn(console, "log").mockImplementation(); + + render( + + + , + ); + + // Simulate the CORRECT sequence where iframe sends current state + const sequence = [ + { + iframeCurrentState: "household=12345", + iframeSendsState: "household=12345", // Sends current state + expectedParentUrl: "household=12345", + }, + { + iframeCurrentState: "household=67890", + iframeSendsState: "household=67890", // Sends current state + expectedParentUrl: "household=67890", + }, + ]; + + for (const step of sequence) { + console.log(`\n--- Iframe shows: ${step.iframeCurrentState} ---`); + console.log(`Iframe sends: ${step.iframeSendsState}`); + + const messageEvent = new MessageEvent("message", { + data: { + type: "urlUpdate", + params: step.iframeSendsState, + }, + origin: "https://policyengine.github.io", + }); + + act(() => { + window.dispatchEvent(messageEvent); + }); + + await waitFor(() => { + const lastCall = + mockNavigate.mock.calls[mockNavigate.mock.calls.length - 1]; + expect(lastCall).toBeTruthy(); + const urlParams = lastCall[0].split("?")[1]; + console.log(`Parent URL updated to: ${urlParams}`); + expect(urlParams).toBe(step.expectedParentUrl); + }); + } + + console.log("\n--- Summary ---"); + console.log("When iframe sends its CURRENT state, URLs stay in sync!"); + + consoleSpy.mockRestore(); + }); + + it("demonstrates timing issue with state updates", async () => { + render( + + + , + ); + + // Simulate what might be happening in the iframe + const simulateIframeClick = async (newHousehold) => { + // 1. User clicks on household + console.log(`User clicks household: ${newHousehold}`); + + // 2. Iframe might send message BEFORE updating its own state + const oldState = + navigationHistory.length > 0 + ? navigationHistory[navigationHistory.length - 1].split("=")[1] + : "initial"; + + const messageEvent = new MessageEvent("message", { + data: { + type: "urlUpdate", + params: `household=${oldState}`, // Sends OLD state! + }, + origin: "https://policyengine.github.io", + }); + + window.dispatchEvent(messageEvent); + + // 3. Then iframe updates its own display + console.log( + `Iframe now displays: ${newHousehold} (but parent got: ${oldState})`, + ); + }; + + // Simulate user interactions + await simulateIframeClick("12345"); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledTimes(1)); + + await simulateIframeClick("67890"); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledTimes(2)); + + // Verify the "one behind" pattern + expect(mockNavigate).toHaveBeenNthCalledWith( + 1, + "/us/obbba-household-by-household?household=initial", + { replace: true }, + ); + expect(mockNavigate).toHaveBeenNthCalledWith( + 2, + "/us/obbba-household-by-household?household=initial", // Still showing first value! + { replace: true }, + ); + }); +}); diff --git a/src/__tests__/pages/AppPage.oneBehind.test.js b/src/__tests__/pages/AppPage.oneBehind.test.js new file mode 100644 index 000000000..623d24c70 --- /dev/null +++ b/src/__tests__/pages/AppPage.oneBehind.test.js @@ -0,0 +1,101 @@ +/** + * This test demonstrates the "one behind" issue with OBBBA URL synchronization + * + * The problem: When clicking households in the iframe, the parent URL updates + * but shows the PREVIOUS household ID, not the current one. + * + * Root cause: The iframe is sending its OLD state in the postMessage before + * updating its own internal state. + */ + +describe("OBBBA 'One Behind' Issue", () => { + it("demonstrates the problem", () => { + // Simulate what's happening: + const iframeInternalState = { household: "initial" }; + const parentUrlHistory = []; + + // Function to simulate iframe behavior (problematic version) + const simulateIframeClick_PROBLEMATIC = (newHousehold) => { + // 1. User clicks, iframe sends message with CURRENT (old) state + parentUrlHistory.push(iframeInternalState.household); + + // 2. THEN iframe updates its own state + iframeInternalState.household = newHousehold; + + console.log(`After clicking ${newHousehold}:`); + console.log(` Iframe shows: ${iframeInternalState.household}`); + console.log( + ` Parent URL shows: ${parentUrlHistory[parentUrlHistory.length - 1]}`, + ); + console.log( + ` One behind? ${iframeInternalState.household !== parentUrlHistory[parentUrlHistory.length - 1]}`, + ); + }; + + // Simulate user interactions + console.log("\n=== PROBLEMATIC BEHAVIOR (what's happening now) ==="); + simulateIframeClick_PROBLEMATIC("12345"); + simulateIframeClick_PROBLEMATIC("67890"); + simulateIframeClick_PROBLEMATIC("99999"); + + // Verify the "one behind" pattern + expect(parentUrlHistory).toEqual(["initial", "12345", "67890"]); + expect(iframeInternalState.household).toBe("99999"); + + // The parent is showing "67890" but iframe is showing "99999"! + expect(parentUrlHistory[parentUrlHistory.length - 1]).not.toBe( + iframeInternalState.household, + ); + }); + + it("shows how it should work", () => { + const iframeInternalState = { household: "initial" }; + const parentUrlHistory = []; + + // Function to simulate iframe behavior (correct version) + const simulateIframeClick_CORRECT = (newHousehold) => { + // 1. User clicks, iframe updates its own state FIRST + iframeInternalState.household = newHousehold; + + // 2. THEN sends message with NEW state + parentUrlHistory.push(iframeInternalState.household); + + console.log(`After clicking ${newHousehold}:`); + console.log(` Iframe shows: ${iframeInternalState.household}`); + console.log( + ` Parent URL shows: ${parentUrlHistory[parentUrlHistory.length - 1]}`, + ); + console.log( + ` In sync? ${iframeInternalState.household === parentUrlHistory[parentUrlHistory.length - 1]}`, + ); + }; + + // Simulate user interactions + console.log("\n=== CORRECT BEHAVIOR (how it should work) ==="); + simulateIframeClick_CORRECT("12345"); + simulateIframeClick_CORRECT("67890"); + simulateIframeClick_CORRECT("99999"); + + // Verify they stay in sync + expect(parentUrlHistory).toEqual(["12345", "67890", "99999"]); + expect(iframeInternalState.household).toBe("99999"); + + // The parent and iframe show the same value! + expect(parentUrlHistory[parentUrlHistory.length - 1]).toBe( + iframeInternalState.household, + ); + }); + + it("explains the fix needed", () => { + console.log("\n=== FIX NEEDED ==="); + console.log("The obbba-scatter app needs to:"); + console.log("1. Update its own state/URL first"); + console.log("2. THEN send the postMessage with the NEW state"); + console.log("\nCurrently it's doing:"); + console.log("1. Send postMessage with OLD state"); + console.log("2. Then update its own state"); + + // This test always passes - it's just for documentation + expect(true).toBe(true); + }); +}); diff --git a/src/__tests__/pages/AppPage.test.js b/src/__tests__/pages/AppPage.test.js new file mode 100644 index 000000000..ecdbabc73 --- /dev/null +++ b/src/__tests__/pages/AppPage.test.js @@ -0,0 +1,255 @@ +import { render, waitFor } from "@testing-library/react"; +import { BrowserRouter } from "react-router-dom"; +import AppPage from "../../pages/AppPage"; + +// Mock the apps data +jest.mock("../../apps/appTransformers", () => ({ + apps: [ + { + title: "OBBBA household-by-household analysis", + description: "Test description", + url: "https://policyengine.github.io/obbba-scatter", + slug: "obbba-household-by-household", + tags: ["us", "featured", "policy"], + }, + { + title: "Other App", + description: "Other app description", + url: "https://example.com/other-app", + slug: "other-app", + tags: ["us"], + }, + ], +})); + +// Mock react-router-dom hooks +const mockNavigate = jest.fn(); +const mockLocation = { + pathname: "/us/obbba-household-by-household", + search: "?household=12345&baseline=current", +}; + +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useParams: () => ({ appName: "obbba-household-by-household" }), + useNavigate: () => mockNavigate, + useLocation: () => mockLocation, +})); + +// Mock the Header and Footer components +jest.mock("../../layout/Header", () => { + return function Header() { + return
Header
; + }; +}); + +jest.mock("../../layout/Footer", () => { + return function Footer() { + return
Footer
; + }; +}); + +describe("AppPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset location mock + mockLocation.search = ""; + }); + + it("renders the OBBBA iframe with URL parameters on initial load", () => { + mockLocation.search = "?household=12345&baseline=current"; + + const { container } = render( + + + , + ); + + const iframe = container.querySelector("iframe"); + expect(iframe).toBeTruthy(); + expect(iframe.src).toBe( + "https://policyengine.github.io/obbba-scatter?household=12345&baseline=current", + ); + }); + + it("does not add URL parameters to non-OBBBA apps", () => { + // Change to other app + jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useParams: () => ({ appName: "other-app" }), + useNavigate: () => mockNavigate, + useLocation: () => ({ ...mockLocation, pathname: "/us/other-app" }), + })); + + const { container } = render( + + + , + ); + + const iframe = container.querySelector("iframe"); + expect(iframe).toBeTruthy(); + expect(iframe.src).toBe("https://example.com/other-app"); + }); + + it("handles postMessage from iframe and updates parent URL", async () => { + const { container } = render( + + + , + ); + + // Wait for iframe to be rendered + await waitFor(() => { + expect(container.querySelector("iframe")).toBeTruthy(); + }); + + // Simulate postMessage from iframe + const messageEvent = new MessageEvent("message", { + data: { + type: "urlUpdate", + params: "household=67890&baseline=reform", + }, + origin: "https://policyengine.github.io", + }); + + window.dispatchEvent(messageEvent); + + // Check that navigate was called with the new parameters + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + "/us/obbba-household-by-household?household=67890&baseline=reform", + { replace: true }, + ); + }); + }); + + it("ignores postMessage from unauthorized origins", async () => { + render( + + + , + ); + + // Simulate postMessage from wrong origin + const messageEvent = new MessageEvent("message", { + data: { + type: "urlUpdate", + params: "household=99999", + }, + origin: "https://malicious-site.com", + }); + + window.dispatchEvent(messageEvent); + + // Navigate should not have been called + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("does not reload iframe when URL is updated from iframe message", async () => { + const { container } = render( + + + , + ); + + // Get initial iframe + const iframe = container.querySelector("iframe"); + const initialSrc = iframe.src; + + // Simulate postMessage from iframe + const messageEvent = new MessageEvent("message", { + data: { + type: "urlUpdate", + params: "household=67890&baseline=reform", + }, + origin: "https://policyengine.github.io", + }); + + window.dispatchEvent(messageEvent); + + // Wait a bit to ensure any potential re-render would have happened + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + + // Check that iframe src hasn't changed (no reload) + const iframeAfter = container.querySelector("iframe"); + expect(iframeAfter.src).toBe(initialSrc); + }); + + it("logs debug information when receiving URL updates", async () => { + const consoleSpy = jest.spyOn(console, "log").mockImplementation(); + + render( + + + , + ); + + // Simulate postMessage from iframe + const messageEvent = new MessageEvent("message", { + data: { + type: "urlUpdate", + params: "household=67890&baseline=reform", + }, + origin: "https://policyengine.github.io", + }); + + window.dispatchEvent(messageEvent); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + "Received urlUpdate from iframe:", + "household=67890&baseline=reform", + ); + expect(consoleSpy).toHaveBeenCalledWith( + "Navigating to:", + "/us/obbba-household-by-household?household=67890&baseline=reform", + ); + }); + + consoleSpy.mockRestore(); + }); + + describe("URL synchronization sequence", () => { + it("should handle rapid sequential URL updates correctly", async () => { + render( + + + , + ); + + // Simulate rapid sequential messages + const messages = [ + { household: "11111", baseline: "current" }, + { household: "22222", baseline: "current" }, + { household: "33333", baseline: "current" }, + ]; + + for (const params of messages) { + const messageEvent = new MessageEvent("message", { + data: { + type: "urlUpdate", + params: new URLSearchParams(params).toString(), + }, + origin: "https://policyengine.github.io", + }); + + window.dispatchEvent(messageEvent); + + // Small delay to simulate real-world timing + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + // Check that navigate was called for each update + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledTimes(3); + expect(mockNavigate).toHaveBeenLastCalledWith( + "/us/obbba-household-by-household?household=33333&baseline=current", + { replace: true }, + ); + }); + }); + }); +}); diff --git a/src/images/posts/obbba-modeling-methodology.png b/src/images/posts/obbba-modeling-methodology.png new file mode 100644 index 000000000..e69de29bb diff --git a/src/pages/AppPage.jsx b/src/pages/AppPage.jsx index b58590b0a..7cc244fbe 100644 --- a/src/pages/AppPage.jsx +++ b/src/pages/AppPage.jsx @@ -17,11 +17,11 @@ export default function AppPage() { const isOBBBAApp = app?.slug === "obbba-household-by-household"; const [initialUrl, setInitialUrl] = useState(null); - // Construct iframe URL with parameters (for OBBBA app only on initial load) + // Construct initial iframe URL with parameters useEffect(() => { - if (!app) return; + if (!app || initialUrl) return; - if (isOBBBAApp && !initialUrl) { + if (isOBBBAApp) { const baseUrl = app.url; const separator = baseUrl.includes("?") ? "&" : "?"; const urlParams = new URLSearchParams(location.search); @@ -31,11 +31,11 @@ export default function AppPage() { : baseUrl; setInitialUrl(url); - } else if (!isOBBBAApp) { + } else { // For non-OBBBA apps, just use the app URL setInitialUrl(app.url); } - }, [app, location.search, initialUrl, isOBBBAApp]); + }, [app, location.search, isOBBBAApp, initialUrl]); // Listen for messages from OBBBA iframe useEffect(() => { @@ -49,7 +49,9 @@ export default function AppPage() { // Handle URL update messages from the iframe if (event.data?.type === "urlUpdate" && event.data?.params) { const newParams = new URLSearchParams(event.data.params); - navigate(`${location.pathname}?${newParams.toString()}`, { + const newParamsString = newParams.toString(); + + navigate(`${location.pathname}?${newParamsString}`, { replace: true, }); } diff --git a/src/posts/articles/obbba-modeling-methodology.md b/src/posts/articles/obbba-modeling-methodology.md new file mode 100644 index 000000000..206253243 --- /dev/null +++ b/src/posts/articles/obbba-modeling-methodology.md @@ -0,0 +1,180 @@ +At PolicyEngine, we strive to provide transparent and accurate modeling of major policy changes. The One Big Beautiful Bill Act (OBBBA), signed into law on July 4, 2025, represents one of the most comprehensive tax and spending reforms in recent history. This report explains how PolicyEngine models the OBBBA, including the policy provisions we capture, our data sources, and calibration methodology. + +## Overview + +PolicyEngine's OBBBA modeling captures the major tax provisions and benefit program changes while calibrating to Congressional Budget Office (CBO) projections. Our approach leverages detailed microsimulation at the household level to project both individual and economy-wide impacts. + +## Policy provisions modeled + +### Tax provisions + +PolicyEngine models the OBBBA's comprehensive individual income tax changes, which go beyond a simple TCJA extension: + +**Core rate and deduction changes**: + +- **Tax rates**: Permanent extension of TCJA rates (12%, 22%, 24%, 32%, 37%) +- **Standard deduction**: Increased to $32,600 for joint filers in 2026 (higher than TCJA's $30,300) +- **Personal exemptions**: Remain eliminated +- **Alternative Minimum Tax**: Higher exemption amounts ($139,000 for joint filers) with modified phase-out rates +- **Itemized deduction limitation**: New income-based limitations on itemized deductions (Pease limitation was repealed by TCJA) + +**Credit modifications**: + +- **Child Tax Credit**: $2,200 base amount with $1,700 refundability cap for 2026 (not $1,800 as in TCJA), rising to $1,800 in 2027-2028, with new SSN requirements +- **Senior standard deduction**: Additional $6,000 deduction for taxpayers age 65 and over (2025-2028) +- **Child and Dependent Care Credit**: Enhanced maximum credit and two-tier phase-down structure + +**New deductions and exemptions**: + +- **Tips and overtime**: Exemption of up to $25,000 of tip income and overtime wages from income tax (2025-2028), phasing out at higher incomes +- **Auto loan interest**: Deduction of up to $10,000 of interest on qualifying vehicle loans (2025-2028) +- **SALT deduction**: Increased cap to $40,000 for taxpayers earning under $500,000, reverting to $10,000 in 2030 + +**Business provisions**: + +- **Qualified Business Income Deduction**: Maintained at 20% (not increased to 23%) with minimum $400 deduction for qualifying businesses + +### Benefit program changes + +The OBBBA implements significant changes to safety net programs: + +- **Medicaid work requirements**: Adults eligible through ACA expansion must meet work and reporting requirements +- **SNAP work requirements**: Enhanced requirements for able-bodied adults without dependents +- **ACA premium tax credit changes**: Non-extension of enhanced subsidies that were set to expire + +## Comprehensive modeling approach + +### State and local tax integration + +PolicyEngine maintains a complete model of state and local income taxes for all 50 states and DC. This enables: + +- **Accurate SALT deduction calculations**: Our state tax models calculate the exact state and local tax liabilities that feed into the federal SALT deduction +- **Mechanical effects on state taxes**: Many states begin with federal adjusted gross income or taxable income as their starting point. The OBBBA's new deductions (tips, overtime, auto loan interest) automatically flow through to reduce state tax liabilities in these states +- **Interaction effects**: Changes to federal itemized deductions affect state taxes in states that conform to federal itemization rules + +### Integrated benefit program modeling + +PolicyEngine models the complete rules for major benefit programs, not just their take-up rates: + +- **SNAP**: Full eligibility determination based on income, assets, household composition, and work requirements +- **Medicaid/CHIP**: State-specific eligibility pathways including expansion status, income limits, and categorical eligibility +- **ACA subsidies**: Premium tax credit calculations based on income, family size, and local benchmark premiums + +This integrated approach captures important interactions: + +- Federal tax changes affect modified adjusted gross income (MAGI) used for ACA and Medicaid eligibility +- Work requirements interact with existing categorical eligibility rules +- Benefit cliffs and phase-outs combine with tax provisions to create complex marginal rate effects + +## Data sources + +### Enhanced Current Population Survey + +PolicyEngine uses the Enhanced CPS as our primary data source. This dataset builds on the Census Bureau's Current Population Survey through several imputation models: + +**Tip income imputation**: We train a quantile random forest model on the Survey of Income and Program Participation (SIPP), which contains employer-reported tip income.[^2] The model uses: + +- Employment income +- Age +- Number of children under 18 +- Number of children under 6 + +**Overtime income**: We calculate overtime premiums using:[^3] + +- Hours worked per week from CPS +- Occupation codes to determine Fair Labor Standards Act exemption status +- Base employment income to derive hourly rates +- Standard time-and-a-half (1.5x) premium for hours over 40 + +**Auto loan interest**: We impute from the Survey of Consumer Finances using predictors:[^4] + +- Age and state +- Household size and number of children +- Employment, interest/dividend, and Social Security/pension income +- The model imputes net worth, auto loan balance, and auto loan interest simultaneously + +**Immigration status**: We implement the [ASEC Undocumented Algorithm (Ryan 2022)](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=4662801), which uses a process-of-elimination approach.[^5] The algorithm examines 14 conditions including: + +- Arrival year (pre-1982 for IRCA amnesty eligibility) +- Receipt of federal programs (Medicare, Social Security, SSI) +- Government employment or military service +- Other legal status indicators + +Those not meeting any condition are assigned SSN card type "NONE" (likely undocumented), calibrated to match external estimates (13 million for 2024-2025) + +### Tax data integration + +While we use the IRS Public Use File for certain tax modeling components, we note that: + +- Mortgage interest deduction remains imputed from the PUF without modification +- We do not model estate tax or corporate tax provisions +- Capital gains and dividend income follow SOI-calibrated fractions + +## Calibration methodology + +### Program take-up rates + +PolicyEngine's approach to modeling OBBBA's coverage impacts involves adjusting program take-up rates from their baseline values: + +| Program | Baseline Take-up[^6] | OBBBA Take-up (2026-2028)[^7] | Change | +|---------|---------------------|-------------------------------|---------| +| SNAP | 82.0% | 77.5% | -4.5pp | +| Medicaid | 93.0% | 92.0% | -1.0pp | +| ACA | 67.2% | 65.5% | -1.7pp | + +These adjustments reflect CBO's projected coverage losses due to work requirements and administrative changes. However, the actual impact on enrollment is more complex than these raw percentages suggest. PolicyEngine first calibrates household weights to match baseline program enrollment, then applies the adjusted take-up rates. This two-stage process means the effective enrollment changes may differ from the simple percentage point differences shown above, but produces aggregate impacts consistent with CBO's estimates of 10.5 million Medicaid coverage losses and corresponding SNAP enrollment reductions. + +### Alignment with projections + +PolicyEngine's modeling focuses on the individual income tax provisions and their interactions with benefit programs. Our [analysis of the reconciliation bill's tax provisions](/us/research/final-2025-reconciliation-tax) projects a $3.8 trillion reduction in federal revenues from 2026 to 2035 compared to current law. + +While we model the eligibility changes for programs like Medicaid and SNAP through our calibrated take-up rates, we do not directly model the full fiscal impact of coverage losses. The Congressional Budget Office has separately estimated that the OBBBA will reduce federal spending on Medicaid and CHIP by $1.02 trillion through coverage reductions.[^1] + +## Modeling limitations + +Several provisions of the OBBBA are not captured in our current modeling: + +- **Estate tax changes**: Not modeled due to data limitations +- **Corporate tax provisions**: Outside the scope of individual microsimulation +- **Immigration enforcement**: Indirect effects on population and workforce not modeled +- **State fiscal impacts**: Secondary effects on state budgets not captured +- **Provider tax changes**: Medicaid provider tax moratorium effects not modeled + +## Validation + +We validate our modeling through several approaches: + +1. **Aggregate comparisons**: Total revenue and spending changes match CBO projections within reasonable bounds +2. **Distributional analysis**: Income decile impacts align with independent analyses +3. **Edge case testing**: Specific household scenarios tested against hand calculations +4. **State-level validation**: Coverage losses by state compared to KFF allocations of CBO estimates + +## Technical implementation + +The OBBBA reforms are implemented across PolicyEngine's infrastructure: + +- **Parameter files**: Located in [`policyengine_us/parameters/gov/contrib/reconciliation/`](https://github.com/PolicyEngine/policyengine-us/tree/master/policyengine_us/parameters/gov/contrib/reconciliation) +- **Reform definitions**: Consolidated in [`obbba-scatter/data/reforms.py`](https://github.com/PolicyEngine/obbba-scatter/blob/main/data/reforms.py) +- **Visualization**: Interactive [household-by-household calculator](/us/research/obbba-scatter) + +## Conclusion + +PolicyEngine's OBBBA modeling provides comprehensive analysis of the law's major provisions while acknowledging areas where data or scope limitations prevent full modeling. By calibrating to CBO projections while maintaining detailed household-level simulation, we offer both accuracy and transparency in understanding this landmark legislation's impacts. + +For researchers and policymakers seeking to understand specific provisions or household impacts, our open-source implementation allows full inspection of modeling choices and assumptions. We continue to refine our modeling as new data and analyses become available. + +--- + +[^1]: Center for American Progress, ["The Truth About the One Big Beautiful Bill Act's Cuts to Medicaid and Medicare"](https://www.americanprogress.org/article/the-truth-about-the-one-big-beautiful-bill-acts-cuts-to-medicaid-and-medicare/), July 2025. See also KFF's [state-by-state allocation](https://www.kff.org/medicaid/issue-brief/allocating-cbos-estimates-of-federal-medicaid-spending-reductions-across-the-states-enacted-reconciliation-package/) of CBO's estimates. + +[^2]: Implementation in [`policyengine_us_data/datasets/sipp/sipp.py`](https://github.com/PolicyEngine/policyengine-us-data/blob/master/policyengine_us_data/datasets/sipp/sipp.py#L39-L71) and [`policyengine_us_data/datasets/cps/cps.py`](https://github.com/PolicyEngine/policyengine-us-data/blob/master/policyengine_us_data/datasets/cps/cps.py#L1219-L1233). + +[^3]: Overtime calculation in [`policyengine_us/variables/household/income/person/fsla_overtime_premium.py`](https://github.com/PolicyEngine/policyengine-us/blob/master/policyengine_us/variables/household/income/person/fsla_overtime_premium.py) and occupation mapping in [`policyengine_us_data/datasets/cps/cps.py`](https://github.com/PolicyEngine/policyengine-us-data/blob/master/policyengine_us_data/datasets/cps/cps.py#L1236-L1273). + +[^4]: Auto loan imputation in [`policyengine_us_data/datasets/cps/cps.py`](https://github.com/PolicyEngine/policyengine-us-data/blob/master/policyengine_us_data/datasets/cps/cps.py#L1275-L1356). + +[^5]: Full SSN status imputation implementation in [`policyengine_us_data/datasets/cps/cps.py`](https://github.com/PolicyEngine/policyengine-us-data/blob/master/policyengine_us_data/datasets/cps/cps.py#L1407-L1686) and detailed documentation in the [SSN statuses imputation notebook](https://github.com/PolicyEngine/policyengine-us-data/blob/master/docs/SSN_statuses_imputation.ipynb). + +[^6]: Baseline take-up rates from PolicyEngine parameters: [SNAP](https://github.com/PolicyEngine/policyengine-us/blob/master/policyengine_us/parameters/gov/usda/snap/takeup_rate.yaml), [Medicaid](https://github.com/PolicyEngine/policyengine-us/blob/master/policyengine_us/parameters/gov/hhs/medicaid/takeup_rate.yaml), [ACA](https://github.com/PolicyEngine/policyengine-us/blob/master/policyengine_us/parameters/gov/aca/takeup_rate.yaml). + +[^7]: OBBBA take-up adjustments in [`obbba-scatter/data/reforms.py`](https://github.com/PolicyEngine/obbba-scatter/blob/main/data/reforms.py#L535-L570). diff --git a/src/posts/posts.json b/src/posts/posts.json index 0e868b00a..e18e0cb62 100644 --- a/src/posts/posts.json +++ b/src/posts/posts.json @@ -1,4 +1,13 @@ [ + { + "title": "How PolicyEngine models the One Big Beautiful Bill Act", + "description": "A technical guide to our OBBBA modeling methodology, including policy rules, data sources, and calibration to CBO projections.", + "date": "2025-07-27", + "tags": ["us", "policy", "featured", "reconciliation"], + "filename": "obbba-modeling-methodology.md", + "image": "obbba-modeling-methodology.png", + "authors": ["max-ghenis"] + }, { "title": "Analysis of individual income tax provisions in the final reconciliation bill", "description": "Our simulation projects a reduction in federal revenues of $3.8 trillion from 2026 to 2035 compared to current law.",