Skip to content

Add an input guide, a design document, and security and deployment notes - #1419

Open
MaxGhenis wants to merge 10 commits into
mainfrom
agency-docs-inputs-design-security
Open

MaxGhenis wants to merge 10 commits into
mainfrom
agency-docs-inputs-design-security

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Why

At the 2026-10-08 BLS Consumer Expenditure seminar, agency staff asked for three things the user guide doesn't give them:

  • Guidance on valid inputs and the input flow for their own datasets. They can't use Claude Code, so the AI plugin doesn't help them.
  • Methodology and design documentation.
  • For IT and security review: what the package depends on and whether it contacts the network.

A Stata user also read the slides as saying NBER's taxsim35 command switches engines by year.

What this adds

Three guides under docs/, linked from the README:

  1. Input guide. Every input column with its type, units and valid values, what a blank cell or a missing column becomes, which PolicyEngine variable it sets, and what the emulator rejects or silently accepts. A worked example takes a made-up survey extract to taxes merged back onto it, in Python, Stata and R.
  2. Design document. Routing by year, how inputs become PolicyEngine variables, the run-time adjustments, where each output comes from, versions and pinning, and known differences from TAXSIM-35.
  3. Security and deployment notes. Network use, files written, the bundled executables with hashes, Python versions, the full dependency list with licenses, and an offline installation recipe.

Tables generated from the code

scripts/generate_docs_reference.py rebuilds every block between <!-- BEGIN GENERATED --> markers:

  • Input handling comes from running the emulator's own input path (PolicyEngineRunner, then TaxsimMicrosimDataset.generate) on probe records and reading the PolicyEngine inputs it builds, and from the input file TaxsimRunner writes for the TAXSIM-35 executable. Nothing is simulated.
  • Column meanings are the one hand-written source, docs/reference/input_variables.yaml.
  • Outputs come from variable_mappings.yaml; executables from hashing and inspecting resources/; Python versions and dependencies from pyproject.toml, ci.yml and a hashed uv pip compile snapshot in docs/dependencies/.

Tests

Test file Fails when
tests/test_docs_reference.py A generated block differs from the code; the YAML and the columns the code reads differ; a run-time pin is missing from the design doc; the dependency snapshot no longer satisfies pyproject.toml
tests/test_input_guide_claims.py The guide's rejected-input messages or unchecked-value claims stop matching the emulator; an amount column isn't conserved (property-based)
tests/test_worked_example.py The Python example stops running; the Stata script's .dta hand-off gives different taxes; the R script builds a different input or merges differently
tests/test_no_network.py The policyengine subcommand attempts a connection leaving the machine, a DNS lookup or an HTTP request; or leaves a temporary file behind (reported, not failed, on Windows)

They add about five minutes to a test job on my machine.

Findings on the two claims made to BLS IT

"Nothing's going to our servers": true for a local install, false for the web runner.

  • A local run (command line, Python, R) opened no network connection. I checked four ways, on policyengine-us 2.38.0 (Python 3.11) and 1.783.0 (Python 3.10):
    • Read every network call in policyengine-taxsim and the download paths in policyengine-us, policyengine-core and spm-calculator. PolicyEngine US downloads its default dataset only when a simulation has none; the emulator always passes its own.
    • Logged socket, DNS and HTTP events with a Python audit hook during a run of 2019 and 2023–2025 records with empty caches: none, apart from urllib3 binding a loopback socket on import.
    • Reran with all network access denied by sandbox-exec: completed, same output.
    • Inspected the TAXSIM-35 executables' imports: no socket, DNS or HTTP functions.
  • The web runner at policyengine.org/us/taxsim/run uploads the whole CSV to PolicyEngine's API on Modal. Its email option sends results through Resend and, with "Keep me updated" checked (the default), adds the address to PolicyEngine's Mailchimp list. The debrief records that the seminar's web demo ran successfully, which is this runner. The upload page says nothing about where the file goes. The security notes say so plainly.
  • Two opt-in Python paths post pre-2021 records to NBER (use_remote_taxsim=True, and the local API server). No command-line flag reaches them.

"Pretty basic Python libraries, like NumPy": not accurate. An install pulls in 89 packages on Python 3.11 to 3.14 (85 on 3.10). They include pytest, IPython, plotly, pyvis, requests, httpx, huggingface-hub and a Census API client, mostly through policyengine-core and spm-calculator. A run never calls the network-capable ones, but an IT review will see all of them. policyengine-us and policyengine-core are AGPL-3.0; policyengine-taxsim is MIT.

