Skip to content

Return output amounts as float64 rounded to the cent, and integer record IDs - #1423

Open
MaxGhenis wants to merge 2 commits into
mainfrom
fix-mixed-year-output-dtypes
Open

MaxGhenis wants to merge 2 commits into
mainfrom
fix-mixed-year-output-dtypes

Conversation

@MaxGhenis

Copy link
Copy Markdown
Contributor

Problem

An input that mixes tax years before 2021 with 2021 or later printed 32-bit float noise and float record IDs. On main at 549bead:

printf 'taxsimid,year,state,mstat,page,pwages,idtl\n1,2019,5,1,40,50000,2\n2,2023,33,1,30,52000,2\n' > mixed.csv
policyengine-taxsim policyengine mixed.csv -o out.csv

gave taxsimid 1.0 and 2.0, and tfica and v29 4009.199951171875 for the 2023 record. The stdin command printed the same.

Cause

  • PolicyEngineRunner._extract_vectorized_results rounded with np.round(arr, 2) on the float32 arrays PolicyEngine returns. The result stays float32, which cannot hold most amounts in cents; 28 of the 41 output columns in a 300-record run were float32. numpy also does the rounding in float32: float32(44737.546875) * 100 is 4473754.5 in 32 bits, which rounds half-to-even to 44737.54 instead of 44737.55.
  • StitchedRunner.run joins those rows to the TAXSIM binary's float64 rows with pd.concat, which widens float32 to float64 and shows the error (4009.199951171875). The binary prints the record ID as 1., which parses as a float, so the joined taxsimid column became float.
  • A PolicyEngine-only run printed float32 values at their shortest repr, so it usually looked clean. It still printed some amounts from $131,072 up with one decimal place (pandas 2) or in scientific notation from $1 million (pandas 3: 1.1826661e+06), and a .dta file stored the amounts as Stata floats.

Fix

  • New policyengine_taxsim/core/output_dtypes.py:
    • round_float64 widens to float64 and then rounds.
    • identifiers_as_integers stores whole-number float taxsimid, year and state columns as int64. A column holding a fraction, a missing value, an infinity or a number outside int64 is left alone, so no value ever changes; only the type does.
  • All 13 np.round calls in PolicyEngineRunner now use round_float64.
  • PolicyEngineRunner.run() and StitchedRunner.run() return integer identifiers. The stdin command, the policyengine subcommand (CSV and .dta), the web API's _run_taxsim and the R wrapper all go through one of these two.
  • TaxsimRunner and the taxsim subcommand are unchanged: they return the binary's own output, taxsimid included.

What changes in PolicyEngine-only output

I ran the same 300 synthetic PolicyEngine records (2021 to 2025, wages $5,000 to $5 million) through the old and new rounding and compared the CSVs cell by cell (Python 3.11, pandas 3.0.6, policyengine-us 2.38.4):

Cells
Amount cells 11,400
Same text 10,881
Changed, below $131,072 108, each by one cent
Changed, $131,072 and up 411, at most $0.30; 250 had been in scientific notation
frate, srate changed 0
Identifier cells changed 0

Every new value is the correct rounding of the 32-bit value PolicyEngine computed; a property test checks this against Python's decimal module. PolicyEngine still computes in 32 bits, so above $131,072 the computed amount itself is no finer than 1/64 of a dollar (a quarter at $3.4 million). This change makes each output an exact float64 number of cents; it does not make the computation more precise.

The next dashboard refresh picks up these cent-level changes. Comparisons use a $15 tolerance, so a one-cent change can only flip a record that sits within a cent of it.

Invariants

