Skip to content

Reform.from_dict applies bare-date period keys for a single instant, silently (and its bare except swallows update errors) #509

Description

@MaxGhenis

Problem

Reform.from_dict (policyengine_core/reforms/reform.py) has two footguns in its period-key handling:

for period, value in period_values.items():
    try:
        period = period_(period)
        parameter = parameter.update(period=period, value=value)
    except:
        if "." in period:
            start, stop = period.split(".")
            ...
  1. Bare date keys apply for a single instant, silently. {"param": {"2026-01-01": 0.39}} parses via period_("2026-01-01") and updates the parameter only at that instant — every later year reads the baseline value again. No warning. Callers almost never mean this: the natural reading (and what downstream wrappers document) is "from this date onward." This produced silently-wrong multi-year revenue estimates downstream (Open-ended reforms apply only to the start year in microsimulation (bare-date period keys) policyengine.py#453: a 10-year window scored the reform's start year correctly and exactly $0.00 for the nine later years).

  2. The bare except: swallows everything from the first branch — not just period-parse failures but errors raised by parameter.update itself (bad value type, immutable parameter, etc.), which then re-enter the range-split path and surface as an unrelated secondary error (or, if the key happens to contain a ., silently take the wrong code path).

Repro (~30 s, no data)

from policyengine_us import CountryTaxBenefitSystem
from policyengine_core.reforms import Reform

def probe(period_key):
    sys_ = CountryTaxBenefitSystem()
    R = Reform.from_dict(
        {"gov.irs.income.bracket.rates.7": {period_key: 0.39}},
        country_id="policyengine_us",
    )
    reformed = R(sys_)
    p = reformed.parameters.get_child("gov.irs.income.bracket.rates.7")
    return [float(p(f"{y}-01-01")) for y in (2026, 2027, 2030, 2035)]

print(probe("2026-01-01"))            # [0.39, 0.37, 0.37, 0.37]  ← silent truncation
print(probe("2026-01-01.2100-12-31")) # [0.39, 0.39, 0.39, 0.39]

Suggested fixes

  • Preferred: treat a bare date key as open-ended (value applies from that instant onward), matching the scalar branch's forever semantics (period="year:2000:100") and the intuitive reading. If changing semantics is too risky for existing callers, emit a loud warning when a bare date is applied as a single instant.
  • Independently: narrow except: to the period-parse exception so parameter.update errors surface directly.

Downstream, policyengine.py now serialises explicit start.stop ranges for all reforms (PolicyEngine/policyengine.py#454), so this is hardening for direct core users and other wrappers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions