diff --git a/Makefile b/Makefile index c2efd4e5860..c4a8a7fd82c 100644 --- a/Makefile +++ b/Makefile @@ -83,6 +83,9 @@ REST_SS_CALCULATOR_DIFFERENTIAL_TESTS := $(TESTS)/unit/test_ss_calculator_differ # Local 4 GiB-capped run peaked at 2.385 GiB before its 1800s loaded-host cap; # CI run 40318424511 passed all 126 cases in 312.62s (6:20 process wall). REST_SOCIAL_SECURITY_INPUT_TESTS := $(TESTS)/core/test_social_security_inputs.py +# Two-household H5 ingress and neutral-hook comparisons retain a reform tree. +# Isolate this file in the existing input target; no national dataset is loaded. +REST_SOCIAL_SECURITY_PIA_RATIO_INPUT_TESTS := $(TESTS)/core/test_ss_pia_ratio_inputs.py # These three parameter modules construct full reform trees; one also loads # a default Microsimulation only to read parameters. Release their heap before # the remaining policy tests, rather than stacking both in the 13.9 GB group. @@ -98,7 +101,8 @@ REST_REMAINING_HEAVY_TESTS := $(TESTS)/test_niit_section_911_magi.py \ $(TESTS)/test_schedule_d_filer_net_capital_gain.py REST_LISTED_TESTS := $(REST_CACHED_ARRAY_TESTS) $(REST_HEAVY_A_TESTS) $(REST_HEAVY_B_TESTS) \ $(REST_CORE_HEAVY_TESTS) $(REST_SS_CALCULATOR_ENGINE_TESTS) $(REST_SS_CALCULATOR_DIFFERENTIAL_TESTS) \ - $(REST_SOCIAL_SECURITY_INPUT_TESTS) $(REST_POLICY_PARAMETER_TESTS) $(REST_REMAINING_HEAVY_TESTS) + $(REST_SOCIAL_SECURITY_INPUT_TESTS) $(REST_SOCIAL_SECURITY_PIA_RATIO_INPUT_TESTS) \ + $(REST_POLICY_PARAMETER_TESTS) $(REST_REMAINING_HEAVY_TESTS) REST_IGNORES := $(addprefix --ignore=,$(TESTS)/policy/contrib $(TESTS)/microsimulation $(REST_SPM_TESTS)) # $(call rest_pytest,,,) is the pytest # command for one group. With REST_REPORT_DIR set, each group writes its own @@ -330,7 +334,14 @@ test-other-python-rest-ss-calculator-engine: test-other-python-rest-ss-calculator-differential: $(call rest_pytest,ss-calculator-differential,$(REST_SS_CALCULATOR_DIFFERENTIAL_TESTS)) test-other-python-rest-social-security-inputs: + @failed=""; for group in compatibility pia-ratio; do \ + $(MAKE) --no-print-directory test-other-python-rest-social-security-inputs-$$group || failed="$$failed $$group"; \ + done; \ + if [ -n "$$failed" ]; then echo "Failed Social Security input groups:$$failed"; exit 1; fi +test-other-python-rest-social-security-inputs-compatibility: $(call rest_pytest,social-security-inputs,$(REST_SOCIAL_SECURITY_INPUT_TESTS)) +test-other-python-rest-social-security-inputs-pia-ratio: + $(call rest_pytest,social-security-pia-ratio-inputs,$(REST_SOCIAL_SECURITY_PIA_RATIO_INPUT_TESTS)) test-other-python-rest-core: $(call rest_pytest,core,$(TESTS)/core,$(REST_LISTED_TESTS)) test-other-python-rest-policy-parameters: diff --git a/changelog.d/ss-pia-ratio-claiming-proxy.fixed.md b/changelog.d/ss-pia-ratio-claiming-proxy.fixed.md new file mode 100644 index 00000000000..e0008535a22 --- /dev/null +++ b/changelog.d/ss-pia-ratio-claiming-proxy.fixed.md @@ -0,0 +1 @@ +Flag assumed retirement claiming-age sensitivities as proxies even when the birth date is exact. diff --git a/changelog.d/ss-pia-ratio-lever-c.added.md b/changelog.d/ss-pia-ratio-lever-c.added.md new file mode 100644 index 00000000000..501d39bcfb5 --- /dev/null +++ b/changelog.d/ss-pia-ratio-lever-c.added.md @@ -0,0 +1,2 @@ +Add an eligibility-year PIA-ratio lever for reported Social Security benefits, supplied claim-age inputs, and named population inference and coverage diagnostics. + diff --git a/docs/usage/social-security-pia-ratio.md b/docs/usage/social-security-pia-ratio.md new file mode 100644 index 00000000000..fca7ba33b16 --- /dev/null +++ b/docs/usage/social-security-pia-ratio.md @@ -0,0 +1,250 @@ +# Population Social Security PIA ratio + +The reported-benefit route implements design v3 C1–C3, with A1's factor +composition, A5.2's record/proxy distinctions, P2's annual cross-section +convention, and amendments M2, M4, M8 and M9. It approximates a formula reform +for benefits already in payment. It does not reconstruct a worker's earnings +history or establish benefit entitlement. + +For each annual Person component `X` (`retirement`, `disability`, +`survivors`, `dependents`), the population lever applies + +```text +z = reported_X / (12 * a * s * baseline_COLA(E, t)) +factor_X = new_PIA(inferred_AIME, E; t) / baseline_PIA(inferred_AIME, E) +``` + +The public formula multiplies the reported base by this factor, the joint COLA +factor, the across-the-board factor and the payable factor. Calculated source +routes compute their PIA and COLAs directly and bypass these population +factors. An initial public-name input is migrated to its `_reported` twin, +including public-name dataset columns in the scoring year; a later explicit +public write is a final override. See the benefit-lever ingress documentation. + +The forward PIA calculations and inverse use the same service as the +[supplied-record calculator](social-security-calculator.md). +`bend_points_at` selects the original eligibility-year bend points; +`formula_path_at` supplies the combined reform and its `recompute_mode`. +C does not add a second retroactive-recomputation switch. The inverse always +uses the fixed unreformed formula and the unreformed reported amount. + +## Supplied claim age and explicit scenarios + +`social_security_retirement_claim_age_months` is a pure annual Person input: +age in whole months at first entitlement, so 62 years 3 months is 747. +It has no formula, uprating, or positive-amount selection rule. A projected H5 +may store this column like any other input, with a value for each scoring year. + +`gov.contrib.ssa.benefit_levers.pia_ratio.claiming_scenario` explicitly selects: + +| Value | Claiming adjustment source | +|---|---| +| `supplied_claim_age` (default) | The supplied age in whole months. | +| `entitlement_dates` | Component entitlement year/month and supplied birth date. | +| `age_62` | Sensitivity assuming age 62. | +| `age_65` | Sensitivity assuming age 65. | +| `fra` | Sensitivity assuming cohort full retirement age. | +| `age_70` | Sensitivity assuming age 70. | + +Core represents these named selector parameters as singleton lists. For example, +set `claiming_scenario` to `[age_65]` and `disability_eligibility_proxy` +to `[current_year]` in a parameter reform; the adapter reads the one selected +name. Bare scalar strings are unsupported by Core's parameter schema. + +All scenarios preserve cohort FRA, early months and delayed-credit rates. +Under `supplied_claim_age`, a missing value, a value before 62, or a value +after 70 leaves the affected factor at one and raises +`_pia_ratio_missing_claim_age`. There is no silent change to another scenario. +The `entitlement_dates` scenario uses the entitlement year/month and birth +year/month. Supply `social_security_birth_month`; the optional +`social_security_birth_day` refines SSA age attainment. With a known birth +month and no day, day 2 is the explicit within-month convention and +`_pia_ratio_proxy` is true. Zero birth month means unavailable. A complete +`ss_calculator_birth_date` supplies the exact date instead; exact dates +preserve attainment on the day before a birthday, including January 1 births. + +Where only a birth year is available for an age-month scenario, the cohort +calculation uses the documented non-January-1 approximation and raises +`_pia_ratio_proxy`. Exact birth information and an explicitly supplied +`birth_year` take precedence over a year derived from current age. When birth +information is unavailable but a worker eligibility year is supplied, E minus +62 can supply the approximate birth cohort for FRA and credit rates; this does +not change the supplied eligibility year. Supplied month/day without an +observed birth year or complete birth date still raises the proxy flag. +A claiming scenario is substantive: +in C2, replacing the +correct age-62 adjustment of 0.70 with 1 infers AIME $2,978 instead of $5,000 +and changes the annual gain from $1,165.248 to $1,664.64 before rounding, an +overstatement of about 43%. + +## Eligibility years and auxiliary identification + +Supplied `social_security_X_worker_eligibility_year`, +`social_security_X_worker_id` links and the underlying worker's +`social_security_worker_computation_year` precede proxies. A supplied +entitlement/onset year supplies the component's applicable basis where +appropriate. A link must identify an existing Person and a supplied computation +basis; conflicting record years are unsupported. + +For own retirement, a supplied worker eligibility year takes precedence. Without +one, exact birth information or an explicitly supplied `birth_year` identifies +the age-62 eligibility year before the current-age proxy; known entitlement +dates and a supplied observed claim age can also identify that year. If none of these +supplies a basis, each scoring year's proxy is `E = t - age_t + 62`. Rebuild +it independently for every annual cross-section. A row whose age stays 70 and +has no supplied basis has E=2018 in 2026 and E=2027 in 2035; +the model does not carry an inferred cohort or claiming history between those +rows. Integer age also leaves birthday/within-year ambiguity, so compare adjacent +eligibility years when scoring sensitivities. + +Disability uses `a = 1` and a supplied onset, entitlement or worker computation +year when available. Missing onset is explicitly controlled by +`gov.contrib.ssa.benefit_levers.pia_ratio.disability_eligibility_proxy`: +`pre_reform` is the upper-exposure assumption, using a supported benchmark +before the active reform; `current_year` assumes newly entitled in the scoring +year. Both are labelled proxies, not observed onset histories. + +For survivors and dependents, a recipient age cannot identify the underlying +worker's PIA. A supplied worker link/year alone does not establish the share. +The identified auxiliary route also requires: + +- `social_security_X_worker_benefit_ratio`: the composite `a * s`, including + every applicable claiming adjustment and recipient share/reduction. +- `social_security_X_worker_ratio_identified`: an explicit assertion that this + is one identified worker PIA, with no unaccounted dual entitlement, binding + family cap or earnings-test withholding. + +For reported retirement, a record that also has positive reported survivor, +dependent or disability amounts remains unmodeled for this lever. The mixed +recipient amounts do not identify whether retirement is one full worker PIA, +dual entitlement or a partial-year conversion. + +The inversion divides by that composite ratio once. Without a supported +worker basis and identified ratio, the amount keeps factor one and raises +`_pia_ratio_unmodeled`. No recipient-age worker proxy is enabled by default. +For this composite route, the named claiming-adjustment output is one as a +decomposition convention: the entire supplied `a * s` is assigned to the +worker-benefit ratio. This does not establish that the underlying worker +claimed at FRA or identify `a` separately. +The restricted supplied-ratio survivor household calculator retains its own +more detailed record-basis and nonbinding-limit assertions. + +When S.770 is active, reported survivors additionally require the same +recipient-side `ss_calculator_*` computation-basis fields as the household +route: worker birth year and death year, basis provenance, prior-disability +declaration, ordinary non-WINDEX assertion, and the applicable exact dates or +year-only/same-year assertions. An authoritative computation year is required +for prior disability. The basis must agree with the supplied worker eligibility +or computation year. Missing identification leaves factor one and raises +`_pia_ratio_unmodeled`; contradictory supplied information is rejected. +For a pre-2026 death before age 62 with no prior disability eligibility, the +existing S.770 interpretation switch controls only the 18% first-bend-point +increase. The 95% first factor applies with either switch value. + +Supported benchmark years start in 1983. A positive record with an earlier +benchmark keeps factor one and raises `_pia_ratio_pre_1983`. The initial PIA +rounding rule before 1983 is outside this inverse's scope. COLAs accumulate +from eligibility, including pre-claim COLAs, using the unreformed statutory +ledger; they do not use benefit-income uprating or reformed COLAs. + +## Named annual outputs + +Replace `X` below with any of the four component names. Monetary outputs are +monthly dollars at the eligibility-year valuation basis, even though the +reported source benefit is annual. + +| Person variable suffix after `social_security_X` | Meaning | +|---|---| +| `_inferred_aime` | Baseline inverse of the observed amount; the lower-endpoint convention for an exact dime-rounded eligibility PIA. | +| `_inferred_aime_lower_bound` | Inclusive conditional AIME interval bound. | +| `_inferred_aime_upper_bound` | Exclusive conditional AIME interval bound. | +| `_pia_ratio_eligibility_year` | Underlying worker eligibility/computation year E. | +| `_pia_ratio_claiming_adjustment` | Imputed worker claiming adjustment a; zero means unavailable on a positive retirement record, while one is the auxiliary composite-ratio decomposition convention. | +| `_pia_ratio_own_record_retired_worker` | Own-record retired-worker indicator. | +| `_pia_ratio_missing_claim_age` | Required claiming information is unusable. | +| `_pia_ratio_pre_1983` | Benchmark predates supported initial-PIA rounding. | +| `_pia_ratio_unmodeled` | This amount lacks a supported identified inversion. | +| `_pia_ratio_proxy` | An eligibility, birth or claiming approximation was used, including every resolved `age_62`, `age_65`, `fra` or `age_70` sensitivity even with an exact birth date. | +| `_pia_ratio_measurement_uncertainty` | Annual reported measurement is not an exact eligibility PIA. | + +Zero monetary outputs on records without an inference are placeholders. +Inspect the flags before interpreting them as zero earnings. The own-record +indicator describes the modeling classification; it is not evidence of insured +status or an administrative validation of benefit type. It is independent of +whether claiming data are missing or the benchmark predates 1983. For an AIME +distribution or percentile calculation, also exclude `_pia_ratio_unmodeled`, +`_pia_ratio_missing_claim_age` and `_pia_ratio_pre_1983` records and inspect +the proxy and measurement-uncertainty flags. +For identified-only distributions, also exclude `_pia_ratio_proxy` records; +report their sensitivity results separately. The named AIME outputs include +supported proxy inferences, so this filter must be applied by the consumer. + +On a positive retirement record, claiming adjustment zero is an unavailable +diagnostic sentinel until the selected scenario yields a usable adjustment. +Missing claiming data and unsupported pre-1983 records can therefore report +zero while keeping the benefit factor at one. Do not interpret this sentinel +as an actuarial factor, a complete benefit cut, or a default FRA assumption; +filter the associated flags. Disability keeps adjustment one, auxiliary +composite ratios use one by decomposition convention, and a zero-benefit +record keeps the neutral adjustment one. + +For exact dime-rounded baseline eligibility PIA z, the interval is +`[inverse(z), inverse(z + 0.10))`; the point estimate is its lower endpoint. +C2's $2,156.80 baseline PIA therefore gives +`[5000, 5000.3125)`. When the source is a rounded paid benefit or a survey +annual amount, that narrow interval is conditional on the assumed measurement +basis. It does not quantify all survey error. +`gov.contrib.ssa.benefit_levers.pia_ratio.annual_rounding_bound` optionally +supplies a nonnegative symmetric annual-dollar bound for +`[max(0, b - bound), b + bound)`, propagated backward through the baseline +divisor, with one dime of initial PIA truncation added at the upper endpoint. +The point estimate continues to invert the observed amount; it does not move +to the lower endpoint of this wider measurement interval. The default zero +adds no annual measurement-resolution assumption; it does not certify exact +data. + +Core stores monetary input arrays as float32. Before exact arithmetic, the +adapter uses each stored scalar's shortest float32 decimal representation. +This avoids expanding binary representation noise into an additional PIA +dime; it does not round every annual source to cents or recover precision +already lost when the input was stored. + +Named AIME point estimates and interval bounds are stored at Core's float32 +resolution. The adapter rounds them toward positive infinity so an ordinary +cast cannot move a lower endpoint below the baseline inverse. Represented +values can differ from the mathematical endpoint by up to one float32 step; +use the shared pure service for exact interval membership or boundary +calculations. It keeps rational endpoints internally, and the shared +`PIAInverse` exposes exact Fraction bounds as well as high-precision decimals. + +A zero benefit produces factor one and zero monetary outputs without division. +Negative reported amounts and nonpositive divisors are unsupported and rejected. +Factors cannot be negative. With every PIA reform off, factors are exactly one. + +## Weighted counts and scoring limits + +For any Person indicator `flag`, the authoritative weighted count is + +```text +sum(flag_i * person_weight_i) +``` + +The associated unmodeled annual amount is +`sum(flag_i * social_security_X_reported_i * person_weight_i)`. +Apply weights once. Report identified results, proxy sensitivities and unmodeled +amounts separately. + +Household variables `social_security_X_pia_ratio_FLAG_count` are also available +for `FLAG` equal to `missing_claim_age`, `pre_1983`, `unmodeled`, `proxy` +or `measurement_uncertainty`. They sum the corresponding Person indicators +within a household. A normal weighted household aggregation gives the weighted +count when members use the household weight. If an artifact supplies distinct +Person weights within a household, use the Person-indicator formula above. + +This approximation assumes the reported annual amount represents a full-year +benefit at the stated valuation basis; partial-year entitlement, survey error, +dual entitlement, withheld checks and binding caps can defeat unique inversion. +No package-specific parameter values or administrative percentile estimates are +encoded here. M8 allows progressive price-indexing thresholds from a supplied +bend-point fraction or dollar path; C's named outputs remain available for +population diagnostics and drift checks. diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/pia_ratio/annual_rounding_bound.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/pia_ratio/annual_rounding_bound.yaml new file mode 100644 index 00000000000..d229b02dd5a --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/pia_ratio/annual_rounding_bound.yaml @@ -0,0 +1,8 @@ +description: "Nonnegative optional symmetric annual dollar rounding or measurement-resolution bound. A supplied reported amount b represents the half-open interval [max(0, b - bound), b + bound) before backward propagation through the baseline claiming, worker-share and COLA divisor. The upper endpoint also allows one dime of initial PIA truncation. Zero leaves only the conditional PIA-rounding interval; it does not assert survey measurement is exact." +values: + 1983-01-01: 0 +metadata: + unit: currency-USD + label: Social Security population PIA-ratio annual rounding bound + economy: false + period: year diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/pia_ratio/claiming_scenario.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/pia_ratio/claiming_scenario.yaml new file mode 100644 index 00000000000..c3daa60b20a --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/pia_ratio/claiming_scenario.yaml @@ -0,0 +1,7 @@ +description: "Explicit claiming-adjustment source: supplied_claim_age, entitlement_dates, age_62, age_65, fra, or age_70. supplied_claim_age reads the pure claim-age-months input and leaves missing or unusable records unreformed; there is no automatic fallback." +values: + 1983-01-01: [supplied_claim_age] +metadata: + unit: str + label: Social Security population PIA-ratio claiming scenario + economy: false diff --git a/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/pia_ratio/disability_eligibility_proxy.yaml b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/pia_ratio/disability_eligibility_proxy.yaml new file mode 100644 index 00000000000..3e813710fc0 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ssa/benefit_levers/pia_ratio/disability_eligibility_proxy.yaml @@ -0,0 +1,7 @@ +description: "Explicit missing-onset sensitivity: pre_reform uses an eligibility benchmark before the earliest active reform path, bounded by supported years; current_year treats the record as newly entitled in the scoring year. Supplied entitlement and worker computation years take precedence. Both remain labelled proxies." +values: + 1983-01-01: [pre_reform] +metadata: + unit: str + label: Social Security disability PIA-ratio eligibility proxy + economy: false diff --git a/policyengine_us/tests/code_health/test_rest_python_groups.py b/policyengine_us/tests/code_health/test_rest_python_groups.py index 33b0c24b2f8..f9560c504d5 100644 --- a/policyengine_us/tests/code_health/test_rest_python_groups.py +++ b/policyengine_us/tests/code_health/test_rest_python_groups.py @@ -952,22 +952,25 @@ def stub_environment(tmp_path, outcomes): return env, calls -@pytest.mark.parametrize("failed_index", [None, 0, 1, 2]) +@pytest.mark.parametrize("failed_index", [None, 0, 1, 2, 3]) def test_social_security_processes_keep_reports_and_run_after_failures( tmp_path, groups, failed_index ): - """Keep three isolated processes after moving synthetic inputs to Rest.""" + """Keep four isolated processes, including both synthetic input files.""" report_option = "--junitxml=simulation-microsimulation.xml" microsimulation_commands = dry_run( "test-microsimulation", f"PYTEST_ADDOPTS={report_option}" ) assert len(microsimulation_commands) == 2 input_target = "test-other-python-rest-social-security-inputs" - (input_command,) = dry_run(input_target) - input_group = groups["social-security-inputs"] - assert input_command == ["pytest", *input_group["args"]] - commands = [*microsimulation_commands, input_command] - assert len(commands) == 3 + input_commands = dry_run(input_target) + input_groups = [ + groups[name] + for name in ("social-security-inputs", "social-security-pia-ratio-inputs") + ] + assert input_commands == [["pytest", *group["args"]] for group in input_groups] + commands = [*microsimulation_commands, *input_commands] + assert len(commands) == 4 reports = [] process_files = {} for index, command in enumerate(microsimulation_commands): @@ -976,22 +979,29 @@ def test_social_security_processes_keep_reports_and_run_after_failures( process_files[str(index)] = collected_files( [a for a in command[1:] if not a.startswith("--junitxml=")] ) - input_file = "policyengine_us/tests/core/test_social_security_inputs.py" - process_files["inputs"] = collected_files(input_command[1:]) - assert process_files["inputs"] == [input_file] - reference = collected_files([str(TESTS / "microsimulation"), input_file]) + input_files = [ + "policyengine_us/tests/core/test_social_security_inputs.py", + "policyengine_us/tests/core/test_ss_pia_ratio_inputs.py", + ] + for index, (command, file) in enumerate(zip(input_commands, input_files)): + process_files[f"inputs-{index}"] = collected_files(command[1:]) + assert process_files[f"inputs-{index}"] == [file] + reference = collected_files([str(TESTS / "microsimulation"), *input_files]) assert partition_problems(reference, process_files) == [] assert all( fnmatch(report.split("=", 1)[1], "simulation-*.xml") for report in reports ) - (input_report,) = [ - report for report in input_group["reports"] if report.endswith(".xml") - ] - assert input_report == f"{REPORT_DIR}/rest-python-social-security-inputs.xml" - reports.append(f"--junitxml={input_report}") - assert len(set(reports)) == 3 + for name, group in zip( + ("social-security-inputs", "social-security-pia-ratio-inputs"), input_groups + ): + (input_report,) = [ + report for report in group["reports"] if report.endswith(".xml") + ] + assert input_report == f"{REPORT_DIR}/rest-python-{name}.xml" + reports.append(f"--junitxml={input_report}") + assert len(set(reports)) == 4 - outcomes = ["0"] * 3 + outcomes = ["0"] * 4 if failed_index is not None: outcomes[failed_index] = "1" env, calls = stub_environment(tmp_path, outcomes) @@ -1013,7 +1023,7 @@ def test_social_security_processes_keep_reports_and_run_after_failures( ) assert calls.read_text().splitlines() == ["|".join(c[1:]) for c in commands] assert (microsimulation_result.returncode != 0) == (failed_index in (0, 1)) - assert (input_result.returncode != 0) == (failed_index == 2) + assert (input_result.returncode != 0) == (failed_index in (2, 3)) def scenario_outcomes(scenario, count): @@ -1044,7 +1054,14 @@ def test_every_group_runs_and_any_failure_fails_the_target(tmp_path, groups, sce failed = [group for group, outcome in zip(groups, outcomes) if outcome != "0"] assert (result.returncode != 0) == bool(failed), result.stdout + result.stderr if failed: - assert f"Failed Rest Python groups: {' '.join(failed)}" in result.stdout + # Both input report groups run inside the same existing Make target. + failed_targets = dict.fromkeys( + "social-security-inputs" + if group == "social-security-pia-ratio-inputs" + else group + for group in failed + ) + assert f"Failed Rest Python groups: {' '.join(failed_targets)}" in result.stdout @pytest.mark.parametrize("scenario", ["one-fails-one-killed", "all-fail", "all-pass"]) diff --git a/policyengine_us/tests/core/test_ss_pia_ratio_inputs.py b/policyengine_us/tests/core/test_ss_pia_ratio_inputs.py new file mode 100644 index 00000000000..b7a724af9bf --- /dev/null +++ b/policyengine_us/tests/core/test_ss_pia_ratio_inputs.py @@ -0,0 +1,438 @@ +"""PIA-ratio dataset ingress and compatibility with the neutral stacked hooks. + +The two-household H5 exercises a dataset column that a situation-only policy +test cannot cover. The differential replaces only C's four factor formulas +with A's original constant-one hooks: all other stacked-base calculations and +the public-column migration remain live. No population microsimulation runs. +""" + +from copy import deepcopy + +import numpy as np +import pytest +import yaml +from policyengine_core.data import Dataset +from policyengine_core.reforms import Reform +from policyengine_core.simulations import Simulation as CoreSimulation +from policyengine_core.simulations import SimulationBuilder + + +YEAR = 2026 +COMPONENTS = ( + "social_security_retirement", + "social_security_disability", + "social_security_survivors", + "social_security_dependents", +) +BENEFITS = (*COMPONENTS, "social_security") +DOWNSTREAM = ( + "taxable_social_security", + "tax_unit_taxable_social_security", + "adjusted_gross_income", + "income_tax", + "household_net_income", + "ssi", + "ssi_unearned_income", + "snap_unearned_income", +) +AMOUNTS = ((18_440.64, 0, 0, 0), (12_000, 6_000, 1_200, 300)) +LATER_YEAR_AMOUNTS = (12_000, 6_000, 3_000, 1_000) +GROUP_ENTITIES = ("household", "tax_unit", "spm_unit", "family", "marital_unit") +GROUP_PLURALS = ("households", "tax_units", "spm_units", "families", "marital_units") + + +def _write_h5(path): + """Write scored-year public benefits and whole-month claim-age columns.""" + import h5py + + ids = np.array([1, 2]) + columns = { + "person_id": ids, + "age": [62, 70], + "social_security_retirement_claim_age_months": [747, 0], + "taxable_interest_income": [30_000, 10], + "state_fips": [48, 48], + } + for entity in GROUP_ENTITIES: + columns[f"person_{entity}_id"] = ids + columns[f"{entity}_id"] = ids + columns[f"{entity}_weight"] = [2.0, 3.0] + for index, component in enumerate(COMPONENTS): + columns[component] = [amounts[index] for amounts in AMOUNTS] + with h5py.File(path, "w") as stored: + for name, values in columns.items(): + stored.create_dataset(f"{name}/{YEAR}", data=values) + + +def _situation(): + people = {} + for index, person in enumerate(("retiree", "missing_claim_age")): + people[person] = { + "age": {YEAR: (62, 70)[index]}, + "social_security_retirement_claim_age_months": {YEAR: (747, 0)[index]}, + "taxable_interest_income": {YEAR: (30_000, 10)[index]}, + **{ + component: {YEAR: AMOUNTS[index][column]} + for column, component in enumerate(COMPONENTS) + }, + } + result = {"people": people} + for plural in GROUP_PLURALS: + result[plural] = {person: {"members": [person]} for person in people} + for household in result["households"].values(): + household["state_code"] = {YEAR: "TX"} + return result + + +@pytest.fixture(scope="module") +def country_system(): + # Share the read-only model; reforms and simulations keep independent state. + from policyengine_us.system import system + + return system + + +def _private_reformed_system(system, reform): + # Reform clones parameters, unlike CoreSimulation(reform=...) on an + # explicit system. Its generic carry-over default shadows PE-US's, so + # retain the country's input contract on the private wrapper as well. + result = reform(system) + result.auto_carry_over_input_variables = system.auto_carry_over_input_variables + return result + + +def _neutral_stacked_base(system): + """Restore the original A hooks while retaining the complete stacked model.""" + from policyengine_us.model_api import Person, Variable, YEAR + + class NeutralPIARatioHooks(Reform): + def apply(self): + for component in COMPONENTS: + variable = type( + f"{component}_pia_ratio_factor", + (Variable,), + { + "value_type": float, + "entity": Person, + "definition_period": YEAR, + "formula": lambda person, period, parameters: 1, + }, + ) + self.update_variable(variable) + + return _private_reformed_system(system, NeutralPIARatioHooks) + + +@pytest.fixture(scope="module") +def neutral_stacked_base(country_system): + # One read-only constant-one reference serves every ingress route. + return _neutral_stacked_base(country_system) + + +def _snapshot(simulation): + requested = { + name: np.asarray(simulation.calculate(name, YEAR)).copy() + for name in (*BENEFITS, *DOWNSTREAM) + } + # Check intermediates reached by these outputs, not just their final + # totals. New diagnostic-only values have no counterpart in A's hooks. + cached = {} + for population in simulation.populations.values(): + for name, holder in population._holders.items(): + array = holder.get_array(YEAR, simulation.branch_name) + if array is not None: + cached[name] = np.asarray(array).copy() + return requested, cached + + +def _assert_snapshots_equal(current, reference): + assert current[0].keys() == reference[0].keys() + for name in current[0]: + np.testing.assert_array_equal( + current[0][name], reference[0][name], err_msg=name + ) + assert reference[1].keys() <= current[1].keys() + for name in reference[1]: + np.testing.assert_array_equal( + current[1][name], reference[1][name], err_msg=name + ) + + +@pytest.mark.parametrize("ingress", ("reported_situation", "public_h5")) +def test_all_off_matches_neutral_stacked_base( + country_system, neutral_stacked_base, tmp_path, ingress +): + """C adds no current-law change on either supported initial-ingress route.""" + if ingress == "public_h5": + path = tmp_path / "reported_benefits_2026.h5" + _write_h5(path) + inputs = {"dataset": Dataset.from_file(path)} + else: + inputs = {"situation": _situation()} + # Explicit reported names are the policy-sensitive route in a situation. + for person in inputs["situation"]["people"].values(): + for component in COMPONENTS: + person[f"{component}_reported"] = person.pop(component) + + results = [] + for system in (country_system, neutral_stacked_base): + simulation_inputs = ( + {"dataset": Dataset.from_file(path)} + if ingress == "public_h5" + else deepcopy(inputs) + ) + simulation = CoreSimulation( + **simulation_inputs, + tax_benefit_system=system, + default_input_period=YEAR, + default_calculation_period=YEAR, + ) + requested, cached = _snapshot(simulation) + for index, component in enumerate(COMPONENTS): + np.testing.assert_array_equal( + requested[component], + np.asarray([amounts[index] for amounts in AMOUNTS], dtype=np.float32), + ) + results.append((requested, cached)) + + _assert_snapshots_equal(*results) + + +def _later_year_situation(): + people = { + "recipient": { + "age": {2024: 70, YEAR: 72}, + "taxable_interest_income": {YEAR: 30_000}, + }, + "spouse": {"age": {2024: 68, YEAR: 70}}, + } + for component, amount in zip(COMPONENTS, LATER_YEAR_AMOUNTS): + people["recipient"][component] = {2024: amount} + situation = { + "people": people, + **{plural: {"unit": {"members": list(people)}} for plural in GROUP_PLURALS}, + } + situation["households"]["unit"]["state_code"] = {YEAR: "TX"} + return situation + + +def _build_later_year_simulation(system, route): + if route == "public_situation": + from policyengine_us import Simulation + + return Simulation(situation=_later_year_situation(), tax_benefit_system=system) + if route == "yaml_builder": + # Match the public-name YAML contract consumed by Core's test runner. + text = yaml.safe_dump({"input": _later_year_situation()}) + return SimulationBuilder().build_from_entities( + system, yaml.safe_load(text)["input"] + ) + simulation = SimulationBuilder().build_default_simulation(system, count=2) + simulation.set_input("age", YEAR, [72, 70]) + simulation.set_input("taxable_interest_income", YEAR, [30_000, 0]) + simulation.set_input("state_code", YEAR, ["TX", "TX"]) + for component, amount in zip(COMPONENTS, LATER_YEAR_AMOUNTS): + simulation.set_input(component, 2024, [amount, 0]) + return simulation + + +@pytest.mark.parametrize( + "route", ("public_situation", "dataset_set_input", "yaml_builder") +) +def test_current_law_public_and_later_year_ingress( + country_system, neutral_stacked_base, route +): + """Public inputs retain current law after migration and 2024-to-2026 uprating.""" + results = [] + for system in (country_system, neutral_stacked_base): + simulation = _build_later_year_simulation(system, route) + for component in COMPONENTS: + np.testing.assert_array_equal( + simulation.calculate(f"{component}_pia_ratio_factor", YEAR), 1 + ) + results.append(_snapshot(simulation)) + _assert_snapshots_equal(*results) + assert results[0][0]["social_security_retirement"][0] > 12_000 + + +def test_household_flags_count_members_once(country_system): + situation = _later_year_situation() + for person in situation["people"].values(): + for component in COMPONENTS: + person.pop(component, None) + person["social_security_retirement_reported"] = {YEAR: 12_000} + simulation = CoreSimulation(situation=situation, tax_benefit_system=country_system) + flags = simulation.calculate( + "social_security_retirement_pia_ratio_missing_claim_age", YEAR + ) + counts = simulation.calculate( + "social_security_retirement_pia_ratio_missing_claim_age_count", YEAR + ) + np.testing.assert_array_equal(flags, [True, True]) + np.testing.assert_array_equal(counts, [2]) + assert np.dot(counts, [3]) == np.dot(flags, [3, 3]) == 6 + + +def test_assumed_claim_age_proxy_outputs_exclude_identified_only(country_system): + """Exact birth dates do not turn an assumed claim age into observed history.""" + people = { + person: { + "age": {YEAR: 70}, + "ss_calculator_birth_date": {"eternity": "1956-06-15"}, + "social_security_retirement_reported": {YEAR: 12_000}, + "social_security_retirement_claim_age_months": {YEAR: 0}, + } + for person in ("first_retiree", "second_retiree") + } + situation = { + "people": people, + **{plural: {"unit": {"members": list(people)}} for plural in GROUP_PLURALS}, + } + reform = Reform.from_dict( + { + "gov.contrib.ssa.benefit_levers.pia_ratio.claiming_scenario": { + YEAR: ["age_65"] + } + } + ) + simulation = CoreSimulation( + situation=situation, + tax_benefit_system=_private_reformed_system(country_system, reform), + default_input_period=YEAR, + default_calculation_period=YEAR, + ) + prefix = "social_security_retirement_pia_ratio_" + proxy = simulation.calculate(f"{prefix}proxy", YEAR) + np.testing.assert_array_equal(proxy, [True, True]) + np.testing.assert_array_equal( + simulation.calculate(f"{prefix}proxy_count", YEAR), [2] + ) + assert np.all( + simulation.calculate("social_security_retirement_inferred_aime", YEAR) > 0 + ) + identified_only = simulation.calculate(f"{prefix}own_record_retired_worker", YEAR) + np.testing.assert_array_equal(identified_only, [True, True]) + for flag in ("unmodeled", "missing_claim_age", "pre_1983", "proxy"): + values = simulation.calculate(f"{prefix}{flag}", YEAR) + if flag != "proxy": + np.testing.assert_array_equal(values, [False, False]) + identified_only = identified_only & ~values + assert not np.any(identified_only) + + +def test_supplied_claim_months_load_from_h5_and_drive_inverse(country_system, tmp_path): + path = tmp_path / "claim_months_2026.h5" + _write_h5(path) + reform = Reform.from_dict( + { + "gov.contrib.ssa.pia.in_effect": {YEAR: True}, + "gov.contrib.ssa.pia.factors.first": {YEAR: 0.95}, + "gov.contrib.ssa.pia.bend_point_multipliers.first": {YEAR: 1.15}, + "gov.ssa.social_security.pia.bend_points.first": {YEAR: 960}, + "gov.ssa.social_security.pia.bend_points.second": {YEAR: 5785}, + } + ) + simulation = CoreSimulation( + dataset=Dataset.from_file(path), + tax_benefit_system=_private_reformed_system(country_system, reform), + default_input_period=YEAR, + default_calculation_period=YEAR, + ) + np.testing.assert_array_equal( + simulation.calculate("social_security_retirement_claim_age_months", YEAR), + [747, 0], + ) + # Core carries stored inputs forward through its ordinary dataset contract; + # the input has no formula/uprating, and C rebuilds inferred cohorts by year. + np.testing.assert_array_equal( + simulation.calculate("social_security_retirement_claim_age_months", YEAR + 1), + [747, 0], + ) + adjustment = simulation.calculate( + "social_security_retirement_pia_ratio_claiming_adjustment", YEAR + ) + assert adjustment[0] == pytest.approx(0.7125) + assert adjustment[1] == 0 + aime = simulation.calculate("social_security_retirement_inferred_aime", YEAR) + assert aime[0] == pytest.approx(5000, abs=0.01) + lower = simulation.calculate( + "social_security_retirement_inferred_aime_lower_bound", YEAR + ) + upper = simulation.calculate( + "social_security_retirement_inferred_aime_upper_bound", YEAR + ) + assert lower[0] <= aime[0] < upper[0] + factors = simulation.calculate("social_security_retirement_pia_ratio_factor", YEAR) + assert factors[0] == pytest.approx(2295.5 / 2156.8) + assert factors[1] == 1 + np.testing.assert_array_equal( + simulation.calculate( + "social_security_retirement_pia_ratio_own_record_retired_worker", YEAR + ), + [True, False], + ) + flags = np.asarray( + simulation.calculate( + "social_security_retirement_pia_ratio_missing_claim_age", YEAR + ) + ) + np.testing.assert_array_equal(flags, [False, True]) + # Person flags retain the household weights supplied by the projected file. + weights = np.asarray(simulation.calculate("household_weight", YEAR)) + assert np.dot(flags, weights) == 3 + counts = simulation.calculate( + "social_security_retirement_pia_ratio_missing_claim_age_count", YEAR + ) + np.testing.assert_array_equal(counts, [0, 1]) + assert np.dot(counts, weights) == 3 + public = simulation.calculate("social_security_retirement", YEAR) + assert public[0] == pytest.approx(12 * 0.7125 * 2295.5, abs=0.01) + assert public[1] == 12_000 + + +def test_claim_months_are_a_pure_annual_input(country_system): + variable = country_system.variables["social_security_retirement_claim_age_months"] + assert variable.value_type is int + assert variable.default_value == 0 + assert not variable.formulas + assert not getattr(variable, "uprating", None) + + +def test_named_aime_endpoint_round_trips_after_float32_storage(country_system): + """A nonterminating inverse must survive storage in the Person holder.""" + from decimal import Decimal + from fractions import Fraction + + from policyengine_us.tools.social_security.parameters import bend_points_at + from policyengine_us.tools.social_security.pia import compute_pia + + situation = { + "people": { + "retiree": { + "age": {YEAR: 62}, + "social_security_retirement_reported": {YEAR: 60}, + "social_security_retirement_claim_age_months": {YEAR: 804}, + } + }, + **{plural: {"group": {"members": ["retiree"]}} for plural in GROUP_PLURALS}, + } + simulation = CoreSimulation( + situation=situation, + tax_benefit_system=country_system, + default_input_period=YEAR, + default_calculation_period=YEAR, + ) + aime = simulation.calculate("social_security_retirement_inferred_aime", YEAR)[0] + lower = simulation.calculate( + "social_security_retirement_inferred_aime_lower_bound", YEAR + )[0] + upper = simulation.calculate( + "social_security_retirement_inferred_aime_upper_bound", YEAR + )[0] + assert lower == aime + assert lower < upper + assert Fraction(float(lower)) >= Fraction(50, 9) + assert Fraction(float(upper)) >= Fraction(17, 3) + assert compute_pia( + float(aime), YEAR, bend_points=bend_points_at(country_system.parameters, YEAR) + ) == Decimal("5.0") diff --git a/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_dependents_pia_ratio_factor.yaml b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_dependents_pia_ratio_factor.yaml new file mode 100644 index 00000000000..6a61d790e13 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_dependents_pia_ratio_factor.yaml @@ -0,0 +1,94 @@ +- name: An auxiliary recipient age cannot identify the worker PIA + period: 2026 + input: + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + people: + child: + age: 10 + social_security_dependents_reported: 6000 + older_spouse: + age: 70 + social_security_dependents_reported: 6000 + output: + social_security_dependents_pia_ratio_factor: [1, 1] + social_security_dependents_pia_ratio_unmodeled: [true, true] + social_security_dependents_pia_ratio_own_record_retired_worker: [false, false] + social_security_dependents: [6000, 6000] + +- name: Linked dependent with an identified composite ratio uses the worker year + period: 2026 + absolute_error_margin: 0.01 + input: + gov.ssa.social_security.pia.bend_points.first: 960 + gov.ssa.social_security.pia.bend_points.second: 5785 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + gov.contrib.ssa.pia.bend_point_multipliers.first: 1.15 + people: + child: + age: 10 + social_security_dependents_worker_id: worker + social_security_dependents_worker_benefit_ratio: 0.5 + social_security_dependents_worker_ratio_identified: true + social_security_dependents_reported: 12940.8 + worker: + age: 62 + social_security_worker_computation_year: 2026 + output: + people: + child: + social_security_dependents_pia_ratio_eligibility_year: 2026 + social_security_dependents_pia_ratio_factor: 1.0643082344 + social_security_dependents_inferred_aime: 5000 + social_security_dependents_pia_ratio_unmodeled: false + social_security_dependents: 13773 + worker: + social_security_dependents_pia_ratio_factor: 1 + +- name: A worker link without an identified share remains unmodeled + period: 2026 + input: + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + people: + child: + age: 10 + social_security_dependents_worker_id: worker + social_security_dependents_reported: 6000 + worker: + age: 62 + social_security_worker_computation_year: 2026 + output: + people: + child: + social_security_dependents_pia_ratio_factor: 1 + social_security_dependents_pia_ratio_unmodeled: true + social_security_dependents: 6000 + +- name: A supplied 80 percent composite ratio preserves the exact baseline dime + period: 2026 + absolute_error_margin: 0.005 + input: + gov.ssa.social_security.pia.bend_points.first: 960 + gov.ssa.social_security.pia.bend_points.second: 5785 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + people: + dependent: + age: 70 + social_security_dependents_worker_id: worker + social_security_dependents_worker_benefit_ratio: 0.8 + social_security_dependents_worker_ratio_identified: true + # 12 * 0.8 * 2156.8. The input ratio is stored in a float32 holder. + social_security_dependents_reported: 20705.28 + worker: + age: 62 + social_security_worker_computation_year: 2026 + output: + people: + dependent: + social_security_dependents_inferred_aime: 5000 + social_security_dependents_pia_ratio_factor: 1.0222552 + social_security_dependents_pia_ratio_unmodeled: false + social_security_dependents: 21166.08 diff --git a/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_disability_pia_ratio_factor.yaml b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_disability_pia_ratio_factor.yaml new file mode 100644 index 00000000000..adaa895567a --- /dev/null +++ b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_disability_pia_ratio_factor.yaml @@ -0,0 +1,60 @@ +- name: A supplied onset year identifies disability PIA without a claiming reduction + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + social_security_disability_entitlement_year: 2026 + social_security_disability_reported: 25881.60 + gov.ssa.social_security.pia.bend_points.first: 960 + gov.ssa.social_security.pia.bend_points.second: 5785 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + gov.contrib.ssa.pia.bend_point_multipliers.first: 1.15 + output: + social_security_disability_pia_ratio_eligibility_year: 2026 + social_security_disability_pia_ratio_claiming_adjustment: 1 + social_security_disability_pia_ratio_missing_claim_age: false + social_security_disability_inferred_aime: 5000 + social_security_disability_pia_ratio_factor: 1.0643082344 + social_security_disability: 27546 + social_security_disability_pia_ratio_proxy: false + social_security_disability_pia_ratio_own_record_retired_worker: false + +- name: Missing disability onset is an explicitly labelled pre reform proxy + period: 2026 + input: + age: 45 + social_security_disability_reported: 12000 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + gov.contrib.ssa.pia.factors.start_year: 2026 + output: + social_security_disability_pia_ratio_eligibility_year: 2025 + social_security_disability_pia_ratio_claiming_adjustment: 1 + social_security_disability_pia_ratio_proxy: true + social_security_disability_pia_ratio_factor: 1 + social_security_disability: 12000 + +- name: Current year disability proxy is selected by parameter + period: 2026 + input: + age: 45 + social_security_disability_reported: 12000 + gov.contrib.ssa.benefit_levers.pia_ratio.disability_eligibility_proxy: [current_year] + output: + social_security_disability_pia_ratio_eligibility_year: 2026 + social_security_disability_pia_ratio_proxy: true + social_security_disability_pia_ratio_factor: 1 + +- name: Supplied disability eligibility overrides the proxy choice + period: 2026 + input: + age: 45 + social_security_disability_worker_eligibility_year: 2018 + social_security_disability_entitlement_year: 2020 + social_security_disability_reported: 12000 + gov.contrib.ssa.benefit_levers.pia_ratio.disability_eligibility_proxy: [current_year] + output: + social_security_disability_pia_ratio_eligibility_year: 2018 + social_security_disability_pia_ratio_proxy: false + social_security_disability_pia_ratio_factor: 1 diff --git a/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_retirement_pia_ratio_factor.yaml b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_retirement_pia_ratio_factor.yaml new file mode 100644 index 00000000000..5e1c17fb033 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_retirement_pia_ratio_factor.yaml @@ -0,0 +1,120 @@ +# Each distinct parameter override combination retains a full model in Core's cache. +# Group these cases to limit each file to at most five combinations. + +- name: Inactive population PIA reform preserves a reported record without ages + period: 2026 + input: + social_security_retirement_reported: 12000 + output: + social_security_retirement_pia_ratio_factor: 1 + social_security_retirement: 12000 + +- name: Missing or out of range supplied claim ages do not fall back + period: 2026 + input: + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + people: + missing: + age: 62 + social_security_retirement_reported: 12000 + before_62: + age: 62 + social_security_retirement_claim_age_months: 743 + social_security_retirement_reported: 12000 + after_70: + age: 72 + social_security_retirement_claim_age_months: 841 + social_security_retirement_reported: 12000 + output: + social_security_retirement_pia_ratio_factor: [1, 1, 1] + social_security_retirement_pia_ratio_claiming_adjustment: [0, 0, 0] + social_security_retirement_pia_ratio_missing_claim_age: [true, true, true] + social_security_retirement: [12000, 12000, 12000] + +- name: Zero benefit stays zero without a supplied claiming divisor + period: 2026 + input: + age: 62 + social_security_retirement_reported: 0 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + output: + social_security_retirement_pia_ratio_factor: 1 + social_security_retirement: 0 + social_security_retirement_inferred_aime: 0 + social_security_retirement_pia_ratio_missing_claim_age: false + +- name: Pre 1983 record remains unreformed and exposes its indicator + period: 2026 + input: + age: 106 + social_security_retirement_worker_eligibility_year: 1982 + social_security_retirement_claim_age_months: 744 + social_security_retirement_reported: 12000 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.recompute_existing: true + gov.contrib.ssa.pia.factors.first: 0.95 + output: + social_security_retirement_pia_ratio_eligibility_year: 1982 + social_security_retirement_pia_ratio_factor: 1 + social_security_retirement_pia_ratio_pre_1983: true + social_security_retirement_pia_ratio_pre_1983_count: 1 + social_security_retirement: 12000 + +- name: Cohort gate leaves an existing beneficiary unchanged + period: 2026 + input: + age: 68 + social_security_retirement_worker_eligibility_year: 2020 + social_security_retirement_claim_age_months: 744 + social_security_retirement_reported: 24000 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + gov.contrib.ssa.pia.factors.start_year: 2026 + output: + social_security_retirement_pia_ratio_factor: 1 + social_security_retirement: 24000 + +- name: The same factor change recomputes an old cohort only when selected + period: 2026 + absolute_error_margin: 0.01 + input: + age: 68 + social_security_retirement_worker_eligibility_year: 2020 + social_security_retirement_claim_age_months: 744 + social_security_retirement_reported: 24000 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.recompute_existing: true + gov.contrib.ssa.pia.factors.first: 0.95 + gov.contrib.ssa.pia.factors.start_year: 2026 + output: + social_security_retirement_pia_ratio_eligibility_year: 2020 + social_security_retirement_pia_ratio_claiming_adjustment: 0.7166666666666667 + +- name: Calculated sources bypass the population inverse under an active reform + period: 2026 + input: + social_security_retirement_source: supplied_pia + social_security_retirement_base: 10000 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + output: + social_security_retirement_pia_ratio_factor: 1 + social_security_retirement_pia_ratio_missing_claim_age: false + social_security_retirement: 10000 + +- name: Introduced S770 retains its distinct 18 percent first bend increase + period: 2026 + absolute_error_margin: 0.01 + input: + age: 62 + social_security_retirement_claim_age_months: 744 + social_security_retirement_reported: 18117.12 + gov.ssa.social_security.pia.bend_points.first: 960 + gov.ssa.social_security.pia.bend_points.second: 5785 + gov.contrib.congress.s770.section_2.in_effect: true + output: + social_security_retirement_inferred_aime: 5000 + social_security_retirement_pia_ratio_factor: 1.0727002967 + social_security_retirement: 19434.24 diff --git a/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_retirement_pia_ratio_factor_claiming.yaml b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_retirement_pia_ratio_factor_claiming.yaml new file mode 100644 index 00000000000..fcb8d4e6931 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_retirement_pia_ratio_factor_claiming.yaml @@ -0,0 +1,92 @@ +# Each distinct parameter override combination retains a full model in Core's cache. +# Group these cases to limit each file to at most five combinations. + +- name: C2 lower-endpoint inverse preserves the 15 percent bend fixture + period: 2026 + absolute_error_margin: 0.01 + input: + age: 62 + social_security_retirement_claim_age_months: 744 + social_security_retirement_reported: 18117.12 + gov.ssa.social_security.pia.bend_points.first: 960 + gov.ssa.social_security.pia.bend_points.second: 5785 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + gov.contrib.ssa.pia.bend_point_multipliers.first: 1.15 + output: + social_security_retirement_pia_ratio_eligibility_year: 2026 + social_security_retirement_pia_ratio_claiming_adjustment: 0.70 + social_security_retirement_inferred_aime: 5000 + social_security_retirement_inferred_aime_lower_bound: 5000 + social_security_retirement_inferred_aime_upper_bound: 5000.3125 + social_security_retirement_pia_ratio_factor: 1.0643082344 + social_security_retirement: 19282.20 + social_security_retirement_pia_ratio_own_record_retired_worker: true + social_security_retirement_pia_ratio_measurement_uncertainty: true + social_security_retirement_pia_ratio_unmodeled: false + +- name: FRA is an explicit sensitivity and changes inferred AIME + period: 2026 + absolute_error_margin: 0.01 + input: + age: 62 + social_security_retirement_reported: 18117.12 + gov.contrib.ssa.benefit_levers.pia_ratio.claiming_scenario: [fra] + gov.ssa.social_security.pia.bend_points.first: 960 + gov.ssa.social_security.pia.bend_points.second: 5785 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + gov.contrib.ssa.pia.bend_point_multipliers.first: 1.15 + output: + social_security_retirement_pia_ratio_claiming_adjustment: 1 + social_security_retirement_inferred_aime: 2978 + social_security_retirement_pia_ratio_proxy: true + +- name: Age 62 sensitivity identifies its claiming adjustment explicitly + period: 2026 + absolute_error_margin: 0.01 + input: + age: 62 + social_security_retirement_reported: 18117.12 + gov.contrib.ssa.benefit_levers.pia_ratio.claiming_scenario: [age_62] + gov.ssa.social_security.pia.bend_points.first: 960 + gov.ssa.social_security.pia.bend_points.second: 5785 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + output: + social_security_retirement_pia_ratio_claiming_adjustment: 0.70 + social_security_retirement_inferred_aime: 5000 + social_security_retirement_pia_ratio_proxy: true + +- name: Age 65 sensitivity retains the 24 month early retirement reduction + period: 2026 + absolute_error_margin: 0.01 + input: + age: 62 + social_security_retirement_reported: 22430.72 + gov.contrib.ssa.benefit_levers.pia_ratio.claiming_scenario: [age_65] + gov.ssa.social_security.pia.bend_points.first: 960 + gov.ssa.social_security.pia.bend_points.second: 5785 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + output: + social_security_retirement_pia_ratio_claiming_adjustment: 0.8666666666666667 + social_security_retirement_inferred_aime: 5000 + social_security_retirement_pia_ratio_proxy: true + +- name: Age 70 sensitivity uses the birth cohort delayed retirement credits + period: 2026 + absolute_error_margin: 0.01 + input: + age: 62 + social_security_retirement_reported: 32093.184 + gov.contrib.ssa.benefit_levers.pia_ratio.claiming_scenario: [age_70] + gov.ssa.social_security.pia.bend_points.first: 960 + gov.ssa.social_security.pia.bend_points.second: 5785 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + output: + social_security_retirement_pia_ratio_claiming_adjustment: 1.24 + social_security_retirement_inferred_aime: 5000 + social_security_retirement_pia_ratio_proxy: true + diff --git a/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_retirement_pia_ratio_factor_eligibility.yaml b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_retirement_pia_ratio_factor_eligibility.yaml new file mode 100644 index 00000000000..d88fb3b29eb --- /dev/null +++ b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_retirement_pia_ratio_factor_eligibility.yaml @@ -0,0 +1,160 @@ +# Each distinct parameter override combination retains a full model in Core's cache. +# Group these cases to limit each file to at most five combinations. + +- name: This scoring year rebuilds eligibility from its own age cross section + period: 2035 + input: + age: 70 + social_security_retirement_claim_age_months: 744 + social_security_retirement_reported: 12000 + output: + social_security_retirement_pia_ratio_eligibility_year: 2027 + social_security_retirement_pia_ratio_factor: 1 + +- name: The same age in the 2026 cross section has a different eligibility year + period: 2026 + input: + age: 70 + social_security_retirement_claim_age_months: 744 + social_security_retirement_reported: 12000 + output: + social_security_retirement_pia_ratio_eligibility_year: 2018 + social_security_retirement_pia_ratio_factor: 1 + +- name: Supplied entitlement and claim months override a conflicting age proxy + period: 2026 + absolute_error_margin: 0.0001 + input: + age: 70 + social_security_retirement_entitlement_year: 2025 + social_security_retirement_entitlement_month: 6 + social_security_retirement_claim_age_months: 780 + social_security_retirement_reported: 12000 + output: + # First entitlement at 65 in June 2025 identifies June 2022 eligibility. + social_security_retirement_pia_ratio_eligibility_year: 2022 + social_security_retirement_pia_ratio_claiming_adjustment: 0.8666666666666667 + +- name: Changing the claiming sensitivity preserves the observed eligibility year + period: 2026 + input: + age: 70 + social_security_retirement_entitlement_year: 2025 + social_security_retirement_entitlement_month: 6 + social_security_retirement_claim_age_months: 780 + social_security_retirement_reported: 12000 + gov.contrib.ssa.benefit_levers.pia_ratio.claiming_scenario: [fra] + output: + # The factual age-65 award fixes E independently of the FRA sensitivity. + social_security_retirement_pia_ratio_eligibility_year: 2022 + social_security_retirement_pia_ratio_claiming_adjustment: 1 + +- name: A supplied birth year determines the full retirement age cohort + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + birth_year: 1964 + social_security_retirement_claim_age_months: 744 + social_security_retirement_reported: 18117.12 + gov.ssa.social_security.pia.bend_points.first: 960 + gov.ssa.social_security.pia.bend_points.second: 5785 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + output: + # The supplied cohort has FRA 67 despite this cross section's age proxy. + social_security_retirement_pia_ratio_eligibility_year: 2026 + social_security_retirement_pia_ratio_claiming_adjustment: 0.70 + social_security_retirement_inferred_aime: 5000 + +- name: A linked worker computation year overrides the recipient age proxy + period: 2026 + input: + people: + recipient: + age: 80 + social_security_retirement_worker_id: worker + social_security_retirement_claim_age_months: 744 + social_security_retirement_reported: 12000 + worker: + age: 62 + social_security_worker_computation_year: 2020 + output: + people: + recipient: + social_security_retirement_pia_ratio_eligibility_year: 2020 + worker: + social_security_retirement_pia_ratio_factor: 1 + +- name: A linked eligibility cohort determines FRA before an unsupplied age proxy + period: 2026 + absolute_error_margin: 0.0001 + input: + people: + recipient: + age: 70 + social_security_retirement_worker_id: worker + social_security_retirement_claim_age_months: 744 + social_security_retirement_reported: 12000 + worker: + age: 70 + social_security_worker_computation_year: 2015 + output: + people: + recipient: + social_security_retirement_pia_ratio_eligibility_year: 2015 + social_security_retirement_pia_ratio_claiming_adjustment: 0.75 + +- name: Entitlement dates select the same 62 years 3 months claiming adjustment + period: 2026 + absolute_error_margin: 0.0001 + input: + age: 62 + social_security_birth_month: 6 + social_security_birth_day: 15 + social_security_retirement_entitlement_year: 2026 + social_security_retirement_entitlement_month: 9 + social_security_retirement_reported: 18440.64 + gov.contrib.ssa.benefit_levers.pia_ratio.claiming_scenario: [entitlement_dates] + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + output: + social_security_retirement_pia_ratio_eligibility_year: 2026 + social_security_retirement_pia_ratio_claiming_adjustment: 0.7125 + social_security_retirement_pia_ratio_missing_claim_age: false + +- name: Entitlement dates accept birth year and month without a supplied day + period: 2026 + absolute_error_margin: 0.0001 + input: + age: 62 + birth_year: 1964 + social_security_birth_month: 6 + social_security_retirement_entitlement_year: 2026 + social_security_retirement_entitlement_month: 9 + social_security_retirement_reported: 18440.64 + gov.contrib.ssa.benefit_levers.pia_ratio.claiming_scenario: [entitlement_dates] + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + output: + social_security_retirement_pia_ratio_eligibility_year: 2026 + social_security_retirement_pia_ratio_claiming_adjustment: 0.7125 + social_security_retirement_pia_ratio_proxy: true + social_security_retirement_pia_ratio_missing_claim_age: false + +- name: A supplied birth month and day still flag an age derived birth year + period: 2026 + absolute_error_margin: 0.0001 + input: + age: 62 + social_security_birth_month: 6 + social_security_birth_day: 15 + social_security_retirement_claim_age_months: 747 + social_security_retirement_reported: 18440.64 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + output: + social_security_retirement_pia_ratio_eligibility_year: 2026 + social_security_retirement_pia_ratio_claiming_adjustment: 0.7125 + social_security_retirement_pia_ratio_proxy: true + diff --git a/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_survivors_pia_ratio_factor.yaml b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_survivors_pia_ratio_factor.yaml new file mode 100644 index 00000000000..a492f9bbbb4 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/ssa/benefit_levers/social_security_survivors_pia_ratio_factor.yaml @@ -0,0 +1,47 @@ +- name: Survivor ages and recipient entitlement dates do not identify worker PIA + period: 2026 + input: + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + people: + young_survivor: + age: 10 + social_security_survivors_entitlement_year: 2025 + social_security_survivors_reported: 6000 + older_survivor: + age: 70 + social_security_survivors_reported: 12000 + output: + social_security_survivors_pia_ratio_factor: [1, 1] + social_security_survivors_pia_ratio_unmodeled: [true, true] + social_security_survivors: [6000, 12000] + +- name: A linked survivor composite ratio enters the inverse only once + period: 2026 + absolute_error_margin: 0.01 + input: + gov.ssa.social_security.pia.bend_points.first: 960 + gov.ssa.social_security.pia.bend_points.second: 5785 + gov.contrib.ssa.pia.in_effect: true + gov.contrib.ssa.pia.factors.first: 0.95 + gov.contrib.ssa.pia.bend_point_multipliers.first: 1.15 + people: + survivor: + age: 60 + social_security_survivors_worker_id: worker + social_security_survivors_worker_benefit_ratio: 0.7 + social_security_survivors_worker_ratio_identified: true + social_security_survivors_reported: 18117.12 + worker: + age: 62 + social_security_worker_computation_year: 2026 + output: + people: + survivor: + social_security_survivors_pia_ratio_eligibility_year: 2026 + social_security_survivors_inferred_aime: 5000 + social_security_survivors_pia_ratio_factor: 1.0643082344 + social_security_survivors_pia_ratio_unmodeled: false + social_security_survivors: 19282.20 + worker: + social_security_survivors_pia_ratio_factor: 1 diff --git a/policyengine_us/tests/unit/test_ss_calculator_differential.py b/policyengine_us/tests/unit/test_ss_calculator_differential.py index 3228ede8e5b..6597c4d557e 100644 --- a/policyengine_us/tests/unit/test_ss_calculator_differential.py +++ b/policyengine_us/tests/unit/test_ss_calculator_differential.py @@ -23,10 +23,11 @@ CALCULATOR_PREFIXES = ("ss_calculator_", "ss_calculated_") NEW_PARAMETER_NODES = ( - # Benefit levers and COLA history also serve the reported-benefit route, - # which remains part of the reference when the calculator is removed. - "gov.contrib.ssa.pia", - "gov.contrib.congress.s770.section_2", + # Keep gov.contrib.ssa.pia and gov.contrib.congress.s770.section_2: + # reported-benefit PIA ratios read these shared formula switches even + # without the household calculator. Calculator-only variables are still + # removed above, so neutral formula dependencies on them remain detectable. + # Benefit levers and statutory COLA history also remain in the reference. "gov.ssa.social_security.pia.bend_points", "gov.ssa.social_security.cola", ) diff --git a/policyengine_us/tests/unit/test_ss_pia_ratio.py b/policyengine_us/tests/unit/test_ss_pia_ratio.py new file mode 100644 index 00000000000..948c5ced647 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_pia_ratio.py @@ -0,0 +1,304 @@ +"""Pure population ratio API: no simulation or parameter-tree construction.""" + +from decimal import Decimal +from fractions import Fraction + +from hypothesis import given, settings, strategies as st +import pytest + +from policyengine_us.tools.social_security.pia import ( + FormulaPath, + compute_pia, + inverse_pia, +) +from policyengine_us.tools.social_security.pia_ratio import compute_pia_ratio + + +D = Decimal +BENDS = (960, 5785) +REVIEW_PATH = FormulaPath( + factor_values=(".95", ".32", ".15"), + bend_point_multipliers=("1.15", 1), +) + + +def test_c2_exact_interval_and_gain_before_and_after_dime_truncation(): + result = compute_pia_ratio( + "18117.12", + 2020, + REVIEW_PATH, + bend_points=BENDS, + claiming_adjustment=".70", + exact_eligibility_pia=True, + ) + assert result.aime == result.aime_lower == D(5000) + assert result.aime_upper == D("5000.3125") + assert not result.measurement_uncertainty + assert D("18117.12") * result.factor == D("19282.20") + before_rounding = compute_pia( + 5000, 2020, REVIEW_PATH, bend_points=BENDS, round_result=False + ) + baseline = compute_pia(5000, 2020, bend_points=BENDS, round_result=False) + assert 12 * D(".70") * (before_rounding - baseline) == D("1165.248") + + +def test_c2_fra_assumption_changes_inferred_aime_and_gain(): + result = compute_pia_ratio( + "18117.12", 2020, REVIEW_PATH, bend_points=BENDS, claiming_adjustment=1 + ) + assert result.aime == D(2978) + assert result.measurement_uncertainty + baseline = compute_pia(2978, 2020, bend_points=BENDS, round_result=False) + reform = compute_pia(2978, 2020, REVIEW_PATH, bend_points=BENDS, round_result=False) + assert 12 * (reform - baseline) == D("1664.64") + assert (D("1664.64") / D("1165.248") - 1) == pytest.approx(D(3) / 7) + + +def test_s770_eighteen_percent_bend_is_distinct_from_review_fixture(): + baseline = compute_pia(5000, 2020, bend_points=BENDS, round_result=False) + reform = compute_pia( + 5000, + 2020, + FormulaPath(s770=True), + 2026, + "retroactive", + bend_points=BENDS, + round_result=False, + ) + assert reform - baseline == D(".1634") * BENDS[0] + result = compute_pia_ratio( + "18117.12", + 2020, + FormulaPath(s770=True), + 2026, + "retroactive", + bend_points=BENDS, + claiming_adjustment=".7", + exact_eligibility_pia=True, + ) + assert D("18117.12") * result.factor == D("19434.24") + + +@pytest.mark.parametrize( + "pia", [".1", "863.9", "864", "864.1", "2407.9", "2408", "2408.1", "3000"] +) +def test_every_inverse_segment_and_boundary_uses_shared_lower_endpoint(pia): + result = compute_pia_ratio( + 12 * D(pia), 2020, bend_points=BENDS, exact_eligibility_pia=True + ) + interval = inverse_pia(pia, 2020, bend_points=BENDS) + assert result.aime == interval.lower + assert result.aime_lower == interval.lower + assert result.aime_upper == interval.upper + assert compute_pia(result.aime, 2020, bend_points=BENDS) == D(pia) + + +def test_nonterminating_lower_endpoint_is_not_rounded_down_before_forward_pia(): + result = compute_pia_ratio( + "1.2", 2020, REVIEW_PATH, bend_points=BENDS, exact_eligibility_pia=True + ) + assert result.factor == 1 + assert Fraction(result.aime) >= Fraction(1, 9) + + +def test_annual_measurement_flag_even_when_derived_pia_is_a_dime(): + result = compute_pia_ratio( + "18117.12", 2020, bend_points=BENDS, claiming_adjustment=".7" + ) + assert result.aime == 5000 + assert result.measurement_uncertainty + + +def test_annual_rounding_interval_is_propagated_through_all_divisors(): + point = compute_pia_ratio( + "18117.12", + 2020, + bend_points=BENDS, + claiming_adjustment=".7", + auxiliary_share=".5", + cola_factor=2, + ) + expanded = compute_pia_ratio( + "18117.12", + 2020, + bend_points=BENDS, + claiming_adjustment=".7", + auxiliary_share=".5", + cola_factor=2, + annual_rounding_bound=".5", + ) + assert expanded.aime == point.aime == 5000 + assert expanded.aime_lower < point.aime_lower + assert expanded.aime_upper > point.aime_upper + assert expanded.measurement_uncertainty + + +def test_survey_non_dime_interval_does_not_claim_to_be_exact(): + result = compute_pia_ratio( + "18117.13", 2020, bend_points=BENDS, claiming_adjustment=".7" + ) + assert result.measurement_uncertainty + assert result.aime == result.aime_lower > 5000 + assert result.aime_upper > result.aime + with pytest.raises(ValueError, match="multiple of one dime"): + compute_pia_ratio( + "18117.13", + 2020, + bend_points=BENDS, + claiming_adjustment=".7", + exact_eligibility_pia=True, + ) + + +def test_baseline_inverse_never_uses_reform_factors(): + lower_path = FormulaPath(factor_values=(".5", ".2", ".1")) + for path in (REVIEW_PATH, lower_path): + result = compute_pia_ratio( + "25881.6", 2020, path, bend_points=BENDS, exact_eligibility_pia=True + ) + assert result.aime == 5000 + + +def test_custom_unreformed_historical_scale_is_used_for_both_inverse_and_pia(): + result = compute_pia_ratio( + 1200, + 2020, + FormulaPath(factor_values=(".9", ".32", ".15")), + bend_points=BENDS, + factors=(".8", ".3", ".1"), + exact_eligibility_pia=True, + ) + assert result.aime == 125 + assert result.factor == D("1.125") + + +def test_colas_are_removed_before_inversion_once(): + result = compute_pia_ratio( + "36234.24", + 2020, + REVIEW_PATH, + bend_points=BENDS, + claiming_adjustment=".7", + cola_factor=2, + exact_eligibility_pia=True, + ) + assert result.aime == 5000 + assert D("36234.24") * result.factor == D("38564.4") + + +def test_zero_benefit_returns_neutral_without_inversion_or_formula_inputs(): + result = compute_pia_ratio(0, 2020, REVIEW_PATH, bend_points=(0, 0)) + assert result.factor == 1 + assert result.aime == result.aime_lower == result.aime_upper == 0 + assert D(0) * result.factor == 0 + assert not result.measurement_uncertainty + + +def test_pre1983_record_is_flagged_and_left_unreformed_without_inversion(): + result = compute_pia_ratio(20000, 1982, REVIEW_PATH, bend_points=(0, 0)) + assert result.factor == 1 + assert result.pre1983 + assert result.aime == result.aime_lower == result.aime_upper == 0 + + +@pytest.mark.parametrize("benefit", [-1, "NaN", "Infinity", True]) +def test_invalid_reported_benefit_is_rejected_even_before_1983(benefit): + with pytest.raises(ValueError, match="annual reported benefit"): + compute_pia_ratio(benefit, 1982, bend_points=BENDS) + + +@pytest.mark.parametrize( + "name", ["claiming_adjustment", "auxiliary_share", "cola_factor"] +) +@pytest.mark.parametrize("value", [0, -1, "NaN", "Infinity"]) +def test_invalid_divisor_is_rejected(name, value): + with pytest.raises(ValueError, match="positive|finite"): + compute_pia_ratio(10000, 2020, bend_points=BENDS, **{name: value}) + + +def test_nonpositive_baseline_pia_is_not_an_infinite_factor(): + with pytest.raises(ValueError, match="baseline PIA must be positive"): + compute_pia_ratio(".01", 2020, REVIEW_PATH, bend_points=BENDS) + + +@pytest.mark.parametrize("bound", [-1, "NaN", "Infinity"]) +def test_invalid_measurement_bound_rejected(bound): + with pytest.raises(ValueError, match="annual rounding bound"): + compute_pia_ratio(10000, 2020, bend_points=BENDS, annual_rounding_bound=bound) + + +def test_exact_pia_cannot_also_have_an_annual_measurement_interval(): + with pytest.raises(ValueError, match="cannot have an annual rounding bound"): + compute_pia_ratio( + 1200, + 2020, + bend_points=BENDS, + exact_eligibility_pia=True, + annual_rounding_bound=1, + ) + + +def test_cohort_and_shared_retroactive_modes_have_distinct_applicability(): + path = FormulaPath(factor_values=(".95", ".32", ".15"), factor_start_year=2027) + assert compute_pia_ratio("25881.6", 2020, path, 2027, bend_points=BENDS).factor == 1 + assert ( + compute_pia_ratio( + "25881.6", 2020, path, 2026, "retroactive", bend_points=BENDS + ).factor + == 1 + ) + assert ( + compute_pia_ratio( + "25881.6", 2020, path, 2027, "retroactive", bend_points=BENDS + ).factor + > 1 + ) + + +# These properties check arithmetic, not elapsed time on a shared test host. +@settings(deadline=None) +@given( + st.decimals(min_value=".01", max_value="1000000", places=2), st.integers(1983, 2035) +) +def test_every_reform_off_is_exactly_one(benefit, year): + assert compute_pia_ratio(benefit, year, bend_points=BENDS).factor == D(1) + + +@settings(deadline=None) +@given(st.integers(1, 50000)) +def test_baseline_inverse_roundtrips_and_contains_the_original_aime(aime): + pia = compute_pia(aime, 2020, bend_points=BENDS) + result = compute_pia_ratio( + 12 * pia, 2020, bend_points=BENDS, exact_eligibility_pia=True + ) + assert result.aime_lower <= aime < result.aime_upper + assert compute_pia(result.aime, 2020, bend_points=BENDS) == pia + + +@settings(deadline=None) +@given(st.integers(1, 50000)) +def test_everywhere_higher_scale_has_factor_at_least_one(aime): + pia = compute_pia(aime, 2020, bend_points=BENDS) + result = compute_pia_ratio( + 12 * pia, 2020, REVIEW_PATH, bend_points=BENDS, exact_eligibility_pia=True + ) + assert result.factor >= 1 + + +@settings(deadline=None) +@given(st.integers(1, 50000), st.integers(0, 100)) +def test_factor_is_never_negative_for_nonnegative_reform_scale(aime, percent): + pia = compute_pia(aime, 2020, bend_points=BENDS) + path = FormulaPath(factor_values=(Fraction(percent, 100), ".32", ".15")) + result = compute_pia_ratio( + 12 * pia, 2020, path, bend_points=BENDS, exact_eligibility_pia=True + ) + assert result.factor >= 0 + + +@settings(deadline=None) +@given(st.integers(1983, 2035)) +def test_zero_remains_zero_under_an_active_reform(year): + result = compute_pia_ratio(0, year, REVIEW_PATH, bend_points=BENDS) + assert result.factor == 1 + assert 0 * result.factor == 0 diff --git a/policyengine_us/tests/unit/test_ss_pia_ratio_parameters.py b/policyengine_us/tests/unit/test_ss_pia_ratio_parameters.py new file mode 100644 index 00000000000..cba3ae13975 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_pia_ratio_parameters.py @@ -0,0 +1,57 @@ +"""Load only C's authored parameter subtree, without a US model or simulation.""" + +from pathlib import Path + +import pytest +from policyengine_core.parameters import ParameterNode + + +PARAMETER_PATH = ( + Path(__file__).resolve().parents[2] + / "parameters/gov/contrib/ssa/benefit_levers/pia_ratio" +) +PARAMETER_NAME = "gov.contrib.ssa.benefit_levers.pia_ratio" + + +@pytest.fixture +def parameters(): + # The native Core loader must read the authored files. A hand-built dict or + # mocked snapshot would miss unsupported scalar-string parameter syntax. + return ParameterNode( + name=PARAMETER_NAME, + directory_path=str(PARAMETER_PATH), + ) + + +@pytest.mark.parametrize("instant", ["1983-01-01", "2026-01-01", "2035-01-01"]) +def test_authored_defaults_load_as_native_named_selector_lists(parameters, instant): + snapshot = parameters(instant) + assert snapshot.claiming_scenario == ["supplied_claim_age"] + assert snapshot.disability_eligibility_proxy == ["pre_reform"] + assert snapshot.annual_rounding_bound == 0 + + +def test_authored_selector_paths_and_metadata_survive_loading(parameters): + assert parameters.claiming_scenario.name == PARAMETER_NAME + ".claiming_scenario" + assert ( + parameters.disability_eligibility_proxy.name + == PARAMETER_NAME + ".disability_eligibility_proxy" + ) + assert parameters.claiming_scenario.metadata["unit"] == "str" + assert parameters.disability_eligibility_proxy.metadata["unit"] == "str" + + +def test_named_selector_reform_and_snapshots_keep_dated_values(parameters): + parameters.claiming_scenario.update(start="2026-01-01", value=["entitlement_dates"]) + parameters.disability_eligibility_proxy.update( + start="2026-01-01", value=["current_year"] + ) + parameters.annual_rounding_bound.update(start="2026-01-01", value=0.5) + earlier = parameters("2025-01-01") + current = parameters("2026-01-01") + assert earlier.claiming_scenario == ["supplied_claim_age"] + assert earlier.disability_eligibility_proxy == ["pre_reform"] + assert earlier.annual_rounding_bound == 0 + assert current.claiming_scenario == ["entitlement_dates"] + assert current.disability_eligibility_proxy == ["current_year"] + assert current.annual_rounding_bound == 0.5 diff --git a/policyengine_us/tests/unit/test_ss_population_composition.py b/policyengine_us/tests/unit/test_ss_population_composition.py new file mode 100644 index 00000000000..a347112a855 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_population_composition.py @@ -0,0 +1,185 @@ +"""Reported-benefit inference and public-formula factor composition. + +The authored SSA parameter subtrees exercise the production adapter and public +formula without constructing a country system or cloning its parameter tree. +""" + +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +from policyengine_core.parameters import ParameterNode +from policyengine_core.periods import period + +import policyengine_us +from policyengine_us.tools.social_security.cola import ( + cola_factor, + read_parameter_events, +) +from policyengine_us.tools.social_security.population import reported_pia_ratio_output +from policyengine_us.tools.social_security_benefit_levers import ( + SocialSecurityBenefitSource, + reported_joint_cola_factor, +) +from policyengine_us.variables.gov.ssa.ss.social_security_retirement import ( + social_security_retirement, +) + +PERIOD = period(2026) +E = 2025 +OLD_PIA = Decimal("2156.8") +NEW_PIA = Decimal("2295.5") + + +@pytest.fixture +def parameters(): + parameter_root = Path(policyengine_us.__file__).resolve().parent / "parameters" + root = ParameterNode(data={}) + for dotted in ( + "gov.contrib.ssa.pia", + "gov.contrib.congress.s770.section_2", + "gov.contrib.ssa.benefit_levers", + "gov.ssa.cola_history", + "gov.ssa.social_security.cola", + "gov.ssa.social_security.pia", + ): + parent = root + parts = dotted.split(".") + for part in parts[:-1]: + if part not in parent.children: + full_name = ".".join(filter(None, (parent.name, part))) + parent.add_child(part, ParameterNode(full_name, data={})) + parent = getattr(parent, part) + parent.add_child( + parts[-1], + ParameterNode(dotted, directory_path=str(parameter_root.joinpath(*parts))), + ) + # Fix the C2 initial-eligibility bends without cloning the whole country. + root.gov.ssa.social_security.pia.bend_points.first.update( + start="2025-01-01", value=960 + ) + root.gov.ssa.social_security.pia.bend_points.second.update( + start="2025-01-01", value=5785 + ) + pia = root.gov.contrib.ssa.pia + pia.in_effect.update(start="2026-01-01", value=True) + pia.recompute_existing.update(start="2026-01-01", value=True) + pia.factors.first.update(start="2026-01-01", value=0.95) + pia.bend_point_multipliers.first.update(start="2026-01-01", value=1.15) + cola = root.gov.contrib.ssa.benefit_levers.cola + cola.pp_change.update(start="2026-01-01", value=0.01) + cola.start_year.update(start="2026-01-01", value=2026) + return root + + +class ReportedRetiree: + ids = ("retiree",) + + def __init__(self, annual_reported): + self.reads = [] + values = { + "age": 63, + "birth_year": 1963, + "social_security_retirement_reported": float(annual_reported), + "social_security_retirement_base": float(annual_reported), + "social_security_retirement_source": SocialSecurityBenefitSource.reported, + "social_security_retirement_worker_eligibility_year": E, + "social_security_retirement_worker_id": "", + "social_security_worker_computation_year": 0, + "social_security_retirement_entitlement_year": 2025, + "social_security_retirement_entitlement_month": 1, + "social_security_retirement_first_cola_year": 2026, + "social_security_retirement_first_cola_month": 1, + "social_security_retirement_claim_age_months": 744, + "social_security_birth_month": 0, + "social_security_birth_day": 0, + "ss_calculator_birth_date": "", + "social_security_survivors_reported": 0, + "social_security_dependents_reported": 0, + "social_security_disability_reported": 0, + # Deliberately implausible final outputs make an accidental read + # of an already-reformed benefit/factor easy to detect. + "social_security_retirement": 1_000_000, + "social_security_retirement_joint_cola_factor": 7.0, + "social_security_retirement_pia_ratio_factor": 9.0, + "social_security_across_the_board_factor": 1.08, + "social_security_retirement_payable_factor": 0.80, + } + self.inputs = {name: np.asarray([value]) for name, value in values.items()} + self.simulation = SimpleNamespace( + tax_benefit_system=SimpleNamespace( + variables={"birth_year": SimpleNamespace(definition_period="year")} + ), + _get_visible_branch_names=lambda: ("default",), + _user_input_keys={("birth_year", "default", PERIOD)}, + ) + + def __call__(self, name, requested_period): + assert requested_period == PERIOD + self.reads.append(name) + return self.inputs[name] + + def set(self, name, value): + self.inputs[name] = np.asarray([value]) + + +def test_adapter_uses_baseline_cola_and_public_formula_applies_each_factor_once( + parameters, +): + events = read_parameter_events(parameters, 2026) + baseline_cola = cola_factor(E, "2026-12", events) + assert baseline_cola == Decimal("1.028") + # A claim at 62 with FRA 67 gives .70. The 2025 benchmark has exactly + # the December 2025 / January 2026 statutory COLA in its 2026 level. + annual = Decimal(12) * Decimal(".70") * OLD_PIA * baseline_cola + assert annual == Decimal("18624.39936") + person = ReportedRetiree(annual) + inferred = reported_pia_ratio_output( + person, PERIOD, parameters, "retirement", "aime" + ) + ratio = reported_pia_ratio_output( + person, PERIOD, parameters, "retirement", "factor" + ) + assert inferred[0] == pytest.approx(5000, abs=0.001) + assert ratio[0] == pytest.approx(float(NEW_PIA / OLD_PIA), abs=1e-7) + assert "social_security_retirement" not in person.reads + assert "social_security_retirement_joint_cola_factor" not in person.reads + assert "social_security_retirement_pia_ratio_factor" not in person.reads + + joint = reported_joint_cola_factor(person, PERIOD, parameters, "retirement") + assert joint[0] == pytest.approx(1.038 / 1.028) + assert joint[0] != 1 + person.set("social_security_retirement_joint_cola_factor", joint[0]) + person.set("social_security_retirement_pia_ratio_factor", ratio[0]) + actual = social_security_retirement.formula(person, PERIOD, parameters) + expected = float(annual) * joint[0] * ratio[0] * 1.08 * 0.80 + assert actual[0] == pytest.approx(expected, rel=1e-12) + + # Change both the configured joint-COLA reform and cached reformed values. + # Neither can enter baseline PIA recovery; the gross amount changes only + # when the public formula applies the new joint-COLA factor. + parameters.gov.contrib.ssa.benefit_levers.cola.pp_change.update( + start="2026-01-01", value=0.02 + ) + person.set("social_security_retirement", actual[0]) + person.set("social_security_retirement_joint_cola_factor", 123.0) + person.set("social_security_retirement_pia_ratio_factor", 456.0) + np.testing.assert_array_equal( + reported_pia_ratio_output(person, PERIOD, parameters, "retirement", "aime"), + inferred, + ) + np.testing.assert_array_equal( + reported_pia_ratio_output(person, PERIOD, parameters, "retirement", "factor"), + ratio, + ) + next_joint = reported_joint_cola_factor(person, PERIOD, parameters, "retirement") + assert next_joint[0] == pytest.approx(1.048 / 1.028) + person.set("social_security_retirement_joint_cola_factor", next_joint[0]) + person.set("social_security_retirement_pia_ratio_factor", ratio[0]) + next_actual = social_security_retirement.formula(person, PERIOD, parameters) + assert next_actual[0] == pytest.approx( + float(annual) * next_joint[0] * ratio[0] * 1.08 * 0.80, rel=1e-12 + ) + assert next_actual[0] / actual[0] == pytest.approx(1.048 / 1.038) diff --git a/policyengine_us/tests/unit/test_ss_population_disability.py b/policyengine_us/tests/unit/test_ss_population_disability.py new file mode 100644 index 00000000000..12d00d77f30 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_population_disability.py @@ -0,0 +1,188 @@ +"""Disability proxy gates using native parameters without constructing a US model.""" + +from fractions import Fraction +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +from policyengine_core.parameters import ParameterNode +from policyengine_core.periods import period + +from policyengine_us.tools.social_security.population import ( + _disability_proxy_year, + _record_basis, +) +from policyengine_us.tools.social_security_benefit_levers import ( + SocialSecurityBenefitSource, +) + + +PARAMETER_ROOT = Path(__file__).resolve().parents[2] / "parameters" +PERIOD = period(2026) + + +@pytest.fixture +def parameters(): + # Load the authored defaults into real Core nodes. Only the three small + # subtrees used by the adapter are needed; no country model is built. + root = ParameterNode(data={}) + for path in ( + "gov.contrib.ssa.pia", + "gov.contrib.congress.s770.section_2", + "gov.contrib.ssa.benefit_levers.pia_ratio", + ): + parent = root + parts = path.split(".") + for name in parts[:-1]: + if name not in parent.children: + full_name = ".".join(filter(None, (parent.name, name))) + parent.add_child(name, ParameterNode(full_name, data={})) + parent = getattr(parent, name) + parent.add_child( + parts[-1], + ParameterNode( + path, + directory_path=str(PARAMETER_ROOT.joinpath(*parts)), + ), + ) + return root + + +class DisabilityInputs: + ids = ("recipient",) + + def __init__(self, **inputs): + values = { + "age": 45, + "birth_year": 1981, + "social_security_disability_reported": 12000, + "social_security_disability_source": SocialSecurityBenefitSource.reported, + "social_security_disability_worker_eligibility_year": 0, + "social_security_disability_worker_id": "", + "social_security_worker_computation_year": 0, + "social_security_disability_entitlement_year": 0, + **inputs, + } + self.inputs = {name: np.asarray([value]) for name, value in values.items()} + self.simulation = SimpleNamespace( + tax_benefit_system=SimpleNamespace( + variables={"birth_year": SimpleNamespace(definition_period="year")} + ), + _get_visible_branch_names=lambda: ("default",), + _user_input_keys=set(), + ) + + def __call__(self, name, requested_period): + assert requested_period == PERIOD + return self.inputs[name] + + +@pytest.mark.parametrize("recompute_existing", [False, True]) +def test_missing_onset_proxy_precedes_factor_start_not_enablement_timestamp( + parameters, recompute_existing +): + pia = parameters.gov.contrib.ssa.pia + # Inline YAML reform values begin in 2000; the formula starts in 2026. + pia.in_effect.update(start="2000-01-01", value=True) + pia.recompute_existing.update(start="2000-01-01", value=recompute_existing) + pia.factors.first.update(start="2000-01-01", value=0.95) + pia.factors.start_year.update(start="2000-01-01", value=2026) + + result = _record_basis(DisabilityInputs(), PERIOD, parameters, "disability") + + assert result["eligibility_year"][0] == 2025 + assert result["claiming_adjustment"][0] == Fraction(1) + assert result["proxy"][0] + assert not result["unmodeled"][0] + + +def test_enabled_identity_path_does_not_supply_a_reform_start(parameters): + parameters.gov.contrib.ssa.pia.in_effect.update(start="2000-01-01", value=True) + + assert _disability_proxy_year(parameters, 2026, "pre_reform") == 2025 + assert _disability_proxy_year(parameters, 2035, "pre_reform") == 2034 + + +def test_bend_point_change_uses_its_effective_cohort(parameters): + pia = parameters.gov.contrib.ssa.pia + pia.in_effect.update(start="2000-01-01", value=True) + pia.bend_point_multipliers.first.update(start="2000-01-01", value=1.15) + pia.effective_year.update(start="2000-01-01", value=2024) + + assert _disability_proxy_year(parameters, 2026, "pre_reform") == 2023 + + +def test_disabled_interval_postpones_the_first_operative_change(parameters): + pia = parameters.gov.contrib.ssa.pia + pia.in_effect.update(start="2000-01-01", value=True) + pia.in_effect.update(start="2024-01-01", value=False) + pia.in_effect.update(start="2026-01-01", value=True) + pia.factors.first.update(start="2000-01-01", value=0.95) + pia.factors.start_year.update(start="2000-01-01", value=2024) + + assert _disability_proxy_year(parameters, 2026, "pre_reform") == 2025 + + +def test_s770_start_is_2026_even_when_the_switch_was_enabled_earlier(parameters): + parameters.gov.contrib.congress.s770.section_2.in_effect.update( + start="2000-01-01", value=True + ) + + assert _disability_proxy_year(parameters, 2025, "pre_reform") == 2024 + assert _disability_proxy_year(parameters, 2030, "pre_reform") == 2025 + + +def test_future_formula_change_does_not_backdate_the_proxy(parameters): + pia = parameters.gov.contrib.ssa.pia + pia.in_effect.update(start="2000-01-01", value=True) + pia.factors.first.update(start="2000-01-01", value=0.95) + pia.factors.start_year.update(start="2000-01-01", value=2032) + + assert _disability_proxy_year(parameters, 2026, "pre_reform") == 2025 + + +def test_indexing_identity_anchor_is_still_the_start_of_its_path(parameters): + pia = parameters.gov.contrib.ssa.pia + pia.in_effect.update(start="2000-01-01", value=True) + pia.full_price_indexing.in_effect.update(start="2000-01-01", value=True) + pia.full_price_indexing.start_year.update(start="2000-01-01", value=2024) + pia.full_price_indexing.real_wage_scale.update(start="2024-01-01", value=1) + + assert _disability_proxy_year(parameters, 2026, "pre_reform") == 2023 + + +def test_current_year_sensitivity_bypasses_formula_path_reads(): + assert _disability_proxy_year(None, 2026, "current_year") == 2026 + + +def test_unknown_disability_proxy_is_rejected(): + with pytest.raises(ValueError, match="Choose pre_reform or current_year"): + _disability_proxy_year(None, 2026, "unknown") + + +def test_supplied_worker_year_precedes_entitlement_and_current_year_proxy(parameters): + policy = parameters.gov.contrib.ssa.benefit_levers.pia_ratio + policy.disability_eligibility_proxy.update( + start="1983-01-01", value=["current_year"] + ) + person = DisabilityInputs( + social_security_disability_worker_eligibility_year=2018, + social_security_disability_entitlement_year=2020, + ) + + result = _record_basis(person, PERIOD, parameters, "disability") + + assert result["eligibility_year"][0] == 2018 + assert result["claiming_adjustment"][0] == Fraction(1) + assert not result["proxy"][0] + + +def test_supplied_entitlement_year_precedes_missing_onset_proxy(parameters): + person = DisabilityInputs(social_security_disability_entitlement_year=2020) + + result = _record_basis(person, PERIOD, parameters, "disability") + + assert result["eligibility_year"][0] == 2020 + assert result["claiming_adjustment"][0] == Fraction(1) + assert not result["proxy"][0] diff --git a/policyengine_us/tests/unit/test_ss_population_retirement.py b/policyengine_us/tests/unit/test_ss_population_retirement.py new file mode 100644 index 00000000000..2f97709e5ef --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_population_retirement.py @@ -0,0 +1,205 @@ +"""Retirement population record resolution without constructing a US model. + +Only Person input arrays, stored-input metadata and a selector snapshot are +supplied here. The production adapter resolves eligibility and calls the real +shared cohort/claiming rules; no formula, parameter tree or simulation is copied. +""" + +from fractions import Fraction +from types import SimpleNamespace + +import numpy as np +import pytest +from policyengine_core.periods import period + +from policyengine_us.tools.social_security.population import _record_basis +from policyengine_us.tools.social_security_benefit_levers import ( + SocialSecurityBenefitSource, +) + + +PERIOD = period(2026) + + +class PersonInputs: + ids = ("retiree",) + + def __init__(self, **inputs): + age = inputs.get("age", 62) + values = { + "age": age, + "birth_year": 2026 - age, + "social_security_retirement_reported": 12000, + "social_security_retirement_source": SocialSecurityBenefitSource.reported, + "social_security_retirement_worker_eligibility_year": 0, + "social_security_retirement_worker_id": "", + "social_security_worker_computation_year": 0, + "social_security_retirement_entitlement_year": 0, + "social_security_retirement_entitlement_month": 1, + "social_security_retirement_claim_age_months": 744, + "social_security_birth_month": 0, + "social_security_birth_day": 0, + "ss_calculator_birth_date": "", + "social_security_survivors_reported": 0, + "social_security_dependents_reported": 0, + "social_security_disability_reported": 0, + **inputs, + } + self.inputs = {name: np.asarray([value]) for name, value in values.items()} + # Exercise the actual has_input helper, including the distinction + # between supplied birth year and the ordinary age-derived formula. + self.simulation = SimpleNamespace( + tax_benefit_system=SimpleNamespace( + variables={"birth_year": SimpleNamespace(definition_period="year")} + ), + _get_visible_branch_names=lambda: ("default",), + _user_input_keys={("birth_year", "default", PERIOD)} + if "birth_year" in inputs + else set(), + ) + + def __call__(self, name, requested_period): + assert requested_period == PERIOD + return self.inputs[name] + + +def resolve(*, scenario="supplied_claim_age", **inputs): + parameters = SimpleNamespace( + gov=SimpleNamespace( + contrib=SimpleNamespace( + ssa=SimpleNamespace( + benefit_levers=SimpleNamespace( + pia_ratio=lambda when: SimpleNamespace( + claiming_scenario=[scenario] + ) + ) + ) + ) + ) + ) + return _record_basis(PersonInputs(**inputs), PERIOD, parameters, "retirement") + + +def test_supplied_eligibility_cohort_precedes_an_age_derived_birth_year(): + result = resolve( + age=70, + social_security_retirement_worker_eligibility_year=2015, + ) + assert result["eligibility_year"][0] == 2015 + assert result["claiming_adjustment"][0] == Fraction(3, 4) + assert not result["missing_claim_age"][0] + + +def test_supplied_birth_year_precedes_the_current_age_proxy(): + result = resolve(age=70, birth_year=1964) + assert result["eligibility_year"][0] == 2026 + assert result["claiming_adjustment"][0] == Fraction(7, 10) + + +@pytest.mark.parametrize( + ("scenario", "adjustment"), + [("supplied_claim_age", Fraction(13, 15)), ("fra", Fraction(1))], +) +def test_observed_claim_identifies_one_cohort_across_sensitivity_scenarios( + scenario, adjustment +): + result = resolve( + scenario=scenario, + age=70, + social_security_retirement_entitlement_year=2025, + social_security_retirement_entitlement_month=6, + social_security_retirement_claim_age_months=780, + ) + assert result["eligibility_year"][0] == 2022 + assert result["claiming_adjustment"][0] == adjustment + assert result["proxy"][0] + + +def test_supplied_birth_month_and_day_do_not_identify_a_missing_birth_year(): + result = resolve( + social_security_birth_month=6, + social_security_birth_day=15, + social_security_retirement_claim_age_months=747, + ) + assert result["eligibility_year"][0] == 2026 + assert result["claiming_adjustment"][0] == Fraction(57, 80) + assert result["proxy"][0] + + +def test_entitlement_dates_accept_birth_month_without_day_as_a_proxy(): + result = resolve( + scenario="entitlement_dates", + birth_year=1964, + social_security_birth_month=6, + social_security_retirement_entitlement_year=2026, + social_security_retirement_entitlement_month=9, + ) + assert result["eligibility_year"][0] == 2026 + assert result["claiming_adjustment"][0] == Fraction(57, 80) + assert not result["missing_claim_age"][0] + assert result["proxy"][0] + + +def test_missing_supplied_claim_age_has_no_actuarial_default(): + result = resolve(social_security_retirement_claim_age_months=0) + assert result["claiming_adjustment"][0] == 0 + assert result["missing_claim_age"][0] + assert result["unmodeled"][0] + + +@pytest.mark.parametrize( + ("scenario", "adjustment"), + ( + ("age_62", Fraction(11, 15)), + ("age_65", Fraction(41, 45)), + ("fra", Fraction(1)), + ("age_70", Fraction(97, 75)), + ), +) +def test_imputed_claiming_scenario_is_flagged_with_exact_birth(scenario, adjustment): + # Born in 1956: FRA is 796 months. At 62 the reduction is + # 36/180 + 16/240; at 65 it is 16/180; at 70 the credit is 44/150. + result = resolve( + scenario=scenario, + age=70, + ss_calculator_birth_date="1956-06-15", + social_security_retirement_claim_age_months=0, + ) + assert not result["unmodeled"][0] + assert result["eligibility_year"][0] == 2018 + assert result["claiming_adjustment"][0] == adjustment + assert result["proxy"][0] + + +@pytest.mark.parametrize("scenario", ("supplied_claim_age", "entitlement_dates")) +def test_observed_claiming_sources_keep_exact_birth_identified(scenario): + result = resolve( + scenario=scenario, + age=70, + ss_calculator_birth_date="1956-06-15", + social_security_retirement_claim_age_months=780, + social_security_retirement_entitlement_year=2021, + social_security_retirement_entitlement_month=6, + ) + assert not result["unmodeled"][0] + assert result["eligibility_year"][0] == 2018 + assert result["claiming_adjustment"][0] == Fraction(41, 45) + assert not result["proxy"][0] + + +def test_zero_benefit_bypasses_record_fields_and_parameter_reads(): + def zero_person(name, requested_period): + assert requested_period == PERIOD + return { + "social_security_retirement_reported": np.array([0]), + "social_security_retirement_source": np.array( + [SocialSecurityBenefitSource.reported] + ), + }[name] + + result = _record_basis(zero_person, PERIOD, None, "retirement") + assert result["eligibility_year"][0] == 0 + assert result["claiming_adjustment"][0] == 1 + assert not result["positive"][0] + assert not result["missing_claim_age"][0] + assert not result["unmodeled"][0] diff --git a/policyengine_us/tests/unit/test_ss_population_survivor.py b/policyengine_us/tests/unit/test_ss_population_survivor.py new file mode 100644 index 00000000000..5ab05d52172 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_population_survivor.py @@ -0,0 +1,226 @@ +"""Survivor population scope uses the shared basis without building a model.""" + +from datetime import date +from decimal import Decimal +from types import SimpleNamespace + +import pytest + +from policyengine_us.tools.social_security.pia import FormulaPath, compute_pia +from policyengine_us.tools.social_security.population_survivor import ( + survivor_formula_path, +) + + +PERIOD = SimpleNamespace(start=date(2026, 1, 1)) +DEFAULTS = { + "worker_birth_year": 0, + "death_year": 0, + "authoritative_year": 0, + "prior_disability_eligibility": -1, + "same_year_death_before_62": -1, + "worker_birth_date": "", + "death_date": "", + "basis_provenance": "", + "ordinary_non_windex": False, + "year_only_not_january_1": False, +} +COMPLETE_BASIS = { + "worker_birth_year": 1970, + "death_year": 2025, + "prior_disability_eligibility": 0, + "basis_provenance": "Supplied ordinary worker computation basis", + "ordinary_non_windex": True, + "year_only_not_january_1": True, +} + + +class PersonInputs: + ids = ("survivor", "worker") + + def __init__(self, **values): + self.values = {**DEFAULTS, **values} + + def __call__(self, name, period): + assert period is PERIOD + field = name.removeprefix("ss_calculator_") + # The complete basis belongs to the recipient. The linked row has no + # assertions: the adapter must not combine partial bases across people. + return [self.values[field], DEFAULTS[field]] + + +def parameters(increase): + return SimpleNamespace( + gov=SimpleNamespace( + contrib=SimpleNamespace( + congress=SimpleNamespace( + s770=SimpleNamespace( + section_2=lambda when: SimpleNamespace( + recompute_pre2026_death_before_eligibility=increase + ) + ) + ) + ) + ) + ) + + +def resolve(values=None, *, increase=False, eligibility_year=2025, worker_index=1): + return survivor_formula_path( + PersonInputs(**(COMPLETE_BASIS if values is None else values)), + PERIOD, + parameters(increase), + 0, + FormulaPath(s770=True), + eligibility_year, + worker_index, + ) + + +def test_custom_reform_without_s770_does_not_require_a_deceased_worker_basis(): + path = FormulaPath(factor_values=(".95", ".32", ".15")) + actual, unsupported = survivor_formula_path(None, PERIOD, None, 0, path, 2025, None) + assert actual is path + assert not unsupported + + +@pytest.mark.parametrize( + ("increase", "gain_per_first_bend"), + [(False, Decimal(".05")), (True, Decimal(".1634"))], +) +def test_preeligibility_death_switch_changes_only_bend_increase( + increase, gain_per_first_bend +): + path, unsupported = resolve(increase=increase) + assert not unsupported + assert path.s770 + assert path.s770_increase_first_bend_point is increase + baseline = compute_pia(5000, 2025, bend_points=(960, 5785), round_result=False) + reform = compute_pia( + 5000, + 2025, + path, + 2026, + "retroactive", + bend_points=(960, 5785), + round_result=False, + ) + assert reform - baseline == gain_per_first_bend * 960 + + +def test_post2025_preeligibility_death_receives_both_increases_with_switch_off(): + path, unsupported = resolve( + {**COMPLETE_BASIS, "death_year": 2026}, eligibility_year=2026 + ) + assert not unsupported + assert path.s770 and path.s770_increase_first_bend_point + + +def test_death_after62_is_outside_the_preeligibility_switch(): + path, unsupported = resolve( + {**COMPLETE_BASIS, "worker_birth_year": 1950}, eligibility_year=2012 + ) + assert not unsupported + assert path.s770 and path.s770_increase_first_bend_point + + +def test_authoritative_prior_disability_basis_receives_both_increases(): + path, unsupported = resolve( + { + **COMPLETE_BASIS, + "prior_disability_eligibility": 1, + "authoritative_year": 2020, + }, + eligibility_year=2020, + ) + assert not unsupported + assert path.s770 and path.s770_increase_first_bend_point + + +def test_exact_january1_birth_uses_previous_attainment_year(): + path, unsupported = resolve( + { + **COMPLETE_BASIS, + "worker_birth_year": 1964, + "worker_birth_date": "1964-01-01", + "death_year": 2026, + "death_date": "2026-01-01", + "year_only_not_january_1": False, + }, + eligibility_year=2025, + ) + assert not unsupported + assert path.s770_increase_first_bend_point + + +@pytest.mark.parametrize( + "missing", + [ + {"worker_birth_year": 0}, + {"death_year": 0}, + {"prior_disability_eligibility": -1}, + {"basis_provenance": ""}, + {"ordinary_non_windex": False}, + {"year_only_not_january_1": False}, + {"prior_disability_eligibility": 1, "authoritative_year": 0}, + ], +) +def test_unavailable_basis_is_flagged_without_inference(missing): + path, unsupported = resolve({**COMPLETE_BASIS, **missing}) + assert unsupported + assert path.s770 and path.s770_increase_first_bend_point + + +def test_completely_absent_basis_is_unmodeled(): + _, unsupported = resolve({}) + assert unsupported + + +def test_sameyear_death_without_dates_or_assertion_is_unmodeled(): + _, unsupported = resolve({**COMPLETE_BASIS, "worker_birth_year": 1963}) + assert unsupported + + +@pytest.mark.parametrize(("before62", "increase"), [(0, True), (1, False)]) +def test_sameyear_explicit_assertion_resolves_the_shared_basis(before62, increase): + path, unsupported = resolve( + { + **COMPLETE_BASIS, + "worker_birth_year": 1963, + "same_year_death_before_62": before62, + } + ) + assert not unsupported + assert path.s770_increase_first_bend_point is increase + + +@pytest.mark.parametrize( + "invalid", + [ + {"worker_birth_year": -1}, + {"death_year": -1}, + {"authoritative_year": -1}, + {"prior_disability_eligibility": 2}, + {"same_year_death_before_62": 2}, + {"worker_birth_date": "invalid"}, + {"death_date": "2025-02-30"}, + {"worker_birth_date": "1971-01-02"}, + {"death_date": "2024-01-02"}, + {"worker_birth_year": 1990, "death_year": 1980}, + {"death_year": 2027}, + ], +) +def test_explicit_invalid_basis_is_rejected(invalid): + with pytest.raises(ValueError): + resolve({**COMPLETE_BASIS, **invalid}) + + +def test_survivor_benchmark_must_match_supplied_linked_computation_year(): + with pytest.raises(ValueError, match="computation years disagree"): + resolve(eligibility_year=2024) + + +@pytest.mark.parametrize("worker_index", [None, -1, 2]) +def test_invalid_worker_link_is_rejected(worker_index): + with pytest.raises(ValueError, match="existing person"): + resolve(worker_index=worker_index) diff --git a/policyengine_us/tests/unit/test_ss_worker_basis.py b/policyengine_us/tests/unit/test_ss_worker_basis.py new file mode 100644 index 00000000000..b87629fd0f9 --- /dev/null +++ b/policyengine_us/tests/unit/test_ss_worker_basis.py @@ -0,0 +1,136 @@ +"""Worker-link resolution without simulation or country-model fixtures.""" + +import numpy as np +import pytest + +from policyengine_us.tools.social_security.records import resolve_worker_basis + + +class Records: + ids = ("recipient", "worker") + + def __init__(self, component, **inputs): + self.inputs = { + f"social_security_{component}_worker_eligibility_year": np.array([0, 0]), + f"social_security_{component}_worker_id": np.array(["", ""], dtype=object), + "social_security_worker_computation_year": np.array([0, 0]), + "social_security_retirement_worker_eligibility_year": np.array([0, 0]), + } + self.inputs.update({key: np.asarray(value) for key, value in inputs.items()}) + + def __call__(self, variable, period): + return self.inputs[variable] + + +@pytest.mark.parametrize("component", ["retirement", "disability"]) +def test_unlinked_own_record_uses_shared_year_without_mutating_inputs(component): + key = f"social_security_{component}_worker_eligibility_year" + records = Records(component, social_security_worker_computation_year=[2020, 2021]) + years, linked, indices = resolve_worker_basis( + records, 2026, component, [True, True] + ) + np.testing.assert_array_equal(years, [2020, 2021]) + np.testing.assert_array_equal(linked, [False, False]) + np.testing.assert_array_equal(indices, [0, 1]) + np.testing.assert_array_equal(records.inputs[key], [0, 0]) + + +def test_supplied_component_year_precedes_own_shared_year(): + records = Records( + "retirement", + social_security_retirement_worker_eligibility_year=[2018, 0], + social_security_worker_computation_year=[2020, 2021], + ) + years, _, _ = resolve_worker_basis(records, 2026, "retirement", [True, True]) + np.testing.assert_array_equal(years, [2018, 2021]) + + +def test_link_reads_workers_same_component_year_before_shared_year(): + records = Records( + "survivors", + social_security_survivors_worker_id=["worker", ""], + social_security_survivors_worker_eligibility_year=[0, 2018], + social_security_worker_computation_year=[0, 2020], + ) + years, linked, indices = resolve_worker_basis( + records, 2026, "survivors", [True, False] + ) + assert years[0] == 2018 + np.testing.assert_array_equal(linked, [True, False]) + np.testing.assert_array_equal(indices, [1, 1]) + + +@pytest.mark.parametrize("component", ["survivors", "dependents"]) +def test_auxiliary_link_can_use_workers_retirement_year(component): + records = Records( + component, + **{ + f"social_security_{component}_worker_id": ["worker", ""], + "social_security_retirement_worker_eligibility_year": [0, 2019], + }, + ) + years, linked, _ = resolve_worker_basis(records, 2026, component, [True, False]) + assert years[0] == 2019 + assert linked[0] + + +def test_auxiliary_without_worker_link_does_not_borrow_own_shared_year(): + records = Records("survivors", social_security_worker_computation_year=[2020, 2021]) + years, linked, _ = resolve_worker_basis(records, 2026, "survivors", [True, True]) + np.testing.assert_array_equal(years, [0, 0]) + assert not np.any(linked) + + +def test_link_without_worker_year_stays_missing_for_callers_to_handle(): + records = Records("retirement", social_security_retirement_worker_id=["worker", ""]) + years, linked, _ = resolve_worker_basis(records, 2026, "retirement", [True, False]) + assert years[0] == 0 + assert linked[0] + + +def test_links_read_supplied_worker_basis_without_order_dependent_link_chasing(): + records = Records( + "survivors", + social_security_survivors_worker_id=["worker", "recipient", ""], + social_security_survivors_worker_eligibility_year=[0, 0, 2019], + social_security_worker_computation_year=[0, 0, 0], + social_security_retirement_worker_eligibility_year=[0, 0, 0], + ) + records.ids = ("recipient", "linked", "worker") + years, linked, _ = resolve_worker_basis( + records, 2026, "survivors", [True, True, False] + ) + np.testing.assert_array_equal(years, [2019, 0, 2019]) + np.testing.assert_array_equal(linked, [True, True, False]) + + +def test_recipient_supplied_year_can_identify_linked_worker_basis(): + records = Records( + "survivors", + social_security_survivors_worker_id=["worker", ""], + social_security_survivors_worker_eligibility_year=[2018, 0], + ) + years, linked, _ = resolve_worker_basis(records, 2026, "survivors", [True, False]) + assert years[0] == 2018 + assert linked[0] + + +def test_conflicting_supplied_and_linked_years_rejected(): + records = Records( + "survivors", + social_security_survivors_worker_id=["worker", ""], + social_security_survivors_worker_eligibility_year=[2018, 2019], + ) + with pytest.raises(ValueError, match="Conflicting"): + resolve_worker_basis(records, 2026, "survivors", [True, False]) + + +def test_unknown_worker_id_rejected_only_for_active_records(): + records = Records( + "retirement", social_security_retirement_worker_id=["missing", ""] + ) + with pytest.raises(ValueError, match="does not identify a person"): + resolve_worker_basis(records, 2026, "retirement", [True, False]) + years, linked, _ = resolve_worker_basis(records, 2026, "retirement", [False, True]) + assert years[0] == 0 + assert not np.any(linked) diff --git a/policyengine_us/tools/social_security/pia_ratio.py b/policyengine_us/tools/social_security/pia_ratio.py new file mode 100644 index 00000000000..d4c704e6d47 --- /dev/null +++ b/policyengine_us/tools/social_security/pia_ratio.py @@ -0,0 +1,208 @@ +"""Infer an eligibility-cohort AIME from an annual reported benefit. + +This is the population, proportional-change approximation. It does not recover +an unidentified monthly payment history or undo earnings-test withholding, +dual entitlement or a binding family maximum. Callers must identify the worker, +claiming adjustment and auxiliary share before using it. Both the reform and +baseline PIAs use the shared household calculator's formula and dime rounding. +The inverse always uses the supplied *unreformed* historical scale. +""" + +from dataclasses import dataclass +from decimal import Decimal, ROUND_CEILING, localcontext +from fractions import Fraction + +from policyengine_us.tools.social_security.pia import ( + DEFAULT_FACTORS, + FormulaPath, + Number, + compute_pia, + has_active_reform, + inverse_pia, + inverse_unrounded, +) + + +@dataclass(frozen=True) +class RatioResult: + """A ratio and monthly-dollar inference diagnostics for one annual record. + + ``aime`` is the baseline inverse at the observed eligibility PIA. For an + exact eligibility PIA it is the lower endpoint of the half-open interval + ``[aime_lower, aime_upper)``. Survey observations instead give a conditional + interval, with additional measurement error explicitly flagged. Endpoints + are represented as 50-digit Decimals rounded toward positive infinity; + exact rational endpoints are retained internally for forward computation. + Zero and unsupported pre-1983 records have factor 1 and zero diagnostics. + """ + + factor: Decimal + aime: Decimal + aime_lower: Decimal + aime_upper: Decimal + pre1983: bool = False + measurement_uncertainty: bool = False + + +def _number(value: Number, name: str) -> Fraction: + try: + if isinstance(value, bool): + raise ValueError + if isinstance(value, Fraction): + return value + result = Decimal(str(value)) + if not result.is_finite(): + raise ValueError + return Fraction(result) + except (ValueError, TypeError, ArithmeticError) as error: + raise ValueError(f"{name} must be a finite number") from error + + +def _decimal(value: Fraction, *, ceiling: bool = False) -> Decimal: + with localcontext() as context: + context.prec = 50 + if ceiling: + context.rounding = ROUND_CEILING + return Decimal(value.numerator) / Decimal(value.denominator) + + +def compute_pia_ratio( + annual_benefit: Number, + eligibility_year: int, + formula_path: FormulaPath | None = None, + payment_year: int | None = None, + recompute_mode: str = "cohort", + *, + bend_points: tuple[Number, Number], + claiming_adjustment: Number = 1, + auxiliary_share: Number = 1, + cola_factor: Number = 1, + factors: tuple[Number, Number, Number] = DEFAULT_FACTORS, + exact_eligibility_pia: bool = False, + annual_rounding_bound: Number = 0, +) -> RatioResult: + """Return ``P_new(A, E; t) / P_old(A, E)`` for a reported annual benefit. + + The inferred monthly eligibility PIA is + ``annual_benefit / (12 * claiming_adjustment * auxiliary_share * cola_factor)``. + ``cola_factor`` must be the unreformed accumulation from eligibility, and + ``annual_benefit`` must be the unreformed reported amount. A supplied + composite survivor ratio belongs in either adjustment argument, once. + + Set ``exact_eligibility_pia=True`` only when the backwards-derived amount + is an exact dime-rounded eligibility PIA. Otherwise the point estimate is + the unrounded baseline inverse, and the interval extends through the + inverse at ``z + .10``. It is conditional on the observed survey amount; + ``measurement_uncertainty`` remains true even if z happens to be a dime. + An optional nonnegative ``annual_rounding_bound`` expands the annual + observation to ``[max(0, b-bound), b+bound)`` before dividing by 12*a*s*C. + The upper end additionally allows one dime of initial PIA truncation. + This conservative interval does not represent other survey measurement + errors or unidentified monthly payment rounding. It cannot be combined + with the exact-PIA assertion. + + Pre-1983 benchmark records remain unchanged because the historical + upward-rounding method is unsupported. Negative/nonfinite benefits and + nonpositive/nonfinite adjustment, share or COLA inputs are rejected. + """ + benefit = _number(annual_benefit, "annual reported benefit") + if benefit < 0: + raise ValueError("annual reported benefit must be nonnegative") + adjustments = tuple( + _number(value, name) + for value, name in ( + (claiming_adjustment, "claiming adjustment"), + (auxiliary_share, "auxiliary share"), + (cola_factor, "baseline COLA factor"), + ) + ) + if any(value <= 0 for value in adjustments): + raise ValueError( + "claiming adjustment, auxiliary share and COLA factor must be positive" + ) + bound = _number(annual_rounding_bound, "annual rounding bound") + if bound < 0: + raise ValueError("annual rounding bound must be nonnegative") + if exact_eligibility_pia and bound: + raise ValueError("exact eligibility PIA cannot have an annual rounding bound") + if isinstance(eligibility_year, bool) or not isinstance(eligibility_year, int): + raise ValueError("eligibility year must be an integer calendar year") + neutral = RatioResult(Decimal(1), Decimal(0), Decimal(0), Decimal(0)) + if eligibility_year < 1983: + return RatioResult( + neutral.factor, + neutral.aime, + neutral.aime_lower, + neutral.aime_upper, + pre1983=True, + ) + if benefit == 0: + return neutral + + divisor = 12 * adjustments[0] * adjustments[1] * adjustments[2] + pia = benefit / divisor + if exact_eligibility_pia or (pia * 10).denominator == 1: + interval = inverse_pia( + pia, eligibility_year, bend_points=bend_points, factors=factors + ) + point = interval.exact_lower + lower, upper = interval.exact_lower, interval.exact_upper + else: + point = inverse_unrounded( + pia, eligibility_year, bend_points=bend_points, factors=factors + ) + lower = point + upper = inverse_unrounded( + pia + Fraction(1, 10), + eligibility_year, + bend_points=bend_points, + factors=factors, + ) + if bound: + lower = inverse_unrounded( + max(0, benefit - bound) / divisor, + eligibility_year, + bend_points=bend_points, + factors=factors, + ) + upper = inverse_unrounded( + (benefit + bound) / divisor + Fraction(1, 10), + eligibility_year, + bend_points=bend_points, + factors=factors, + ) + + active = has_active_reform( + formula_path, eligibility_year, payment_year, recompute_mode + ) + old = compute_pia( + point, + eligibility_year, + payment_year=payment_year, + recompute_mode=recompute_mode, + bend_points=bend_points, + factors=factors, + round_result=True, + ) + new = compute_pia( + point, + eligibility_year, + formula_path, + payment_year, + recompute_mode, + bend_points=bend_points, + factors=factors, + round_result=True, + ) + if (active or old != new) and old <= 0: + raise ValueError("inferred baseline PIA must be positive for a PIA ratio") + factor = Decimal(1) + if old != new: + factor = _decimal(Fraction(new) / Fraction(old)) + return RatioResult( + factor=factor, + aime=_decimal(point, ceiling=True), + aime_lower=_decimal(lower, ceiling=True), + aime_upper=_decimal(upper, ceiling=True), + measurement_uncertainty=not exact_eligibility_pia, + ) diff --git a/policyengine_us/tools/social_security/population.py b/policyengine_us/tools/social_security/population.py new file mode 100644 index 00000000000..c836118f3c9 --- /dev/null +++ b/policyengine_us/tools/social_security/population.py @@ -0,0 +1,391 @@ +"""Reported-benefit PIA inference for independent annual cross-sections. + +Only unreformed reported inputs enter this adapter. Calculated sources already +read the shared formula path themselves and bypass the population ratio. Annual +survey amounts are treated as full-year gross benefits; their PIA intervals are +conditional diagnostics, not identified monthly-payment histories. +""" + +from datetime import date +from decimal import Decimal +from fractions import Fraction + +import numpy as np + +from policyengine_us.tools.social_security.cola import ( + cola_factor, + read_parameter_events, +) +from policyengine_us.tools.social_security.household import ( + attainment_month, + claiming_factor, + full_retirement_age_months, + month_string, +) +from policyengine_us.tools.social_security.parameters import ( + bend_points_at, + formula_path_at, +) +from policyengine_us.tools.social_security.pia import has_active_reform +from policyengine_us.tools.social_security.pia_ratio import compute_pia_ratio +from policyengine_us.tools.social_security.records import resolve_worker_basis +from policyengine_us.tools.social_security.population_survivor import ( + survivor_formula_path, +) +from policyengine_us.tools.social_security_benefit_levers import ( + SocialSecurityBenefitSource, +) +from policyengine_us.tools.period_branch import has_input + + +SCENARIOS = { + "supplied_claim_age", + "entitlement_dates", + "age_62", + "age_65", + "fra", + "age_70", +} + + +def _choice(value): + # Core supports lists but not scalar strings. Retain readable named + # scenarios without adding another policy-selection mechanism. + if isinstance(value, (list, tuple, np.ndarray)) and len(value) == 1: + value = value[0] + if not isinstance(value, str): + raise ValueError("A PIA-ratio scenario must contain exactly one name") + return value + + +def _disability_proxy_year(parameters, year, scenario): + if scenario == "current_year": + return year + if scenario != "pre_reform": + raise ValueError("Choose pre_reform or current_year for disability eligibility") + # This is an exposure sensitivity, not an observed onset distribution. + # Enablement can precede separate factor/bend/indexing gates (inline YAML + # reforms enable switches from 2000). Use the shared path to find the first + # operative formula change, rather than an enabled but neutral formula. + for candidate in range(1983, year + 1): + path, mode = formula_path_at(parameters, candidate, candidate) + if has_active_reform(path, candidate, candidate, mode): + return max(1983, candidate - 1) + return max(1983, year - 1) + + +def _record_basis(person, period, parameters, component): + """Resolve supplied record information before any labelled age proxy.""" + prefix = f"social_security_{component}" + year = period.start.year + amounts = person(f"{prefix}_reported", period) + reported = ( + person(f"{prefix}_source", period) == SocialSecurityBenefitSource.reported + ) + if np.any(reported & (~np.isfinite(amounts) | (amounts < 0))): + raise ValueError( + "Reported Social Security benefits must be finite and nonnegative" + ) + positive = reported & (amounts > 0) + n = len(amounts) + if not np.any(positive): + return dict( + amounts=amounts, + positive=positive, + eligibility_year=np.zeros(n, dtype=int), + claiming_adjustment=[Fraction(1)] * n, + shares=[Fraction(1)] * n, + own_record_retired_worker=np.zeros(n, dtype=bool), + missing_claim_age=np.zeros(n, dtype=bool), + pre_1983=np.zeros(n, dtype=bool), + unmodeled=np.zeros(n, dtype=bool), + proxy=np.zeros(n, dtype=bool), + measurement_uncertainty=np.zeros(n, dtype=bool), + survivor_paths={}, + ) + age = person("age", period) + birth_years = person("birth_year", period) + supplied_birth_year = has_input(person.simulation, "birth_year", period) + years, linked, worker_indices = resolve_worker_basis( + person, period, component, positive + ) + supplied = years > 0 + entitlement = person(f"{prefix}_entitlement_year", period) + proxy = np.zeros(n, dtype=bool) + missing_claim_age = np.zeros(n, dtype=bool) + unmodeled = np.zeros(n, dtype=bool) + own = np.zeros(n, dtype=bool) + adjustments = [Fraction(1)] * n + shares = [Fraction(1)] * n + policy = parameters.gov.contrib.ssa.benefit_levers.pia_ratio(period.start) + + if component == "retirement": + for i in np.flatnonzero(positive): + adjustments[i] = Fraction( + 0 + ) # Unknown until the selected scenario is usable. + # An entitlement date is a claim date, not age-62 eligibility. In its + # absence the scoring year's age rebuilds the cohort independently. + # A link without an explicit computation basis is not permission to + # infer an unrelated worker's record from the recipient's age. + unmodeled |= linked & ~supplied + own = positive & (age >= 62) & (worker_indices == np.arange(n)) + mixed = np.zeros(n, dtype=bool) + for other in ("survivors", "dependents", "disability"): + mixed |= person(f"social_security_{other}_reported", period) > 0 + own &= ~mixed + # Mixed recipient components do not establish a separable own-worker + # PIA in the presence of dual entitlement or partial-year conversions. + unmodeled |= positive & mixed + scenario = _choice(policy.claiming_scenario) + if scenario not in SCENARIOS: + raise ValueError("Unsupported PIA-ratio claiming_scenario") + months = person("social_security_retirement_claim_age_months", period) + birth_months = person("social_security_birth_month", period) + birth_days = person("social_security_birth_day", period) + birth_dates = person("ss_calculator_birth_date", period) + entitlement_months = person(f"{prefix}_entitlement_month", period) + births = [None] * n + exact_births = np.zeros(n, dtype=bool) + known_birth_months = np.zeros(n, dtype=bool) + for i in np.flatnonzero(positive): + j = worker_indices[i] + known_birth_year = supplied_birth_year and birth_years[j] > 0 + if not known_birth_year and not str(birth_dates[j]): + # A supplied month/day does not make an age-, eligibility- or + # claim-derived birth year an observed complete date. + proxy[i] = True + cohort = ( + int(birth_years[j]) + if known_birth_year + else int(years[i]) - 62 + if supplied[i] + else year - int(age[j]) + ) + observed_birth_month = None + if ( + not known_birth_year + and not str(birth_dates[j]) + and entitlement[i] > 0 + and 744 <= months[i] <= 840 + ): + if not 1 <= entitlement_months[i] <= 12: + raise ValueError( + "Social Security entitlement month must lie in [1, 12]" + ) + observed_birth_month = ( + int(entitlement[i]) * 12 + + int(entitlement_months[i]) + - 1 + - int(months[i]) + ) + cohort = observed_birth_month // 12 + birth = date(cohort, 1, 2) + exact_birth = False + if str(birth_dates[j]): + birth = date.fromisoformat(str(birth_dates[j])) + exact_birth = True + known_birth_months[i] = True + elif birth_months[j]: + try: + birth = date(cohort, int(birth_months[j]), int(birth_days[j]) or 2) + except ValueError as error: + raise ValueError( + "Invalid Social Security birth month/day" + ) from error + exact_birth = bool(birth_days[j]) + known_birth_months[i] = True + elif observed_birth_month is not None: + birth = date(cohort, observed_birth_month % 12 + 1, 2) + births[i], exact_births[i] = birth, exact_birth + if not supplied[i] and not linked[i]: + if exact_birth: + years[i] = attainment_month(birth, 744) // 12 + elif known_birth_year: + years[i] = birth.year + 62 + proxy[i] = True + elif observed_birth_month is not None: + # Only observed claim age identifies this cohort. Changing + # the sensitivity for a must not change the worker's E. + years[i] = (observed_birth_month + 744) // 12 + proxy[i] = True + if not years[i]: + years[i] = year - age[i] + 62 + proxy[i] = True + for i in np.flatnonzero(positive & (years >= 1983)): + birth = births[i] + exact_birth = exact_births[i] + # A sensitivity assumes claiming history even with an exact birth. + if not exact_birth or scenario in ("age_62", "age_65", "fra", "age_70"): + proxy[i] = True + if scenario == "entitlement_dates": + if not known_birth_months[i] or entitlement[i] <= 0: + missing_claim_age[i] = True + continue + if not 1 <= entitlement_months[i] <= 12: + raise ValueError( + "Social Security entitlement month must lie in [1, 12]" + ) + claim = int(entitlement[i]) * 12 + int(entitlement_months[i]) - 1 + claim_months = claim - attainment_month(birth, 0) + else: + claim_months = { + "supplied_claim_age": int(months[i]), + "age_62": 744, + "age_65": 780, + "fra": full_retirement_age_months(birth), + "age_70": 840, + }[scenario] + claim = attainment_month(birth, claim_months) + if not 744 <= claim_months <= 840: + missing_claim_age[i] = True + continue + # The annual in-payment approximation uses fully credited DRCs. + # Partial entitlement years/deferred same-year credits require the + # household monthly ledger rather than annual survey inversion. + adjustments[i] = claiming_factor( + birth, month_string(claim), month_string((claim // 12 + 1) * 12) + ) + elif component == "disability": + years = np.where(years > 0, years, entitlement) + missing = positive & (years <= 0) + if np.any(missing): + years[missing] = _disability_proxy_year( + parameters, year, _choice(policy.disability_eligibility_proxy) + ) + proxy |= missing + else: + # A worker link/year and an identified composite a*s are both needed. + # No auxiliary inversion ever substitutes the recipient's age. + ratio = person(f"{prefix}_worker_benefit_ratio", period) + identified = person(f"{prefix}_worker_ratio_identified", period) + if np.any(positive & identified & (~np.isfinite(ratio) | (ratio <= 0))): + raise ValueError( + "An identified worker benefit ratio must be positive and finite" + ) + unmodeled |= ~(linked & supplied & identified) + for i in np.flatnonzero(positive & ~unmodeled): + shares[i] = Fraction(str(ratio[i])) + + if np.any(positive & supplied & (years > year)): + raise ValueError("Social Security eligibility cannot follow the scoring year") + unmodeled |= positive & (years > year) + pre1983 = positive & (years > 0) & (years < 1983) + unmodeled = positive & (unmodeled | missing_claim_age | pre1983) + survivor_paths = {} + if component == "survivors": + for i in np.flatnonzero(positive & ~unmodeled): + path, mode = formula_path_at(parameters, int(years[i]), year) + path, unsupported = survivor_formula_path( + person, period, parameters, i, path, int(years[i]), worker_indices[i] + ) + unmodeled[i] |= unsupported + survivor_paths[i] = (path, mode) + return dict( + amounts=amounts, + positive=positive, + eligibility_year=np.where(positive, years, 0), + claiming_adjustment=adjustments, + shares=shares, + own_record_retired_worker=own, + missing_claim_age=positive & missing_claim_age, + pre_1983=pre1983, + unmodeled=unmodeled, + proxy=positive & proxy, + measurement_uncertainty=positive & ~unmodeled, + survivor_paths=survivor_paths, + ) + + +def reported_pia_ratio_output(person, period, parameters, component, field): + """Named Person diagnostics and factor using one unreformed source basis.""" + if field == "factor": + year = period.start.year + custom = any( + entry.value and int(entry.instant_str[:4]) <= year + for entry in parameters.gov.contrib.ssa.pia.in_effect.values_list + ) + s770 = parameters.gov.contrib.congress.s770.section_2(period.start).in_effect + if not custom and not s770: + # Neutral current law must not require newly supplied record fields. + # This also preserves legacy amounts for records outside C's scope. + amount = person(f"social_security_{component}_reported", period) + reported = ( + person(f"social_security_{component}_source", period) + == SocialSecurityBenefitSource.reported + ) + if np.any(reported & (~np.isfinite(amount) | (amount < 0))): + raise ValueError( + "Reported Social Security benefits must be finite and nonnegative" + ) + return np.ones(len(amount)) + basis = _record_basis(person, period, parameters, component) + if field in basis and field != "claiming_adjustment": + return basis[field] + if field == "claiming_adjustment": + return np.array([float(value) for value in basis[field]]) + if field not in ("factor", "aime", "aime_lower", "aime_upper"): + raise ValueError("Unknown reported PIA ratio output") + result = ( + np.ones(len(basis["amounts"])) + if field == "factor" + else np.zeros(len(basis["amounts"])) + ) + supported = basis["positive"] & ~basis["unmodeled"] + if not np.any(supported): + return result + year = period.start.year + paths = {} + for eligibility in np.unique(basis["eligibility_year"][supported]): + eligibility = int(eligibility) + path, mode = formula_path_at(parameters, eligibility, year) + paths[eligibility] = (path, mode) + if field == "factor" and not any( + has_active_reform(path, eligibility, year, mode) + for eligibility, (path, mode) in paths.items() + ): + return result + events = read_parameter_events(parameters, year) + cola_by_year = {} + bends_by_year = {} + bound = parameters.gov.contrib.ssa.benefit_levers.pia_ratio( + period.start + ).annual_rounding_bound + for i in np.flatnonzero(supported): + eligibility = int(basis["eligibility_year"][i]) + if eligibility not in cola_by_year: + cola_by_year[eligibility] = cola_factor(eligibility, f"{year}-12", events) + bends_by_year[eligibility] = bend_points_at(parameters, eligibility) + path, mode = basis["survivor_paths"].get(i, paths[eligibility]) + # The shortest decimal representation of Core's float32 input restores + # ordinary supplied cents without expanding binary noise into a PIA dime. + amount = Decimal(str(basis["amounts"][i])) + inference = compute_pia_ratio( + amount, + eligibility, + path, + year, + mode, + bend_points=bends_by_year[eligibility], + claiming_adjustment=basis["claiming_adjustment"][i], + auxiliary_share=basis["shares"][i], + cola_factor=cola_by_year[eligibility], + annual_rounding_bound=bound, + ) + value = getattr(inference, field) + if field == "factor": + value = np.float32(value) + else: + # A nearest-rounded float32 inverse may lie below its exact + # endpoint and lose a PIA dime. Preserve a representable ceiling. + represented = np.float32(value) + if ( + np.isfinite(represented) + and Decimal.from_float(float(represented)) < value + ): + represented = np.nextafter(represented, np.float32(np.inf)) + value = represented + if not np.isfinite(float(value)): + raise ValueError("PIA ratio output exceeds the supported numeric range") + result[i] = float(value) + return result diff --git a/policyengine_us/tools/social_security/population_survivor.py b/policyengine_us/tools/social_security/population_survivor.py new file mode 100644 index 00000000000..508d67b0bf9 --- /dev/null +++ b/policyengine_us/tools/social_security/population_survivor.py @@ -0,0 +1,138 @@ +"""S.770 scope for an identified reported survivor worker record. + +This adapter reuses the supplied-record calculator's SurvivorBasis. Calculator +basis fields belong to the survivor recipient, just as on the household route; +a worker link does not authorize filling missing fields from recipient age or +combining partial bases from different people. Missing basis information leaves +the population amount unmodeled under S.770. Explicit contradictions are errors. +""" + +from dataclasses import replace +from datetime import date + +from policyengine_us.tools.social_security.household import ( + SurvivorBasis, + attainment_month, +) + + +def survivor_formula_path( + person, period, parameters, index, path, eligibility_year, worker_index +): + """Return (path, unsupported) with the shared survivor bend-point reading. + + Only an active S.770 path needs these additional death/computation fields. + Other custom formula reforms keep the caller's identified generic composite + worker-benefit ratio route. For S.770, the same ss_calculator_* basis inputs + used by 4a-1 must be supplied on the recipient record. Their benchmark must + equal the independently supplied linked worker eligibility/computation year. + + The pre-2026 death-before-any-eligibility switch changes only the first + bend-point increase. S.770's 95% first factor remains active either way. + A worker with authoritative prior-disability eligibility receives both + increases, as on the household route, regardless of this switch. + """ + if not path.s770: + return path, False + if worker_index is None or not 0 <= int(worker_index) < len(person.ids): + raise ValueError("Survivor worker link must identify an existing person") + + def value(name): + scalar = person("ss_calculator_" + name, period)[index] + return scalar.item() if hasattr(scalar, "item") else scalar + + def optional_date(name): + raw = str(value(name)) + return date.fromisoformat(raw) if raw else None + + worker_birth_year = int(value("worker_birth_year")) + death_year = int(value("death_year")) + authoritative_year = int(value("authoritative_year")) + prior_disability = int(value("prior_disability_eligibility")) + same_year = int(value("same_year_death_before_62")) + worker_birth_date = optional_date("worker_birth_date") + death_date = optional_date("death_date") + provenance = str(value("basis_provenance")) + ordinary_non_windex = bool(value("ordinary_non_windex")) + not_january_1 = bool(value("year_only_not_january_1")) + + if worker_birth_year < 0 or death_year < 0 or authoritative_year < 0: + raise ValueError( + "Survivor computation years must be positive or zero if missing" + ) + if prior_disability not in (-1, 0, 1): + raise ValueError("Survivor prior-disability assertion must be -1, 0 or 1") + if same_year not in (-1, 0, 1): + raise ValueError("Survivor same-year death assertion must be -1, 0 or 1") + if ( + worker_birth_date + and worker_birth_year + and worker_birth_date.year != worker_birth_year + ): + raise ValueError("Worker birth date and birth year disagree") + if death_date and death_year and death_date.year != death_year: + raise ValueError("Death date and death year disagree") + if death_date and worker_birth_date and death_date < worker_birth_date: + raise ValueError("Death cannot precede birth") + if worker_birth_year and death_year and death_year < worker_birth_year: + raise ValueError("Death cannot precede birth") + if death_year > period.start.year: + raise ValueError( + "A reported survivor's worker death cannot follow the scoring year" + ) + + # Default sentinels express unavailable information, not an assertion of a + # complete supported basis. Do not reject ordinary population artifacts just + # because they lack the household route's detailed scope declarations. + if ( + not worker_birth_year + or not death_year + or prior_disability == -1 + or not provenance.strip() + or not ordinary_non_windex + or (prior_disability == 1 and not authoritative_year) + or (not worker_birth_date and not not_january_1 and not authoritative_year) + ): + return path, True + + # A same-year death needs dates or the explicit assertion required by the + # shared service. With no such observation, this is missing identification. + year62 = ( + attainment_month(worker_birth_date, 744) // 12 + if worker_birth_date + else worker_birth_year + 62 + ) + if ( + death_year == year62 + and not (worker_birth_date and death_date) + and same_year == -1 + ): + return path, True + + basis = SurvivorBasis( + worker_birth_year=worker_birth_year, + death_year=death_year, + provenance=provenance, + worker_birth_date=worker_birth_date, + death_date=death_date, + authoritative_year=authoritative_year or None, + prior_disability_eligibility=bool(prior_disability), + ordinary_non_windex=ordinary_non_windex, + year_only_not_january_1=not_january_1, + same_year_death_before_62=None if same_year == -1 else bool(same_year), + ) + benchmark, before62 = basis.benchmark() + if benchmark != int(eligibility_year): + raise ValueError("Survivor basis and linked worker computation years disagree") + + restricted = ( + before62 and death_year < 2026 and not basis.prior_disability_eligibility + ) + if restricted: + increase = bool( + parameters.gov.contrib.congress.s770.section_2( + period.start + ).recompute_pre2026_death_before_eligibility + ) + return replace(path, s770_increase_first_bend_point=increase), False + return path, False diff --git a/policyengine_us/tools/social_security/records.py b/policyengine_us/tools/social_security/records.py new file mode 100644 index 00000000000..563d01322ad --- /dev/null +++ b/policyengine_us/tools/social_security/records.py @@ -0,0 +1,52 @@ +"""Resolve explicitly supplied worker computation bases without age proxies.""" + +import numpy as np + + +def resolve_worker_basis(person, period, component, active): + """Return computation years, link flags and worker indices for each person. + + A recipient's component-specific computation year takes precedence over a + linked worker's supplied basis. If both are present they must agree. Links + read the worker's same-component year, then the shared computation-year + input; auxiliary links may also use the worker's retirement-specific year. + Unlinked own retirement/disability records may use their own shared year. + + Missing years remain zero. In particular, no recipient-age proxy is applied + here. Invalid links and conflicting years are rejected only for ``active`` + records, so an unused source cannot disturb a selected calculation route. + The returned arrays are private copies and never mutate stored inputs. + """ + prefix = f"social_security_{component}" + supplied_years = np.asarray( + person(f"{prefix}_worker_eligibility_year", period), dtype=int + ) + years = supplied_years.copy() + shared = person("social_security_worker_computation_year", period) + identifiers = person(f"{prefix}_worker_id", period) + known = {str(identifier): i for i, identifier in enumerate(person.ids)} + linked = np.zeros(len(years), dtype=bool) + worker_indices = np.arange(len(years), dtype=int) + auxiliary_years = ( + person("social_security_retirement_worker_eligibility_year", period) + if component in ("survivors", "dependents") + else None + ) + for i in np.flatnonzero(active): + identifier = str(identifiers[i]) + if not identifier: + if component in ("retirement", "disability"): + years[i] = years[i] or shared[i] + continue + if identifier not in known: + raise ValueError("Social Security worker_id does not identify a person") + j = known[identifier] + worker_indices[i] = j + record_year = supplied_years[j] or shared[j] + if not record_year and auxiliary_years is not None: + record_year = auxiliary_years[j] + if years[i] and record_year and years[i] != record_year: + raise ValueError("Conflicting supplied and linked worker computation years") + years[i] = years[i] or record_year + linked[i] = True + return years, linked, worker_indices diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_birth_day.py b/policyengine_us/variables/gov/ssa/ss/social_security_birth_day.py new file mode 100644 index 00000000000..35d657ab8f5 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_birth_day.py @@ -0,0 +1,10 @@ +from policyengine_us.model_api import * + + +class social_security_birth_day(Variable): + value_type = int + default_value = 0 + entity = Person + definition_period = YEAR + label = "Social Security birth day" + documentation = "Optional supplied day of birth for the entitlement_dates population claiming scenario. Zero means unavailable; with a known birth month, day 2 is the explicit approximation and raises the proxy flag. No formula or uprating. Exact dates preserve SSA age attainment on the day before a birthday, including January 1 births." diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_birth_month.py b/policyengine_us/variables/gov/ssa/ss/social_security_birth_month.py new file mode 100644 index 00000000000..a5e5a10314c --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_birth_month.py @@ -0,0 +1,10 @@ +from policyengine_us.model_api import * + + +class social_security_birth_month(Variable): + value_type = int + default_value = 0 + entity = Person + definition_period = YEAR + label = "Social Security birth month" + documentation = "Supplied birth month, 1 through 12, for the entitlement_dates population claiming scenario. Zero means unavailable. No formula or uprating. Used with the birth year; a missing birth day uses the explicit day-2 convention and raises the proxy flag. An exact ss_calculator_birth_date can supply the complete date instead." diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_inferred_aime.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_inferred_aime.py new file mode 100644 index 00000000000..277ff53e080 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_inferred_aime.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_dependents_inferred_aime(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Social Security dependents inferred eligibility-year monthly aime" + documentation = "Baseline AIME inverse at the observed unreformed amount, using baseline claiming, identified worker ratio and baseline COLAs. This is the lower endpoint for an exact dime-rounded eligibility PIA. A configured annual measurement bound widens the interval without changing this point estimate. Zero denotes no inference; inspect the diagnostic flags." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "dependents", "aime" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_inferred_aime_lower_bound.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_inferred_aime_lower_bound.py new file mode 100644 index 00000000000..517693f6588 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_inferred_aime_lower_bound.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_dependents_inferred_aime_lower_bound(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = ( + "Social Security dependents inferred eligibility-year monthly aime lower bound" + ) + documentation = "Inclusive lower endpoint of the conditional baseline-inversion interval in monthly AIME dollars. This is a PIA-rounding interval, not a confidence interval for survey measurement error." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "dependents", "aime_lower" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_inferred_aime_upper_bound.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_inferred_aime_upper_bound.py new file mode 100644 index 00000000000..975cde010e3 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_inferred_aime_upper_bound.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_dependents_inferred_aime_upper_bound(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = ( + "Social Security dependents inferred eligibility-year monthly aime upper bound" + ) + documentation = "Exclusive upper endpoint of the conditional baseline-inversion interval in monthly AIME dollars. Any configured annual rounding bound is propagated backward; additional survey measurement error is not quantified." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "dependents", "aime_upper" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_claiming_adjustment.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_claiming_adjustment.py new file mode 100644 index 00000000000..6180e5efe62 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_claiming_adjustment.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_dependents_pia_ratio_claiming_adjustment(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = "/1" + label = "Social Security dependents pia-ratio claiming adjustment" + documentation = "Worker claiming adjustment used in the baseline inversion under the explicitly selected scenario. Disability uses one. An auxiliary composite worker-benefit ratio already includes its claiming adjustment and is not divided twice." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "dependents", "claiming_adjustment" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_eligibility_year.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_eligibility_year.py new file mode 100644 index 00000000000..da8dc1e8ff6 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_eligibility_year.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_dependents_pia_ratio_eligibility_year(Variable): + value_type = int + entity = Person + definition_period = YEAR + unit = "year" + label = "Social Security dependents pia-ratio eligibility year" + documentation = "Underlying worker eligibility or computation year used by the population PIA-ratio calculation. Supplied record and worker links precede a labelled proxy; zero denotes an unavailable basis. Rebuilt independently in every scoring year." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "dependents", "eligibility_year" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_factor.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_factor.py index 034b50023c9..fa929073ed8 100644 --- a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_factor.py +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_factor.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output class social_security_dependents_pia_ratio_factor(Variable): @@ -8,8 +9,12 @@ class social_security_dependents_pia_ratio_factor(Variable): unit = "/1" label = "Social Security dependents PIA ratio factor" documentation = ( - "Neutral extension point for a subsequent reported-benefit PIA reform." + "Eligibility-year reformed-to-baseline PIA ratio for the reported source. " + "Calculated sources bypass this factor. Unsupported or unmodeled records " + "remain at one and expose separate diagnostic indicators." ) def formula(person, period, parameters): - return 1 + return reported_pia_ratio_output( + person, period, parameters, "dependents", "factor" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_measurement_uncertainty.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_measurement_uncertainty.py new file mode 100644 index 00000000000..f56d024044a --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_measurement_uncertainty.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_dependents_pia_ratio_measurement_uncertainty(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security dependents pia-ratio measurement uncertainty indicator" + documentation = "The inference uses a reported annual benefit whose payment or survey measurement basis is not an exact eligibility-level PIA. Its conditional rounding interval does not quantify all measurement uncertainty." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "dependents", "measurement_uncertainty" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_measurement_uncertainty_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_measurement_uncertainty_count.py new file mode 100644 index 00000000000..a4fdedb5ecf --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_measurement_uncertainty_count.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class social_security_dependents_pia_ratio_measurement_uncertainty_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security dependents PIA-ratio measurement uncertainty count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members( + "social_security_dependents_pia_ratio_measurement_uncertainty", period + ) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_missing_claim_age.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_missing_claim_age.py new file mode 100644 index 00000000000..b4a9dec1ce8 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_missing_claim_age.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_dependents_pia_ratio_missing_claim_age(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security dependents pia-ratio missing or unusable claiming data" + documentation = "Positive reported amount lacks usable data required by the selected claiming scenario. Under supplied_claim_age, missing, before-age-62 or after-age-70 values leave the factor at one without switching scenarios." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "dependents", "missing_claim_age" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_missing_claim_age_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_missing_claim_age_count.py new file mode 100644 index 00000000000..a606d474d9d --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_missing_claim_age_count.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class social_security_dependents_pia_ratio_missing_claim_age_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security dependents PIA-ratio missing claim age count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members( + "social_security_dependents_pia_ratio_missing_claim_age", period + ) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_own_record_retired_worker.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_own_record_retired_worker.py new file mode 100644 index 00000000000..4d48333822e --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_own_record_retired_worker.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_dependents_pia_ratio_own_record_retired_worker(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security dependents pia-ratio own-record retired-worker indicator" + documentation = "Whether this component is identified as an own-record retired-worker amount for the population PIA-ratio approximation. This indicator does not establish insured status or validate survey benefit classification." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "dependents", "own_record_retired_worker" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_pre_1983.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_pre_1983.py new file mode 100644 index 00000000000..db62d691ec5 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_pre_1983.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_dependents_pia_ratio_pre_1983(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security dependents pia-ratio unsupported pre-1983 benchmark" + documentation = "Positive reported amount has a benchmark year before 1983. The population PIA-ratio factor remains one because the supported inverse assumes initial PIA dime truncation." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "dependents", "pre_1983" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_pre_1983_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_pre_1983_count.py new file mode 100644 index 00000000000..752ae974041 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_pre_1983_count.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class social_security_dependents_pia_ratio_pre_1983_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security dependents PIA-ratio pre 1983 count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members("social_security_dependents_pia_ratio_pre_1983", period) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_proxy.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_proxy.py new file mode 100644 index 00000000000..859df7dce22 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_proxy.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_dependents_pia_ratio_proxy(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security dependents pia-ratio proxy assumption indicator" + documentation = "The population PIA-ratio diagnostics use an eligibility, claiming, or birth-date approximation rather than a fully supplied record basis. This flag distinguishes sensitivity assumptions from observed history." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "dependents", "proxy" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_proxy_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_proxy_count.py new file mode 100644 index 00000000000..dd3c9e297c9 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_proxy_count.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class social_security_dependents_pia_ratio_proxy_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security dependents PIA-ratio proxy count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members("social_security_dependents_pia_ratio_proxy", period) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_unmodeled.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_unmodeled.py new file mode 100644 index 00000000000..98a3fb42047 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_unmodeled.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_dependents_pia_ratio_unmodeled(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security dependents pia-ratio unmodeled amount indicator" + documentation = "Positive reported amount lacks an identified worker basis, composite ratio, or another input required for a supported inversion. The factor stays one; the amount is unmodeled for this lever." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "dependents", "unmodeled" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_unmodeled_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_unmodeled_count.py new file mode 100644 index 00000000000..972d70a04d0 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_pia_ratio_unmodeled_count.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class social_security_dependents_pia_ratio_unmodeled_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security dependents PIA-ratio unmodeled count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members("social_security_dependents_pia_ratio_unmodeled", period) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_worker_benefit_ratio.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_worker_benefit_ratio.py new file mode 100644 index 00000000000..3179d5bd3fb --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_worker_benefit_ratio.py @@ -0,0 +1,10 @@ +from policyengine_us.model_api import * + + +class social_security_dependents_worker_benefit_ratio(Variable): + value_type = float + default_value = 0 + entity = Person + definition_period = YEAR + label = "Social Security dependents composite worker-benefit ratio" + documentation = "Supplied positive composite ratio of this recipient benefit to the underlying worker PIA, including the worker claiming adjustment and all applicable recipient share reductions. The baseline inversion divides by this composite once. Zero means unavailable. Requires an identified worker basis and the separate worker_ratio_identified assertion; no uprating." diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_dependents_worker_ratio_identified.py b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_worker_ratio_identified.py new file mode 100644 index 00000000000..aceeb2f3dae --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_dependents_worker_ratio_identified.py @@ -0,0 +1,10 @@ +from policyengine_us.model_api import * + + +class social_security_dependents_worker_ratio_identified(Variable): + value_type = bool + default_value = False + entity = Person + definition_period = YEAR + label = "Social Security dependents worker ratio identified" + documentation = "Assert that the supplied composite worker-benefit ratio identifies a single underlying worker PIA, including all reductions and credits, with no unaccounted dual entitlement, binding family cap, or earnings-test withholding. False leaves this auxiliary amount unmodeled for the population PIA-ratio lever." diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_inferred_aime.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_inferred_aime.py new file mode 100644 index 00000000000..89b039e9553 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_inferred_aime.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_disability_inferred_aime(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Social Security disability inferred eligibility-year monthly aime" + documentation = "Baseline AIME inverse at the observed unreformed amount, using baseline claiming, identified worker ratio and baseline COLAs. This is the lower endpoint for an exact dime-rounded eligibility PIA. A configured annual measurement bound widens the interval without changing this point estimate. Zero denotes no inference; inspect the diagnostic flags." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "disability", "aime" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_inferred_aime_lower_bound.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_inferred_aime_lower_bound.py new file mode 100644 index 00000000000..950ffa6f2cf --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_inferred_aime_lower_bound.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_disability_inferred_aime_lower_bound(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = ( + "Social Security disability inferred eligibility-year monthly aime lower bound" + ) + documentation = "Inclusive lower endpoint of the conditional baseline-inversion interval in monthly AIME dollars. This is a PIA-rounding interval, not a confidence interval for survey measurement error." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "disability", "aime_lower" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_inferred_aime_upper_bound.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_inferred_aime_upper_bound.py new file mode 100644 index 00000000000..09074047fb3 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_inferred_aime_upper_bound.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_disability_inferred_aime_upper_bound(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = ( + "Social Security disability inferred eligibility-year monthly aime upper bound" + ) + documentation = "Exclusive upper endpoint of the conditional baseline-inversion interval in monthly AIME dollars. Any configured annual rounding bound is propagated backward; additional survey measurement error is not quantified." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "disability", "aime_upper" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_claiming_adjustment.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_claiming_adjustment.py new file mode 100644 index 00000000000..ad13035523e --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_claiming_adjustment.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_disability_pia_ratio_claiming_adjustment(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = "/1" + label = "Social Security disability pia-ratio claiming adjustment" + documentation = "Worker claiming adjustment used in the baseline inversion under the explicitly selected scenario. Disability uses one. An auxiliary composite worker-benefit ratio already includes its claiming adjustment and is not divided twice." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "disability", "claiming_adjustment" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_eligibility_year.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_eligibility_year.py new file mode 100644 index 00000000000..6fb406f9a8a --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_eligibility_year.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_disability_pia_ratio_eligibility_year(Variable): + value_type = int + entity = Person + definition_period = YEAR + unit = "year" + label = "Social Security disability pia-ratio eligibility year" + documentation = "Underlying worker eligibility or computation year used by the population PIA-ratio calculation. Supplied record and worker links precede a labelled proxy; zero denotes an unavailable basis. Rebuilt independently in every scoring year." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "disability", "eligibility_year" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_factor.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_factor.py index e807b247634..1aa38473f6c 100644 --- a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_factor.py +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_factor.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output class social_security_disability_pia_ratio_factor(Variable): @@ -8,8 +9,12 @@ class social_security_disability_pia_ratio_factor(Variable): unit = "/1" label = "Social Security disability PIA ratio factor" documentation = ( - "Neutral extension point for a subsequent reported-benefit PIA reform." + "Eligibility-year reformed-to-baseline PIA ratio for the reported source. " + "Calculated sources bypass this factor. Unsupported or unmodeled records " + "remain at one and expose separate diagnostic indicators." ) def formula(person, period, parameters): - return 1 + return reported_pia_ratio_output( + person, period, parameters, "disability", "factor" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_measurement_uncertainty.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_measurement_uncertainty.py new file mode 100644 index 00000000000..8f74d309533 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_measurement_uncertainty.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_disability_pia_ratio_measurement_uncertainty(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security disability pia-ratio measurement uncertainty indicator" + documentation = "The inference uses a reported annual benefit whose payment or survey measurement basis is not an exact eligibility-level PIA. Its conditional rounding interval does not quantify all measurement uncertainty." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "disability", "measurement_uncertainty" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_measurement_uncertainty_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_measurement_uncertainty_count.py new file mode 100644 index 00000000000..103de0176c6 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_measurement_uncertainty_count.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class social_security_disability_pia_ratio_measurement_uncertainty_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security disability PIA-ratio measurement uncertainty count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members( + "social_security_disability_pia_ratio_measurement_uncertainty", period + ) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_missing_claim_age.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_missing_claim_age.py new file mode 100644 index 00000000000..e0030573a7e --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_missing_claim_age.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_disability_pia_ratio_missing_claim_age(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security disability pia-ratio missing or unusable claiming data" + documentation = "Positive reported amount lacks usable data required by the selected claiming scenario. Under supplied_claim_age, missing, before-age-62 or after-age-70 values leave the factor at one without switching scenarios." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "disability", "missing_claim_age" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_missing_claim_age_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_missing_claim_age_count.py new file mode 100644 index 00000000000..36cc4c11331 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_missing_claim_age_count.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class social_security_disability_pia_ratio_missing_claim_age_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security disability PIA-ratio missing claim age count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members( + "social_security_disability_pia_ratio_missing_claim_age", period + ) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_own_record_retired_worker.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_own_record_retired_worker.py new file mode 100644 index 00000000000..71ca9ff4095 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_own_record_retired_worker.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_disability_pia_ratio_own_record_retired_worker(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security disability pia-ratio own-record retired-worker indicator" + documentation = "Whether this component is identified as an own-record retired-worker amount for the population PIA-ratio approximation. This indicator does not establish insured status or validate survey benefit classification." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "disability", "own_record_retired_worker" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_pre_1983.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_pre_1983.py new file mode 100644 index 00000000000..9152a1fb02e --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_pre_1983.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_disability_pia_ratio_pre_1983(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security disability pia-ratio unsupported pre-1983 benchmark" + documentation = "Positive reported amount has a benchmark year before 1983. The population PIA-ratio factor remains one because the supported inverse assumes initial PIA dime truncation." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "disability", "pre_1983" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_pre_1983_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_pre_1983_count.py new file mode 100644 index 00000000000..03be6d99f71 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_pre_1983_count.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class social_security_disability_pia_ratio_pre_1983_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security disability PIA-ratio pre 1983 count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members("social_security_disability_pia_ratio_pre_1983", period) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_proxy.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_proxy.py new file mode 100644 index 00000000000..fe78b9b41a8 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_proxy.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_disability_pia_ratio_proxy(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security disability pia-ratio proxy assumption indicator" + documentation = "The population PIA-ratio diagnostics use an eligibility, claiming, or birth-date approximation rather than a fully supplied record basis. This flag distinguishes sensitivity assumptions from observed history." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "disability", "proxy" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_proxy_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_proxy_count.py new file mode 100644 index 00000000000..ae60d24a22f --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_proxy_count.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class social_security_disability_pia_ratio_proxy_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security disability PIA-ratio proxy count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members("social_security_disability_pia_ratio_proxy", period) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_unmodeled.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_unmodeled.py new file mode 100644 index 00000000000..34e72d5b920 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_unmodeled.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_disability_pia_ratio_unmodeled(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security disability pia-ratio unmodeled amount indicator" + documentation = "Positive reported amount lacks an identified worker basis, composite ratio, or another input required for a supported inversion. The factor stays one; the amount is unmodeled for this lever." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "disability", "unmodeled" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_unmodeled_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_unmodeled_count.py new file mode 100644 index 00000000000..41730bebe0d --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_disability_pia_ratio_unmodeled_count.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class social_security_disability_pia_ratio_unmodeled_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security disability PIA-ratio unmodeled count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members("social_security_disability_pia_ratio_unmodeled", period) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_claim_age_months.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_claim_age_months.py new file mode 100644 index 00000000000..feb7dd21d5c --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_claim_age_months.py @@ -0,0 +1,10 @@ +from policyengine_us.model_api import * + + +class social_security_retirement_claim_age_months(Variable): + value_type = int + default_value = 0 + entity = Person + definition_period = YEAR + label = "Retirement age in whole months at first entitlement" + documentation = "Pure supplied retirement claim age in whole months: 62 years 3 months is 747. Zero means unavailable. No formula or uprating; projected dataset columns must supply the value separately for each scoring year. The selected supplied_claim_age scenario accepts ages 62 through 70." diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_inferred_aime.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_inferred_aime.py new file mode 100644 index 00000000000..b97c8000a0f --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_inferred_aime.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_retirement_inferred_aime(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Social Security retirement inferred eligibility-year monthly aime" + documentation = "Baseline AIME inverse at the observed unreformed amount, using baseline claiming, identified worker ratio and baseline COLAs. This is the lower endpoint for an exact dime-rounded eligibility PIA. A configured annual measurement bound widens the interval without changing this point estimate. Zero denotes no inference; inspect the diagnostic flags." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "retirement", "aime" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_inferred_aime_lower_bound.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_inferred_aime_lower_bound.py new file mode 100644 index 00000000000..8024a5dc5f1 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_inferred_aime_lower_bound.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_retirement_inferred_aime_lower_bound(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = ( + "Social Security retirement inferred eligibility-year monthly aime lower bound" + ) + documentation = "Inclusive lower endpoint of the conditional baseline-inversion interval in monthly AIME dollars. This is a PIA-rounding interval, not a confidence interval for survey measurement error." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "retirement", "aime_lower" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_inferred_aime_upper_bound.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_inferred_aime_upper_bound.py new file mode 100644 index 00000000000..b3f488826fc --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_inferred_aime_upper_bound.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_retirement_inferred_aime_upper_bound(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = ( + "Social Security retirement inferred eligibility-year monthly aime upper bound" + ) + documentation = "Exclusive upper endpoint of the conditional baseline-inversion interval in monthly AIME dollars. Any configured annual rounding bound is propagated backward; additional survey measurement error is not quantified." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "retirement", "aime_upper" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_claiming_adjustment.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_claiming_adjustment.py new file mode 100644 index 00000000000..95cb42328c0 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_claiming_adjustment.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_retirement_pia_ratio_claiming_adjustment(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = "/1" + label = "Social Security retirement pia-ratio claiming adjustment" + documentation = "Worker claiming adjustment from the explicitly selected scenario. Zero is an unavailable diagnostic sentinel for a positive retirement record until a usable adjustment is computed, including missing claiming data or an unsupported pre-1983 benchmark; inspect the flags rather than interpreting it as FRA or a benefit cut. A zero-benefit record keeps neutral adjustment one." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "retirement", "claiming_adjustment" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_eligibility_year.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_eligibility_year.py new file mode 100644 index 00000000000..9ba947d99df --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_eligibility_year.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_retirement_pia_ratio_eligibility_year(Variable): + value_type = int + entity = Person + definition_period = YEAR + unit = "year" + label = "Social Security retirement pia-ratio eligibility year" + documentation = "Underlying worker eligibility or computation year used by the population PIA-ratio calculation. Supplied record and worker links precede a labelled proxy; zero denotes an unavailable basis. Rebuilt independently in every scoring year." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "retirement", "eligibility_year" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_factor.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_factor.py index 50375513765..02563922313 100644 --- a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_factor.py +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_factor.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output class social_security_retirement_pia_ratio_factor(Variable): @@ -8,8 +9,12 @@ class social_security_retirement_pia_ratio_factor(Variable): unit = "/1" label = "Social Security retirement PIA ratio factor" documentation = ( - "Neutral extension point for a subsequent reported-benefit PIA reform." + "Eligibility-year reformed-to-baseline PIA ratio for the reported source. " + "Calculated sources bypass this factor. Unsupported or unmodeled records " + "remain at one and expose separate diagnostic indicators." ) def formula(person, period, parameters): - return 1 + return reported_pia_ratio_output( + person, period, parameters, "retirement", "factor" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_measurement_uncertainty.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_measurement_uncertainty.py new file mode 100644 index 00000000000..dcafd307c92 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_measurement_uncertainty.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_retirement_pia_ratio_measurement_uncertainty(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security retirement pia-ratio measurement uncertainty indicator" + documentation = "The inference uses a reported annual benefit whose payment or survey measurement basis is not an exact eligibility-level PIA. Its conditional rounding interval does not quantify all measurement uncertainty." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "retirement", "measurement_uncertainty" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_measurement_uncertainty_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_measurement_uncertainty_count.py new file mode 100644 index 00000000000..3d31811cfac --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_measurement_uncertainty_count.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class social_security_retirement_pia_ratio_measurement_uncertainty_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security retirement PIA-ratio measurement uncertainty count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members( + "social_security_retirement_pia_ratio_measurement_uncertainty", period + ) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_missing_claim_age.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_missing_claim_age.py new file mode 100644 index 00000000000..1e7b78c1e37 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_missing_claim_age.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_retirement_pia_ratio_missing_claim_age(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security retirement pia-ratio missing or unusable claiming data" + documentation = "Positive reported amount lacks usable data required by the selected claiming scenario. Under supplied_claim_age, missing, before-age-62 or after-age-70 values leave the factor at one without switching scenarios." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "retirement", "missing_claim_age" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_missing_claim_age_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_missing_claim_age_count.py new file mode 100644 index 00000000000..d044866684d --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_missing_claim_age_count.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class social_security_retirement_pia_ratio_missing_claim_age_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security retirement PIA-ratio missing claim age count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members( + "social_security_retirement_pia_ratio_missing_claim_age", period + ) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_own_record_retired_worker.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_own_record_retired_worker.py new file mode 100644 index 00000000000..9ffb037810d --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_own_record_retired_worker.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_retirement_pia_ratio_own_record_retired_worker(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security retirement pia-ratio own-record retired-worker indicator" + documentation = "Whether this component is identified as an own-record retired-worker amount for the population PIA-ratio approximation. This indicator does not establish insured status or validate survey benefit classification." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "retirement", "own_record_retired_worker" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_pre_1983.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_pre_1983.py new file mode 100644 index 00000000000..a20624f5477 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_pre_1983.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_retirement_pia_ratio_pre_1983(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security retirement pia-ratio unsupported pre-1983 benchmark" + documentation = "Positive reported amount has a benchmark year before 1983. The population PIA-ratio factor remains one because the supported inverse assumes initial PIA dime truncation." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "retirement", "pre_1983" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_pre_1983_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_pre_1983_count.py new file mode 100644 index 00000000000..bd55768e3f7 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_pre_1983_count.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class social_security_retirement_pia_ratio_pre_1983_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security retirement PIA-ratio pre 1983 count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members("social_security_retirement_pia_ratio_pre_1983", period) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_proxy.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_proxy.py new file mode 100644 index 00000000000..6753193f36a --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_proxy.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_retirement_pia_ratio_proxy(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security retirement pia-ratio proxy assumption indicator" + documentation = "The population PIA-ratio diagnostics use an eligibility, claiming, or birth-date approximation rather than a fully supplied record basis. This flag distinguishes sensitivity assumptions from observed history." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "retirement", "proxy" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_proxy_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_proxy_count.py new file mode 100644 index 00000000000..828d0fac5d9 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_proxy_count.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class social_security_retirement_pia_ratio_proxy_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security retirement PIA-ratio proxy count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members("social_security_retirement_pia_ratio_proxy", period) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_unmodeled.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_unmodeled.py new file mode 100644 index 00000000000..e1badf1a94a --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_unmodeled.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_retirement_pia_ratio_unmodeled(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security retirement pia-ratio unmodeled amount indicator" + documentation = "Positive reported amount lacks an identified worker basis, composite ratio, or another input required for a supported inversion. The factor stays one; the amount is unmodeled for this lever." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "retirement", "unmodeled" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_unmodeled_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_unmodeled_count.py new file mode 100644 index 00000000000..7f97b131663 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_retirement_pia_ratio_unmodeled_count.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class social_security_retirement_pia_ratio_unmodeled_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security retirement PIA-ratio unmodeled count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members("social_security_retirement_pia_ratio_unmodeled", period) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_inferred_aime.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_inferred_aime.py new file mode 100644 index 00000000000..0486bc7ea9d --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_inferred_aime.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_survivors_inferred_aime(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = "Social Security survivors inferred eligibility-year monthly aime" + documentation = "Baseline AIME inverse at the observed unreformed amount, using baseline claiming, identified worker ratio and baseline COLAs. This is the lower endpoint for an exact dime-rounded eligibility PIA. A configured annual measurement bound widens the interval without changing this point estimate. Zero denotes no inference; inspect the diagnostic flags." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "survivors", "aime" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_inferred_aime_lower_bound.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_inferred_aime_lower_bound.py new file mode 100644 index 00000000000..86b66878da9 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_inferred_aime_lower_bound.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_survivors_inferred_aime_lower_bound(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = ( + "Social Security survivors inferred eligibility-year monthly aime lower bound" + ) + documentation = "Inclusive lower endpoint of the conditional baseline-inversion interval in monthly AIME dollars. This is a PIA-rounding interval, not a confidence interval for survey measurement error." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "survivors", "aime_lower" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_inferred_aime_upper_bound.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_inferred_aime_upper_bound.py new file mode 100644 index 00000000000..c6e798a351f --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_inferred_aime_upper_bound.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_survivors_inferred_aime_upper_bound(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = USD + label = ( + "Social Security survivors inferred eligibility-year monthly aime upper bound" + ) + documentation = "Exclusive upper endpoint of the conditional baseline-inversion interval in monthly AIME dollars. Any configured annual rounding bound is propagated backward; additional survey measurement error is not quantified." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "survivors", "aime_upper" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_claiming_adjustment.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_claiming_adjustment.py new file mode 100644 index 00000000000..a350a1d790e --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_claiming_adjustment.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_survivors_pia_ratio_claiming_adjustment(Variable): + value_type = float + entity = Person + definition_period = YEAR + unit = "/1" + label = "Social Security survivors pia-ratio claiming adjustment" + documentation = "Worker claiming adjustment used in the baseline inversion under the explicitly selected scenario. Disability uses one. An auxiliary composite worker-benefit ratio already includes its claiming adjustment and is not divided twice." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "survivors", "claiming_adjustment" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_eligibility_year.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_eligibility_year.py new file mode 100644 index 00000000000..f987b4137d0 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_eligibility_year.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_survivors_pia_ratio_eligibility_year(Variable): + value_type = int + entity = Person + definition_period = YEAR + unit = "year" + label = "Social Security survivors pia-ratio eligibility year" + documentation = "Underlying worker eligibility or computation year used by the population PIA-ratio calculation. Supplied record and worker links precede a labelled proxy; zero denotes an unavailable basis. Rebuilt independently in every scoring year." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "survivors", "eligibility_year" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_factor.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_factor.py index 40be5f4d21d..df21d801594 100644 --- a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_factor.py +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_factor.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output class social_security_survivors_pia_ratio_factor(Variable): @@ -8,8 +9,12 @@ class social_security_survivors_pia_ratio_factor(Variable): unit = "/1" label = "Social Security survivors PIA ratio factor" documentation = ( - "Neutral extension point for a subsequent reported-benefit PIA reform." + "Eligibility-year reformed-to-baseline PIA ratio for the reported source. " + "Calculated sources bypass this factor. Unsupported or unmodeled records " + "remain at one and expose separate diagnostic indicators." ) def formula(person, period, parameters): - return 1 + return reported_pia_ratio_output( + person, period, parameters, "survivors", "factor" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_measurement_uncertainty.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_measurement_uncertainty.py new file mode 100644 index 00000000000..57f5d9485e7 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_measurement_uncertainty.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_survivors_pia_ratio_measurement_uncertainty(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security survivors pia-ratio measurement uncertainty indicator" + documentation = "The inference uses a reported annual benefit whose payment or survey measurement basis is not an exact eligibility-level PIA. Its conditional rounding interval does not quantify all measurement uncertainty." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "survivors", "measurement_uncertainty" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_measurement_uncertainty_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_measurement_uncertainty_count.py new file mode 100644 index 00000000000..78ac1378628 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_measurement_uncertainty_count.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class social_security_survivors_pia_ratio_measurement_uncertainty_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security survivors PIA-ratio measurement uncertainty count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members( + "social_security_survivors_pia_ratio_measurement_uncertainty", period + ) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_missing_claim_age.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_missing_claim_age.py new file mode 100644 index 00000000000..287f56960a4 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_missing_claim_age.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_survivors_pia_ratio_missing_claim_age(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security survivors pia-ratio missing or unusable claiming data" + documentation = "Positive reported amount lacks usable data required by the selected claiming scenario. Under supplied_claim_age, missing, before-age-62 or after-age-70 values leave the factor at one without switching scenarios." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "survivors", "missing_claim_age" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_missing_claim_age_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_missing_claim_age_count.py new file mode 100644 index 00000000000..05d8c3fb79c --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_missing_claim_age_count.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class social_security_survivors_pia_ratio_missing_claim_age_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security survivors PIA-ratio missing claim age count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members( + "social_security_survivors_pia_ratio_missing_claim_age", period + ) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_own_record_retired_worker.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_own_record_retired_worker.py new file mode 100644 index 00000000000..24e310580d5 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_own_record_retired_worker.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_survivors_pia_ratio_own_record_retired_worker(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security survivors pia-ratio own-record retired-worker indicator" + documentation = "Whether this component is identified as an own-record retired-worker amount for the population PIA-ratio approximation. This indicator does not establish insured status or validate survey benefit classification." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "survivors", "own_record_retired_worker" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_pre_1983.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_pre_1983.py new file mode 100644 index 00000000000..04293fc0629 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_pre_1983.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_survivors_pia_ratio_pre_1983(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security survivors pia-ratio unsupported pre-1983 benchmark" + documentation = "Positive reported amount has a benchmark year before 1983. The population PIA-ratio factor remains one because the supported inverse assumes initial PIA dime truncation." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "survivors", "pre_1983" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_pre_1983_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_pre_1983_count.py new file mode 100644 index 00000000000..9fe9c5864b0 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_pre_1983_count.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class social_security_survivors_pia_ratio_pre_1983_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security survivors PIA-ratio pre 1983 count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members("social_security_survivors_pia_ratio_pre_1983", period) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_proxy.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_proxy.py new file mode 100644 index 00000000000..ca2cabb8952 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_proxy.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_survivors_pia_ratio_proxy(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security survivors pia-ratio proxy assumption indicator" + documentation = "The population PIA-ratio diagnostics use an eligibility, claiming, or birth-date approximation rather than a fully supplied record basis. This flag distinguishes sensitivity assumptions from observed history." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "survivors", "proxy" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_proxy_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_proxy_count.py new file mode 100644 index 00000000000..9e27655f647 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_proxy_count.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class social_security_survivors_pia_ratio_proxy_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security survivors PIA-ratio proxy count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members("social_security_survivors_pia_ratio_proxy", period) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_unmodeled.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_unmodeled.py new file mode 100644 index 00000000000..bb389277848 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_unmodeled.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.social_security.population import reported_pia_ratio_output + + +class social_security_survivors_pia_ratio_unmodeled(Variable): + value_type = bool + entity = Person + definition_period = YEAR + label = "Social Security survivors pia-ratio unmodeled amount indicator" + documentation = "Positive reported amount lacks an identified worker basis, composite ratio, or another input required for a supported inversion. The factor stays one; the amount is unmodeled for this lever." + + def formula(person, period, parameters): + return reported_pia_ratio_output( + person, period, parameters, "survivors", "unmodeled" + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_unmodeled_count.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_unmodeled_count.py new file mode 100644 index 00000000000..fa6ac2eb767 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_pia_ratio_unmodeled_count.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class social_security_survivors_pia_ratio_unmodeled_count(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "person" + label = "Social Security survivors PIA-ratio unmodeled count" + documentation = ( + "Number of household members with the corresponding Person indicator. " + "Population aggregation uses household weights once. If supplied Person " + "weights differ within a household, use the Person indicator and its own " + "Person weights for the authoritative weighted count." + ) + + def formula(household, period, parameters): + return household.sum( + household.members("social_security_survivors_pia_ratio_unmodeled", period) + ) diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_worker_benefit_ratio.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_worker_benefit_ratio.py new file mode 100644 index 00000000000..6a682c8e8ff --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_worker_benefit_ratio.py @@ -0,0 +1,10 @@ +from policyengine_us.model_api import * + + +class social_security_survivors_worker_benefit_ratio(Variable): + value_type = float + default_value = 0 + entity = Person + definition_period = YEAR + label = "Social Security survivors composite worker-benefit ratio" + documentation = "Supplied positive composite ratio of this recipient benefit to the underlying worker PIA, including the worker claiming adjustment and all applicable recipient share reductions. The baseline inversion divides by this composite once. Zero means unavailable. Requires an identified worker basis and the separate worker_ratio_identified assertion; no uprating." diff --git a/policyengine_us/variables/gov/ssa/ss/social_security_survivors_worker_ratio_identified.py b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_worker_ratio_identified.py new file mode 100644 index 00000000000..ea8fef57b5d --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ss/social_security_survivors_worker_ratio_identified.py @@ -0,0 +1,10 @@ +from policyengine_us.model_api import * + + +class social_security_survivors_worker_ratio_identified(Variable): + value_type = bool + default_value = False + entity = Person + definition_period = YEAR + label = "Social Security survivors worker ratio identified" + documentation = "Assert that the supplied composite worker-benefit ratio identifies a single underlying worker PIA, including all reductions and credits, with no unaccounted dual entitlement, binding family cap, or earnings-test withholding. False leaves this auxiliary amount unmodeled for the population PIA-ratio lever."