Pinned in tests/test_output_dtypes.py, for PolicyEngine-only and mixed-year inputs:

  1. Every amount equals itself rounded to 2 decimals in float64 (frate, srate, ficar: 4). Hypothesis generates records for PolicyEngineRunner and for StitchedRunner with at least one record per engine; fixed inputs cover the stdin command, the policyengine subcommand's CSV and .dta, and write_output. The CSV text check also rejects scientific notation and any cell with more decimals than its column allows.
  2. Amounts are float64, never float32.
  3. taxsimid, year and state are integers when their values are whole numbers, and no identifier value changes. Hypothesis covers whole numbers up to 2^53, fractions, NaN, infinities and out-of-range values, as float64, nullable Float64, and float32 read from a Stata file.
  4. A PolicyEngine record's output is identical, as a frame and as CSV text, whether or not the input also holds records for the TAXSIM binary.
  5. Differential: round_float64 on float32 values matches decimal ROUND_HALF_EVEN quantization of the same values (500 examples).

A property test with both engines replaced by tables also checks that stitching returns every value unchanged, in input order, with int64 IDs.

Tests

Run locally (targeted only; a resource hold on the shared machine ruled out the full suite, which CI runs):

  • tests/test_output_dtypes.py (new), tests/test_public_contract.py, tests/test_cli_entry_point.py, tests/test_stitched_runner.py, tests/test_stata_io.py: 52 passed on Python 3.11 (pandas 3.0.6, policyengine-us 2.38.4) and 52 passed on Python 3.10 (pandas 2.3.3, policyengine-us 1.783.0).
  • tests/test_state_output_adapters.py's two PolicyEngineRunner tests, which compare runner amounts with unrounded PolicyEngine values to the cent, with the new file: 39 passed on Python 3.11.
  • The new file against main's runners (549bead) on Python 3.10: all 11 end-to-end and stitching tests fail, and only the tests of the new helper module itself pass.
  • The last edit turned off Hypothesis shrinking for the two PolicyEngine-backed property tests (a failing example would otherwise rerun the simulation many times); it changes only how a failure is reported.

The new file adds about 6.5 minutes on Python 3.11 locally and under 2 on 3.10.

Overlap with open PRs

axiom: n/a: output number types, no policy change

🤖 Generated with Claude Code

…ord IDs

PolicyEngineRunner rounded PolicyEngine's float32 arrays with np.round, which
keeps float32 and scales by 100 in 32 bits. StitchedRunner then joined those
rows to the TAXSIM binary's float64 rows, which widened float32 and printed
4009.199951171875 for 4009.20; the binary's "1." record IDs made taxsimid a
float.

Amounts are now widened to float64 before rounding (core/output_dtypes.py),
and PolicyEngineRunner.run() and StitchedRunner.run() store whole-number
taxsimid, year and state columns as int64. New tests pin the invariants with
Hypothesis for PolicyEngine-only and mixed-year inputs, through the runners,
the stdin command and the policyengine subcommand (CSV and .dta).

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

vercel Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
policyengine-taxsim Ready Ready Preview Nov 11, 2026 4:52am UTC

Request Review

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Sweep status (PR sweep of 2026-11-11): an independent Opus 5.5 review approved head 26c972a with no blocking findings. It reproduced the float32 output and float taxsimid on main with real PolicyEngine and the bundled TAXSIM binary, and confirmed this PR fixes both. 53 targeted tests pass, the rounding matches exact decimal half-to-even on 20,000 float32 values, and a 64-record main-vs-PR comparison changed only 6 of 1,588 cells, by at most $0.04, all in former float32 columns. The test jobs fail only on test_or_kicker_output[2025], as every branch cut from current main does: since PolicyEngine/policyengine-us#10093, Oregon's federal-liability subtraction nets out the imputed assigned_aca_ptc. #1518 fixes it. Once #1518 merges, update this branch and merge on green. Non-blocking: in mixed-year input, a PolicyEngine taxsimid above 2^53 still rounds, because pd.concat widens the IDs before identifiers_as_integers runs. Converting each frame's IDs before the concat would fix it.

This branch was successfully deployed

1 active deployment
Preview — 26c972a1 Deployed Nov 11, 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