Both are queued for Max as decision d1192. Whether the upload page should carry a data notice is d1193.

Routing by year, confirmed against the code

  • StitchedRunner.PE_MIN_YEAR = 2021: 2021 and later go to PolicyEngine, earlier years to the bundled TAXSIM-35 executable.
  • Only the stdin command, the policyengine subcommand, StitchedRunner and the web runner route. PolicyEngineRunner and the R package compute every year with PolicyEngine, including years before 2021.
  • NBER's taxsim35.ado, as served on 2026-10-09, has no reference to PolicyEngine. It runs TAXSIM for every year.

Other things the audit turned up

Documented in the guides, not fixed here.

Inputs:

  • mstat 8 and any code other than 1, 2 and 6 are computed as an ordinary unmarried filer (Support TAXSIM mstat 8 (dependent taxpayer) #1218).
  • Spouse columns on non-joint returns are dropped silently; TAXSIM-35 rejects them. Misspelled or upper-case column names are also dropped silently.
  • pbusinc differs from the bundled TAXSIM-35 build. The emulator applies self-employment tax; build cd2026081819 doesn't. For a 2023 single filer with $100,000, fiitax is 9,226.50 against 10,469.90.

Records before 2021 (the bundled TAXSIM-35 path):

  • state -1 returns wrong answers. TAXSIM-35 returns a row per state and StitchedRunner keeps only as many rows as it sent, so the record comes back as Alabama and the pre-2021 records after it are replaced by its rows for other states. The state checks apply only to records from 2021 on.
  • idtl 5 doesn't work: one record returns only the standard columns, two or more raise an IndexError.
  • A stdin run with any pre-2021 record prints three progress lines ahead of the CSV header, and the executable chmod fails on installs owned by another account. Add --version and an opt-in --provenance sidecar to the CLI #1412 fixes both; the guides point to it and carry a marker to remove the note when it merges.
  • The bundled build includes Maryland county tax in siitax (7,428.45 against a state-only 4,417.25 on $100,000 in 2024) and returns 0 for both marginal rates.

Deployment:

  • The Linux TAXSIM-35 build needs glibc 2.34, so it does not run on Red Hat Enterprise Linux 8 or Ubuntu 20.04. It also needs libgfortran5. The macOS build is x86-64 only (Rosetta on Apple silicon). None is code-signed.
  • Temporary files holding the input records are deleted on a best-effort basis. Deletion failures are ignored, and on Windows the HDF5 file is deleted while still open, which Windows normally refuses. Not verified on Windows; the new test reports it there.
  • Python 3.10 installs stop at policyengine-us 1.783.0 (July 2026), the last release to support it, and that snapshot carries pytest 8.4.2 with PYSEC-2026-1845. pip-audit found nothing in the 3.11 to 3.14 snapshots.
  • PyPI's index doesn't keep old releases: the oldest policyengine-us listed is 1.691.1 (May 2026). The design doc tells users to keep the files they run.
  • tfica on PolicyEngine rows includes employee state payroll taxes, which fica and TAXSIM-35's tfica don't.
  • uv.lock is stale; Remove the stale uv.lock and git-ignore it #1420 removes it.

Since this PR opened, #1245 merged and routes nonprop into PolicyEngine; the branch merges main, and the generated tables and prose now describe the new sign-dependent mapping.

Fixed here: the R package's documentation examples labeled SOI code 6 as California and 36 as New York. They are Colorado and Ohio; the examples now use 5 and 33.

Invariants

No tax calculation changes. The invariants the new tests hold:

  • Coverage: the documented columns are exactly the columns the code reads.
  • Docs are a function of the code: regenerating any block leaves the committed file unchanged.
  • Conservation: for any filing status, ages and state, an amount column adds exactly its value to the PolicyEngine inputs, summed over people and variables. Hypothesis, 75 examples. Intended exceptions, both documented: spouse columns add nothing off joint returns, and nonprop adds nothing.
  • No network: the tested run attempts no connection leaving the machine from process start to exit.
  • Differential: the Python example, the Stata .dta hand-off through the command line, and the R script give the same taxes.

Review

Independent GPT-6.1 Sol reviews, saved in ~/reviews/taxsim-agency-docs-1419/:

  1. b0ce19d: request changes, ten findings. I verified each against the code or the bundled executable and addressed all ten in 2d3be9a (state -1, idtl 5, Maryland, glibc, tfica, temporary files, example identifiers, Stata validation, test coverage wording, zero marginal rates).
  2. 2d3be9a: nine of ten resolved; one fix asked for (state -1 also displaces later records) plus nits. Addressed in f25da94, with a two-record test.
  3. f25da94: running.

Not verified

  • The Stata script was not run; this machine has no Stata. Its hand-off to the emulator is tested with the same command on the same .dta input, and the guide says the script itself is unrun.
  • The pip download commands in the offline recipe were not run; a local hook blocks pip. Their --platform flags were checked by computing the tags pip accepts with packaging.tags for all five snapshots (the first draft's single flag would have rejected six packages). The install step was run with uv pip install --no-index --find-links ... --require-hashes with all network access denied.
  • Temporary-file cleanup on Windows.
  • Python 3.12 to 3.14 resolve cleanly but aren't in CI.

axiom: n/a: documentation, tests and tooling; no policy rule changes.

🤖 Generated with Claude Code

MaxGhenis and others added 3 commits October 9, 2026 19:49
Three guides under docs/, asked for at the 2026-10-08 BLS Consumer Expenditure
seminar. Their reference tables are generated from the code by
scripts/generate_docs_reference.py, and tests fail when they drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed tables

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 Oct 12, 2026 1:09am UTC

Request Review

MaxGhenis and others added 2 commits October 9, 2026 20:45
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Temporary files: deletion is attempted, not guaranteed; test leftovers.
- Pre-2021 records: state checks do not apply, state -1 returns a wrong
  answer, idtl 5 does not work, marginal rates are 0.
- Maryland: the bundled TAXSIM build includes county tax.
- tfica includes employee state payroll taxes on PolicyEngine rows.
- Linux TAXSIM build needs glibc 2.34 (generated from the binary).
- Examples: do not round identifiers; Stata stores IDs as double and checks
  inputs before running.
- Say what the network test covers and what it cannot see.

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

Copy link
Copy Markdown
Contributor Author

#1422 makes the R package route by year like the CLI (engine = "policyengine" keeps every year on PolicyEngine), return rows in input order, and install a pinned policyengine-taxsim release from PyPI, or from a folder of wheels with setup_policyengine(wheel_dir = ...) and no network access. Seven statements here then describe the old behavior: the R row and the sentence after the engine table, the blank-year error row, "grouped by year" under Reading the output, the worked example's R paragraph, the security notes' R row under "What does use the network", and two lines of the design doc. No generated block is affected (the generator reads no R code).

The two PRs touch no common file. Whichever merges second carries these edits. The patch applies to this branch at 2d3be9a with git apply:

Patch (3 files, +11/−8)
diff --git a/docs/design.md b/docs/design.md
index b31f739c..fbf9d24d 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -37,7 +37,7 @@ flowchart LR
 | Bundled TAXSIM-35 | `resources/taxsimtest/`, `resources/taxsim35/` | NBER executables, installed with the package |
 | Comparison | `policyengine_taxsim/comparison/`, `dashboard/` | Record-by-record comparison of the two engines, and the validation dashboard at policyengine.org/us/taxsim |
 | Hosted API | `policyengine_taxsim/api.py` | The service behind the web runner, deployed on Modal; not used by a local install |
-| R package | `r-package/policyenginetaxsim/` | Calls `PolicyEngineRunner` through reticulate |
+| R package | `r-package/policyenginetaxsim/` | Calls `StitchedRunner` through reticulate |
 
 The tax rules themselves live in [policyengine-us](https://github.com/PolicyEngine/policyengine-us), which this package installs as a dependency. The emulator chooses inputs and reads outputs; it does not compute taxes itself.
 
@@ -50,7 +50,7 @@ The tax rules themselves live in [policyengine-us](https://github.com/PolicyEngi
 
 Each engine gets its records in one batch. The results are concatenated and put back in input order by original position, so repeated `taxsimid` values (panels, multi-state runs) keep their order. Options that only PolicyEngine understands (`--disable-salt`, `--assume-w2-wages`, `--logs`, an active S-corporation treatment) are applied to PolicyEngine rows only, with a warning when some records go to TAXSIM-35.
 
-The stdin command and the `policyengine` subcommand use `StitchedRunner`, as does the hosted web runner. `PolicyEngineRunner` used directly (the documented Python entry point) and the R package do not route: they compute every year with PolicyEngine. The `taxsim` subcommand computes every year with TAXSIM-35. The [input guide](input-guide.md#which-engine-computes-each-record) has the full table.
+The stdin command, the `policyengine` subcommand and the R package's `policyengine_calculate_taxes()` use `StitchedRunner`, as does the hosted web runner. The R function's `engine = "policyengine"` passes `pe_min_year=0`, which sends every record to PolicyEngine. `PolicyEngineRunner` used directly (the documented Python entry point) does not route: it computes every year with PolicyEngine. The `taxsim` subcommand computes every year with TAXSIM-35. The [input guide](input-guide.md#which-engine-computes-each-record) has the full table.
 
 NBER's `taxsim35` Stata command is not part of this package. It sends every record to TAXSIM-35, and the copy NBER served on 2026-10-09 makes no reference to PolicyEngine. No interface switches engines except the ones built on `StitchedRunner`.
 
diff --git a/docs/input-guide.md b/docs/input-guide.md
index 0890e3ed..22910b6a 100644
--- a/docs/input-guide.md
+++ b/docs/input-guide.md
@@ -55,11 +55,12 @@ The emulator has two engines. PolicyEngine US computes 2021 and later. A TAXSIM-
 | `policyengine-taxsim policyengine in.csv -o out.csv` | PolicyEngine | Bundled TAXSIM-35 |
 | Python `StitchedRunner(df).run()` | PolicyEngine | Bundled TAXSIM-35 |
 | Python `PolicyEngineRunner(df).run()` | PolicyEngine | PolicyEngine |
-| R `policyengine_calculate_taxes()` | PolicyEngine | PolicyEngine |
+| R `policyengine_calculate_taxes()` | PolicyEngine | Bundled TAXSIM-35 |
+| R `policyengine_calculate_taxes(engine = "policyengine")` | PolicyEngine | PolicyEngine |
 | `policyengine-taxsim taxsim in.csv` | Bundled TAXSIM-35 | Bundled TAXSIM-35 |
 | Web runner at policyengine.org/us/taxsim/run | PolicyEngine | TAXSIM-35, both on PolicyEngine's server |
 
-`PolicyEngineRunner` and the R package compute years before 2021 with PolicyEngine, which this project validates against TAXSIM only from 2021 on. Use the command line or `StitchedRunner` when your data include earlier years.
+`PolicyEngineRunner` computes years before 2021 with PolicyEngine, which this project validates against TAXSIM only from 2021 on, and so does the R package when asked to with `engine = "policyengine"`. Use the command line, `StitchedRunner` or the R package's default when your data include earlier years. The R package switched engines by year from version 0.2.0; earlier versions computed every year with PolicyEngine.
 
 NBER's own `taxsim35` command for Stata is separate software. It sends every record to TAXSIM-35, either a local `taxsim35.exe` or NBER's servers. The copy NBER served on 2026-10-09 contains no reference to PolicyEngine, so it does not switch engines by year. The year switch exists only in this package, in the interfaces marked above. See the [design document](design.md#routing-by-year) for how the switch works.
 
@@ -243,7 +244,7 @@ These stop the whole run with an error naming the problem. Each one is covered b
 | Input | Error |
 |---|---|
 | No `year` column | `Input data must contain a 'year' column` |
-| A blank `year` | `Cannot convert non-finite values (NA or inf) to integer` from the command line and `StitchedRunner`; `cannot convert float NaN to integer` from `PolicyEngineRunner` |
+| A blank `year` | `Cannot convert non-finite values (NA or inf) to integer` from the command line and `StitchedRunner`; `cannot convert float NaN to integer` from `PolicyEngineRunner`; `` `year` is missing or not a number in 1 record(s) `` from the R package |
 | `state` outside 0 to 51, or not a whole number | `Record 1: 52 is not a valid TAXSIM SOI state code (0-51)` |
 | `state` of -1 (TAXSIM's every-state option) | `TAXSIM state -1 (compute every state) is not supported` |
 | `state` given as text, such as `CA` | `TAXSIM state code must be a number, got 'CA'` |
@@ -300,7 +301,7 @@ Choose the output with `idtl`: 0 for the standard columns, 2 for full detail, 5
 
 Things to know when you use the results:
 
-- **Merge on `taxsimid`, not row order.** The command line and `StitchedRunner` return records in input order. `PolicyEngineRunner` and the R package currently return them grouped by year.
+- **Merge on `taxsimid`, not row order.** The command line, `StitchedRunner` and the R package return records in input order. `PolicyEngineRunner` currently returns them grouped by year.
 - **Round to cents.** PolicyEngine computes in 32-bit floating point. A value can come back as `4009.199951171875`; it means 4009.20.
 - **A file mixing years before and after 2021 has both engines' columns.** Cells an engine doesn't produce are blank, and TAXSIM-35 adds a column named for its build stamp, such as `cd2026081819`.
 - **Results depend on the versions installed.** Record the policyengine-taxsim and policyengine-us versions with your results. The [design document](design.md#versions-and-pinning) explains how to pin them.
@@ -502,7 +503,7 @@ This script has not been run in Stata; the build machine has no Stata license. I
 
 ### R
 
-Uses base R and the command line, so it needs no R packages and works offline once policyengine-taxsim is installed. The `policyenginetaxsim` R package is an alternative, but it installs Python packages from GitHub on first use and computes every year with PolicyEngine.
+Uses base R and the command line, so it needs no R packages and works offline once policyengine-taxsim is installed. The `policyenginetaxsim` R package is an alternative that returns the same results: it switches engines by year as the command line does. On first use it creates a Python environment and installs a released policyengine-taxsim from PyPI, or, with `setup_policyengine(wheel_dir = ...)`, from a folder of wheels with no network access.
 
 <!-- BEGIN GENERATED: example-r -->
 ```r
diff --git a/docs/security-and-deployment.md b/docs/security-and-deployment.md
index e0857411..c206ab77 100644
--- a/docs/security-and-deployment.md
+++ b/docs/security-and-deployment.md
@@ -51,7 +51,7 @@ Several installed packages can make network requests (requests, urllib3, httpx,
 | What | When | Where it connects | What it sends |
 |---|---|---|---|
 | Installing with pip or uv | Install and upgrade | PyPI (or your configured mirror); GitHub for `git+https://` installs | Package requests |
-| R package setup, `setup_policyengine()` | Run once by you, or automatically by the first `policyengine_calculate_taxes()` when no environment exists | GitHub and PyPI, to install `policyengine-taxsim` from GitHub's `main` branch with `--upgrade`; may download Miniconda through reticulate | Package requests |
+| R package setup, `setup_policyengine()` | Run once by you, or automatically by the first `policyengine_calculate_taxes()` when no environment exists | PyPI (or your configured mirror), to install the policyengine-taxsim release that matches the R package; also downloads Miniconda through reticulate when no compatible Python is installed. With `wheel_dir`, nothing: it installs from that folder (`--no-index --find-links`) and stops instead of downloading Miniconda | Package requests |
 | `StitchedRunner(..., use_remote_taxsim=True)` or `RemoteTaxsimRunner` | Only when Python code asks for it; no command-line flag sets it | NBER, `https://taxsim.nber.org/taxsim35/redirect.cgi` | Records before 2021, all TAXSIM input columns |
 | Local API server, `uvicorn policyengine_taxsim.api:local_app` | Only if you start it; needs the `api` extra and the `modal` package | NBER for records before 2021; Mailchimp and Resend if its email endpoint is used | As above, plus the email address and the results |
 | Web runner at policyengine.org/us/taxsim/run | When you upload a file in a browser | PolicyEngine's API on Modal (`policyengine--policyengine-taxsim-taxsimapi-serve.modal.run`) | The whole uploaded CSV |
@@ -333,6 +333,8 @@ Check the installation. With the snapshot's policyengine-us (2.38.1) this prints
 printf 'taxsimid,year,state,mstat,page,pwages\n1,2023,33,1,30,52000\n' | taxsim-env/bin/policyengine-taxsim

+The R package installs from the same folder, with no network access: setup_policyengine(wheel_dir = "wheels") runs pip with --no-index --find-links (it does not use the requirements file's hashes). Pass the Python the wheels were downloaded for as python if the machine has more than one.
+

Updates and patching

  • The package never updates itself. Upgrades happen only when you run pip.

</details>

MaxGhenis and others added 2 commits October 11, 2026 20:16
Main now routes nonprop into PolicyEngine (#1245): the generator reads
sign- and state-dependent mappings, and the guides and tests drop the
'ignored' claim. From the re-review: a pre-2021 state -1 record replaces
the records after it with its rows for other states; tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… can't describe

From the third review: negative nonprop arrives as a positive deduction,
not as given. The generator now probes state-specific mappings before
calling a column unused, raises on a positive amount arriving negative,
and compares the union of inputs, raising on mismatched shapes. The
generated tables are unchanged.

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

This branch was successfully deployed

1 active deployment
Preview — 09eb0c22 Deployed Oct 12, 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