Skip to content

Correct the US overview's program count and behavioral-response claims - #54

Draft
MaxGhenis wants to merge 1 commit into
masterfrom
us-overview-registry-count-and-lsr
Draft

MaxGhenis wants to merge 1 commit into
masterfrom
us-overview-registry-count-and-lsr

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

WIP. Draft until the browser check and the independent review finish.

Fixes two claims on the US overview (/us/model). UK copy is not touched, because #51 owns it.

1. Policy-rules card: the program count

Before: "Tax codes, benefit formulas, and eligibility rules encoded as executable logic. Over 55 federal, state, and local programs."

After (US): "Tax codes, benefit formulas, and eligibility rules encoded as executable logic. The [coverage tracker] lists 103 federal, state, and local programs, 88 of them marked complete."

  • The numbers come from the registry at runtime through fetchProgramsWithSource('us'). They are not hardcoded.
  • summarizeRegistry (in programStatus.ts) uses the tracker's own computeStatusCount, so the card always matches the tracker's "Total programs" and "Complete" cards.
  • While the registry loads, and when only the bundled fallback list is available (a stale hand-kept copy), the card shows no number: "The coverage tracker lists federal, state, and local programs and the status of each."
  • The link keeps the public mount prefix (/us/model) and the query string (?embed=true&country=us).
  • Per the message house, it cites registry counts and never says "full coverage".

Registry facts checked on 2026-10-14:

  • The build snapshot (policyengine-us main @ 2e15904, 2.42.10) lists 103 programs: 88 complete, 12 partial and 3 in progress in raw registry status.
  • The tracker's derived statuses are 88 complete, 11 partial and 4 in progress. One federal program's per-state list makes it more conservative.
  • programs.yaml at the 2.2.1 release commit (f88982f), which is the version policyengine.py pins and the production API serves, has the same 103 ids with the same statuses. So the count holds for the deployed model too.

2. Walkthrough step 3: the behavioral mechanism

Before: "People change their behavior in response to tax changes. Using elasticity of taxable income, we estimate how earnings shift."

After (US): "People may work more or less when taxes and benefits change. Earnings stay fixed by default; once labor supply elasticities are set, workers' earnings respond to changes in their marginal tax rates and in their households' net income."

What policyengine-us 2.2.1 does. policyengine.py src/policyengine/data/bundle/manifest.json pins policyengine-us==2.2.1, whose release commit is f88982f25e. Every point below was read at that commit:

  • There is no elasticity of taxable income anywhere. The only response parameters are gov.simulation.labor_supply_responses.* and gov.simulation.capital_gains_responses.elasticity.
  • Everything is off by default.
    • These all default to 0: elasticities/income.yaml; elasticities/substitution.yaml (all, every by_position_and_decile.primary decile, and secondary); and capital_gains_responses/elasticity.yaml.
    • labor_supply_behavioral_response returns 0 when there is no reform, or when the income elasticity and every substitution elasticity are 0.
  • Mechanism (behavioral_response_measurements.py): response = earnings before LSR × (income elasticity × relative change in household_net_income + substitution elasticity × relative change in (1 − marginal_tax_rate)).
    • Both changes are clipped to ±100% by bounds/*.
    • Both are measured on branches where the behavioral responses are neutralized.
  • Earnings means employment, self-employment and SSTB self-employment income (earnings_before_lsr). The response is split back across them by pre-response share (*_behavioral_response.py). People with zero earnings have zero response, so there is no participation margin.
  • Marginal tax rate (marginal_tax_rate.py): the share of an extra $1,000 of earnings (simulation.marginal_tax_rate_delta) that household net income does not keep. It is computed for the two adults with the highest market income in each household (adult_earnings_index, simulation.marginal_tax_rate_adults: 2); everyone else's stays 0. That is why the copy says "workers'" and not "each worker's".
  • Elasticity variation: substitution elasticities vary by earnings decile and primary or secondary earner. Both elasticities get a 2× multiplier at age 65 and over.

The card stays short and makes only the claims above. "Workers' earnings" covers employment and self-employment. The repo's comparison data already describes PE-US as static by default (data/comparisons/behavioral_parameters.yaml).

Invariants (property-tested with fast-check)

For any program list and any source:

  • summarizeRegistry returns null for the fallback source and for an empty registry.
  • Otherwise total == programs.length and complete == computeStatusCount(programs).complete.
  • The status counts sum to total, so each program is counted once.
  • 0 ≤ complete ≤ the number of programs the registry itself marks complete. The card never claims more complete programs than the registry does.

Differential test: the card and the coverage tracker are rendered from the same pinned registry snapshot. The card's two numbers equal the tracker's "Total programs" and "Complete" cards.

Tests

  • src/test/overviewClaims.test.tsx covers:
    • the tracker-parity differential above;
    • the link href under /us/model with the query string kept;
    • no number while loading, and none from the fallback list;
    • counts from the production-API source;
    • the UK overview never loading the US registry;
    • the fast-check properties;
    • step 3 naming the mechanism without "taxable income".
  • bun run test: 13 files, 146 tests pass.
  • bun run lint: clean.
  • tsc --noEmit: clean.
  • bun run build: passes.

Merge note

axiom: n/a: explainer-site copy (no policy encoding)

🤖 Generated with Claude Code

The policy-rules card said "Over 55 federal, state, and local programs";
the coverage registry lists 103. The card now reads the registry at
runtime through fetchProgramsWithSource('us') and cites the same total
and complete counts the coverage tracker shows, with a link to it. It
shows no number while loading or when only the bundled fallback list is
available.

Walkthrough step 3 said earnings shift by the elasticity of taxable
income. policyengine-us 2.2.1 (the version policyengine.py pins) has no
ETI: it scales earnings by a substitution elasticity on the change in
marginal tax rate and an income elasticity on the change in household
net income, and every elasticity defaults to zero. The step now says so.

UK copy is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
policyengine-model Ready Ready Preview Oct 14, 2026 12:59pm UTC

Request Review

This branch was successfully deployed

1 active deployment
Preview — 33b2706c Deployed Oct 14, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant