This repository was archived by the owner on Jul 2, 2026. It is now read-only.
Repository navigation
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Scope
This postmortem covers only the zero-dollar SPM threshold failure at the
policyengine-us/policyengine-us-databoundary. It does not cover EITC calibration targets, fiscal estimates, Social Security data, or the history of downstream proposal apps.Summary
We moved SPM thresholds from stored data values into
policyengine-usformulas. That was the right long-term direction:policyengine-us-datashould emit non-formulaic leaf inputs, andpolicyengine-usshould calculate deterministic outputs like thresholds and poverty status.We created a base-year failure mode during that migration. The first threshold formula inferred a geographic adjustment from the prior-year stored threshold. A single-year 2024 Enhanced CPS artifact did not contain a 2023 threshold, so the formula read the missing prior-year value as zero and calculated a zero geographic adjustment. That zero flowed into the 2024 SPM threshold.
At the same time,
policyengine-us-datahad a generic formula-variable pruning step. Oncepolicyengine-usclassifiedspm_unit_spm_thresholdas formula-backed, the exporter could drop the stored threshold that had previously served as the base-year anchor. We therefore removed the old fallback at the same time that the new formula still depended on it.A zero threshold collapses poverty and deep poverty into the same test:
resources < thresholdresources < threshold / 20, both becomeresources < 0The visible symptom was poverty equaling deep poverty.
What Should Have Happened
policyengine-usshould calculate SPM thresholds from current-year leaf inputs:spm-calculatorpolicyengine-us-datashould emit those leaf inputs only. It should not persist formula outputs such asspm_unit_spm_threshold,person_in_poverty,in_poverty, orin_deep_poverty.How We Created The Failure
1. We treated the threshold as a data input
Before the migration,
policyengine-usdefinedspm_unit_spm_thresholdas an input with CPI-U uprating.policyengine-us-datacalculated thresholds from CPS SPM fields and geographic adjustments, then stored them in the H5. This duplicated logic, but it worked because the model treated the stored threshold as a data input.2. We moved threshold logic into a formula that needed a prior-year anchor
policyengine-us#8020 replaced the input/uprating behavior with a
spm-calculatorformula. The formula tried to preserve local geography by backing out an implied geographic adjustment from the prior-year threshold:This passed tests that supplied a prior-year threshold. It failed on a base-year-only artifact. The 2024 Enhanced CPS asked the formula for a 2023 threshold, found no 2023 value, and received the variable default. The formula then accepted
0 / denominatoras a real geographic adjustment of zero.The test suite missed the base-year case. It tested transitions with explicit prior-year thresholds, not a single-year dataset with no prior-year threshold input.
3. We let generic formula pruning remove the old fallback
policyengine-us-data#554 introduced
_drop_formula_variables, which removed variables thatpolicyengine-uscomputed with formulas,adds, orsubtracts. When #554 landed,spm_unit_spm_thresholdwas still input-backed, so the exporter kept it.After #8020, the exporter saw
spm_unit_spm_thresholdas formula-backed and dropped it. That removed the stored threshold that could have masked the missing prior-year anchor.We also had a second design problem nearby: the CPS-only QRF-imputed list included
spm_unit_spm_threshold. Thresholds are deterministic functions of geography, tenure, and composition, so we should never donor-impute them onto the clone half.4. We let missing structural data become a plausible zero
The calculation did not fail when the prior-year threshold was absent. It returned zero and continued. For a poverty threshold, zero is not a plausible structural default. The model should either calculate the threshold from current-year leaf inputs or fail closed.
Timeline
2025-12-19: us-data starts constructing SPM thresholds
policyengine-us-data#453 added
spm-calculatoruse in data construction. The pipeline still treated thresholds as data outputs and model inputs.2026-03-04: us-data adds formula-variable pruning
policyengine-us-data#554 added
_drop_formula_variables. This made sense as a space and contract cleanup, but it inferred export intent from the currentpolicyengine-usvariable kind. Later model-side changes could therefore change what us-data exported.2026-04-17: policyengine-us migrates thresholds to formulas
policyengine-us#8020 made
spm_unit_spm_thresholdformulaic and inferred geography from the prior-year stored threshold. This introduced the zero-anchor failure mode for single-year datasets.2026-05-05: us-data lands a tactical fix
policyengine-us-data#903 moved thresholds out of QRF imputation, recalculated them from assigned geography near the end of the Extended CPS build, and temporarily preserved
spm_unit_spm_thresholdthrough formula pruning.That fixed the immediate artifact, but it still preserved a formulaic output in us-data as a transition step.
2026-05-07: us-data tightens threshold and clone handling
policyengine-us-data#917 aligned threshold and reference-threshold math and tightened clone priors. This improved the transitional data-side implementation but did not yet finish the ownership migration.
2026-05-07 to 2026-05-08: we move ownership to model formulas
policyengine-us#8246 introduced
spm_unit_geographic_adjustmentand made thresholds use current-year composition, tenure, and geographic adjustment.policyengine-us-data#918 stopped materializing SPM thresholds in us-data and stored geographic adjustments instead.
policyengine-us#8249 removed the prior-year threshold backout and calculated geographic adjustment from current raw geography and tenure via
spm-calculator.policyengine-us-data#924 removed the remaining ACS SPM threshold materialization.
2026-05-12: us-data explicitly excludes formulaic SPM outputs
policyengine-us-data#954 centralized formulaic SPM outputs that us-data should not persist:
person_in_povertyin_povertyin_deep_povertyspm_unit_is_in_spm_povertyspm_unit_is_in_deep_spm_povertyspm_unit_spm_thresholdspm_unit_geographic_adjustmentThis made the SPM export rule explicit instead of relying only on broad formula detection.
How We Fixed It
We fixed the incident in layers:
policyengine-us, using current composition, tenure, and geographic adjustment (#8246).Why Tests Missed It
The old tests verified the intended formula behavior under a favorable setup:
policyengine-usreclassified them as formula-backed.The missing test was not "does
spm-calculatorreturn the right Census threshold?" It was "does the final one-year Enhanced CPS artifact plus the locked model package calculate positive thresholds and distinct poverty/deep-poverty rates?"What We Already Hardened
policyengine-usnow tests current-year geographic adjustment and includes a regression that prior stored thresholds do not imply geographic adjustment after #8249.policyengine-us-datanow explicitly drops formulaic SPM outputs throughFORMULAIC_SPM_INPUTS_TO_DROP.spm_unit_spm_thresholdfrom CPS SPM fields inadd_spm_variables.Hardening We Should Add Next
P0: generated-artifact SPM smoke test
After generating an Enhanced CPS artifact, instantiate a microsimulation from the saved H5 and calculate SPM variables from that artifact, not from intermediate dataframes.
Recommended checks, using MicroSeries operations rather than manual weights:
The tolerances should stay broad. The test should catch collapse and impossible thresholds, not pin the model to one baseline.
P0: cross-repo variable-kind contract check
When
policyengine-us-databumps its lockedpolicyengine-usversion, CI should check every variable that us-data exports or uses as a construction anchor. If a variable changes from input/uprated input to formula/adds/subtracts, CI should fail unless us-data deliberately stopped exporting it or explicitly marked it as construction-only.#974 covers the final export side of this. We should still keep a lock-bump-oriented contract check so the failure appears as close as possible to the model dependency update.
P0: self-lag formula lint
policyengine-usshould flag formulas that call the same variable inperiod.last_year. That pattern can be valid, but it should require:This would have caught the #8020 failure mode directly.
P1: fail-closed structural inputs
For structural inputs like geography, tenure, and poverty thresholds, missing data should not silently become zero when it drives classification outcomes. We should either document a safe default, such as geographic adjustment
1.0, or raise a hard error.P1: published-artifact validation before promotion
We should run the artifact-level SPM smoke test against the exact H5 we plan to publish. Source-level tests are not enough because this failure depended on what survived export and how the locked model calculated against the saved file.
P1: explicit export manifests
For each data build surface, us-data should declare:
Then CI should fail if the final artifact contains undeclared formula outputs or omits declared leaf inputs.
Broader Lessons
period.last_yearneeds a no-prior-input path.References
spm_unit_spm_thresholdto a formula using prior-year threshold backout.All reactions