Fix Reform.from_dict: apply bare date keys from that instant onward (#509)#510
Merged
Conversation
…509) Bare period keys like "2026-01-01" parsed to a single-day Period, so the reform value applied for one day only and every later year silently reverted to baseline. The bare `except:` also swallowed real parameter.update errors and misrouted them to the range branch. from_dict now detects period formats explicitly: bare ISO date/month/year apply from that instant onward, while ranges ("start.stop") and compound periods ("year:X:N") stay bounded. Entries are applied in chronological order so multi-entry reforms are order-independent, and update errors propagate. from_api emits explicit ranges so single-civil-year API policies stay bounded under the new rule. Fixes #509. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Extract per-format handlers (_eternity/_range/_bounded_period/_from_instant _update_kwargs) + dispatcher (_period_key_update_kwargs) + sort key (_update_start_instant) into module-level pure functions called by from_dict. Extract from_api range normalization into _api_period_range with an out-of-window guard. - Coerce non-string period keys to str, so an int year key no longer raises. - Build all update kwargs before applying, so a malformed key raises with no partially-applied reform. - Document and lock intended edge behaviors: entries sharing a start instant apply in dict order (last wins); bare dates are literal instants; invalid input is atomic. - Add per-helper unit tests (tests/core/test_reform_period_keys.py) plus behavior tests for ETERNITY, scalar shorthand, int key, bracket-indexed path, mixed range+bare, mid-year literal, same-start last-wins, and atomic failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #509.
Reform.from_dictapplied bare date period keys (e.g."2026-01-01") for a single instant, so the reform value reverted to baseline in every later period — silently — and a bareexcept:swallowed realparameter.updateerrors, misrouting them to the range branch.Changes
from_dict— explicit period-format detection instead of try/except-as-control-flow:"2026"/"2026-01"/"2026-01-01") → applies from that instant onward."start.stop"and compound"year:2026:5"→ bounded (unchanged)."ETERNITY"→ all time.parameter.updateerrors now propagate instead of being swallowed.from_api— emits explicitstart.stopranges so a single-civil-year policy stays bounded under the new from-onward rule (it would otherwise stringify to a bare"YYYY").Why
A dated policy change is naturally read as "from this date onward." The single-instant behavior produced silently-wrong multi-year results downstream — e.g. PolicyEngine/policyengine.py#453 (a 10-year window scored the start year and exactly $0.00 for the nine later years), and in the PolicyEngine simulation API a reform dated
2023-01-01scored exactly $0 for a 2026 simulation while the identical reform dated2026scored −$16.9M.Verification
tests/core/test_reforms.py— bare-date onward (the Reform.from_dict applies bare-date period keys for a single instant, silently (and its bare except swallows update errors) #509 repro), bare-year onward, multi-entry chronological sort, range bounded, compound-period bounded,Noneremoval, invalid-key raises.tests/coresuite: 622 passed, 1 skipped, 1 xfailed (no regressions).policyengine_us:gov.irs.income.bracket.rates.7 @ "2026-01-01"now reads[0.39, 0.39, 0.39, 0.39]for 2026/2027/2030/2035 (was 0.39 for 2026 only).Compatibility
Semantic change for existing bare-key callers (they previously applied for a single instant) → shipped as a
changed(minor) towncrier fragment. API round-trips stay bounded via thefrom_apihardening.🤖 Generated with Claude Code