…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>
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:
gave
taxsimid1.0and2.0, andtficaandv294009.199951171875for the 2023 record. The stdin command printed the same.Cause
PolicyEngineRunner._extract_vectorized_resultsrounded withnp.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) * 100is4473754.5in 32 bits, which rounds half-to-even to44737.54instead of44737.55.StitchedRunner.runjoins those rows to the TAXSIM binary's float64 rows withpd.concat, which widens float32 to float64 and shows the error (4009.199951171875). The binary prints the record ID as1., which parses as a float, so the joinedtaxsimidcolumn became float.1.1826661e+06), and a.dtafile stored the amounts as Stata floats.Fix
policyengine_taxsim/core/output_dtypes.py:round_float64widens to float64 and then rounds.identifiers_as_integersstores whole-number floattaxsimid,yearandstatecolumns 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.np.roundcalls inPolicyEngineRunnernow useround_float64.PolicyEngineRunner.run()andStitchedRunner.run()return integer identifiers. The stdin command, thepolicyenginesubcommand (CSV and.dta), the web API's_run_taxsimand the R wrapper all go through one of these two.TaxsimRunnerand thetaxsimsubcommand are unchanged: they return the binary's own output,taxsimidincluded.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):
frate,sratechangedEvery new value is the correct rounding of the 32-bit value PolicyEngine computed; a property test checks this against Python's
decimalmodule. 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:frate,srate,ficar: 4). Hypothesis generates records forPolicyEngineRunnerand forStitchedRunnerwith at least one record per engine; fixed inputs cover the stdin command, thepolicyenginesubcommand's CSV and.dta, andwrite_output. The CSV text check also rejects scientific notation and any cell with more decimals than its column allows.taxsimid,yearandstateare 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.round_float64on float32 values matchesdecimalROUND_HALF_EVENquantization 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 twoPolicyEngineRunnertests, which compare runner amounts with unrounded PolicyEngine values to the cent, with the new file: 39 passed on Python 3.11.The new file adds about 6.5 minutes on Python 3.11 locally and under 2 on 3.10.
Overlap with open PRs
git merge-tree). The conversion sits inPolicyEngineRunner.run()and at the end ofStitchedRunner.run(), away from the lines they change, and the new tests do not depend on PolicyEngine's row order.docs/input-guide.md, the 32-bit line indocs/design.mdand the rounding comments indocs/examples/custom-dataset/are stale; I've told that PR's session.axiom: n/a: output number types, no policy change
🤖 Generated with Claude Code