Skip to content

Seed Alaska's Permanent Fund Dividend take-up and take reported dividends out of miscellaneous_income #1551

Open

Description

@MaxGhenis

Problem

PolicyEngine/policyengine-us#10763 adds takes_up_ak_permanent_fund_dividend_if_eligible, a default-true person input that gates ak_permanent_fund_dividend and ak_energy_relief. Populace doesn't set it, so PolicyEngine pays every Alaska resident who passes the immigration status test: 730,150 people for 2024, against the 624,489 applications Alaska paid (+16.9%).

Populace also keeps reported dividends in miscellaneous_income (#1443), so PolicyEngine counts the dividend twice for people who reported one.

One build stage can fix both: by matching reported amounts to the dividend, it finds who received one and which dollars to take out.

Why this belongs in the data

  • policyengine_core.commons.formulas.random raises "Formula-time randomness is not allowed", so policyengine-us can't draw take-up inside a formula. Its other take-up flags work the same way: they default to true. Of the 17 that Microcosm's take-up contract lists, Microcosm sets 10 and leaves the other 7, CHIP's among them, at that default.
  • Under AS 43.23.005, Alaska also requires an application, residence on the application date and for the whole qualifying year, 72 consecutive hours of presence in the prior two years, physical presence or an allowable absence, and Selective Service compliance. Under (d), it denies the dividend to some people with felony or repeat convictions. Neither policyengine-us nor Populace records these. is_incarcerated records no conviction and doesn't tie to the qualifying year, and Populace doesn't set it.
  • A child born during the dividend year can't qualify, and PolicyEngine can't identify that child from age. Populace's age equals ASEC A_AGE on all 2,444 Alaska records, and the Census Bureau records age as of a February to April survey date in the year after source_year. If each record keeps its own survey date, 94% (92% to 96%) of Alaska's people aged 0 for 2024 have birth dates in 2022 or 2023 and can qualify under AS 43.23.005(c). If every survey fell in the dividend year, 80% (71% to 87%) could. So policyengine-us#10763 leaves age out: if it gated on age, it would remove mostly children who can qualify.
  • The only record of who received a dividend sits in raw ASEC OI_VAL, which policyengine-us doesn't read.

Evidence

Recipients

We ran real microsimulations on the Alaska households of populace_us_2024.h5 at populace-us-2024-spm-20260909, at their own weights. For 2026, we estimated Alaska's count by dividing the Legislature's $127,340,800 estimate by the $200 energy relief.

  • This PR: policyengine-us#10763 on today's data, which carries no flag.
  • With prototype flag: the same branch reading a flag from the rule below, averaged over 20 seeds. No Populace release carries the prototype flags; we set them only to measure what such a flag would do. The prototype picks its draw cutoff on the file's released weights. With it, we measure a flag that hits Alaska's count on final weights, but not what a stage delivers when it runs before weight calibration (step 4).
Dividend year Alaska paid policyengine-us main This PR With prototype flag
2024 624,489 740,043 (+18.5%) 730,150 (+16.9%) 624,141 (-0.1%), draws 618,190 to 628,769
2025 618,863 746,914 (+20.7%) 736,929 (+19.1%) 629,936 (+1.8%), draws 623,930 to 634,607
2026 636,704 (estimate) 752,248 (+18.1%) 742,191 (+16.6%) 634,434 (-0.4%), draws 628,385 to 639,139
2027 n/a 756,512 746,398 638,030, draws 631,947 to 642,762

The prototype matches 2024 because we set its cutoff there. The model keeps the same flags in later years and uprates the weights: each seed's 2025 and 2026 counts equal its 2024 count times main's uprating ratios (1.00928 and 1.01649), so those rows don't test the draw rule. Alaska paid 83.8% (2025) to 87.0% (2015) of its population. Because we set the prototype's cutoff to match 624,489, it pays 84.3% of Alaska's people.

Matching reported amounts

A record matches when OI_VAL falls within $100 of one to eight dividends Alaska paid in its source_year or the prior year, unless it carries OI_OFF code 12 or 20. We allow multiples for a parent who reports the family's dividends. We ran the rule on every state in the full file, with weights:

Share of people with OI_VAL above 0 Match within $100 Match within $0.50
Alaska 24.40% 23.37% 12.76%
Median other state 0.97% 0.16% 0.00%
Highest other state 2.98% 1.70% 0.47%

The $100 rule catches rounded reports: it finds nearly twice the people the exact rule finds. A match outside Alaska counts as a false positive unless the person received a dividend and moved before the survey. Of Alaska's 617 records with OI_VAL above 0, 615 carry OI_OFF code 19 ("anything else") and 2 carry code 3, so we can't single out the dividend by its OI_OFF code. On the Alaska records:

  • 590 person records match, or 172,918 people.
  • 231 of 732 SPM units (by source identity) hold a match, covering 229,406 people who pass the status test.
  • Zeroing miscellaneous_income on the matching ASEC rows removes $192,478,943 (the exact rule removes $135.2 million).
  • When we weight each record by its household's weight, Alaska's share of people with miscellaneous_income above 0 falls from 11.26% to 1.12% (unweighted, from 270 of 2,444 records to 27). Washington has 1.16%, Oregon 1.25% and the median other state 0.70%, so the removal meets the first criterion in Alaska's reported dividend stays in miscellaneous_income, so the simulated dividend counts it twice #1443.

Proposed stage

Paths below sit under packages/microcosm-build/src/microcosm/build/ unless they start with tools/ or packages/. Line numbers refer to main at 1d174fc.

Add one stage, ak_pfd_take_up, on the anchored count-calibration pattern that us_runtime/snap_state_take_up.py uses at SPM-unit grain and us_runtime/medicaid_take_up.py uses at person grain.

  1. Anchor on reported dividends. An Alaska SPM unit with a member whose OI_VAL matches takes up. Skip records with OI_OFF code 12 or 20, because the split already sends those amounts to strike_benefits and alimony_income. On a match there, step 8's formula would leave miscellaneous_income negative, and neither the split's checks nor the gate's would catch it. No Alaska record with OI_VAL above 0 carries either code. Across all states, 12 records with those codes, in 10 states, would match, and skipping them leaves the table above unchanged. Take the amounts from gov.states.ak.dor.permanent_fund_dividend and gov.states.ak.dor.energy_relief for source_year and the prior year: each amount, and their sum when Alaska paid both that year. Skip a zero amount. The energy relief parameter holds 0 for 2015 to 2021, 2023, 2025 and 2027 on, and every multiple of 0 equals 0, so a zero would match any OI_VAL from $1 to $100. On the Alaska records, a zero would anchor one more respondent, whose $32 report sits on 2 records in 2 SPM units holding 12,574 people. The prototype's PAID table holds no zero. Read the amounts from the engine's parameters if the builder can, so the two can't drift.
  2. Draw the rest. Draw other Alaska units with _stable_unit_draws on the units from _units_with_source_identity, so support-channel clones share a flag and reruns reproduce the file.
  3. Calibrate at stage time. Run calibrate_binary_assignment among non-anchored units so that people who pass the status test and hold the flag equal the 624,489 applications Alaska paid for 2024, on the frame's weights at that point. The target counts people, so weight each unit by its members who pass the status test, as the prototype does. Add that count as a Ledger fact (PolicyEngine/chronicle) with its source. Weight calibration reads facts from the pinned Chronicle feed, so the fact reaches step 4 only after three changes. List its record set and period in us/chronicle_feed_scope.json, rebuild the feed from that list with tools/build_us_chronicle_feed.py, and update the digest in us/chronicle_feed.json, us/target_parity_manifest.json and us/target_parity_feed_families.json.
  4. Carry the count through weight calibration. Microcosm assigns take-up before calibrate_l0_refit (tools/build_us_fiscal_refresh_release.py lines 14688, 14717 and 15338), so a stage-time cutoff doesn't pin the released count. Medicaid's and SNAP's counts reach the release because weight calibration targets the same facts. Add the Ledger fact to INDICATOR_LEDGER_TARGETS in us_runtime/fiscal_targets.py as a state-level indicator sum of person-level ak_permanent_fund_dividend, under a new target role. _direct_reference_from_fact sends each entry to _us_hierarchy_seed (line 3946). That function raises unless microcosm-calibrate's label registries (calibration_provider_label and calibration_variable_label) name the provider and the category, so register both for the new source. The SSI recipient target uses the same person-level indicator pattern. Assigning the flag after weight calibration finishes also works, if a builder prefers it to a weight target.
  5. Gate the delivered count. After calibrate_l0_refit, count eligible flagged people at released weights, and fail the build when the count misses 624,489 by more than a band the PR states and justifies. _enforce_ssi_take_up_delivery and _medicaid_diagnostics_for_existing_output show the shape.
  6. Write the flag to every member of the unit. The variable has person grain, but a parent claims for minor children (AS 43.23.005(c)), and person-grain draws split families. Across 20 seeds, we estimate 2026 Alaska SPM poverty at 8.55% (8.45% to 8.93%) with person-grain draws and 8.47% (8.45% to 8.53%) with SPM-unit draws. We tested only those two grains.
  7. Carry no eligibility mask, as the Medicaid and SNAP stages do, so a user who widens the status test in a reform finds a propensity on the newly eligible. Outside Alaska, leave the flag at its default of true; defined_for keeps those people unpaid.
  8. Remove reported dividends where the split happens. derive_us_alimony_from_asec, which us_runtime/cps_carried.py calls, rewrites miscellaneous_income whenever raw OI_VAL and OI_OFF exist, so a later edit wouldn't survive a rerun. Put the amount rule in one helper that this step and step 1 both call.
    • Give the split a state. The pooled ASEC sets source_year on each person row, but build_pooled_asec_unit_frame moves state_fips to the household table (_attach_household_attributes in us_runtime/asec_pool.py, line 203). Line 202 of packages/microcosm-build/tests/engine_free/us/test_us_asec_pool.py asserts that the person table lacks it. derive_us_cps_carried_inputs hands derive_us_alimony_from_asec the person table alone (us_runtime/cps_carried.py line 224). So map household state_fips onto person rows through person_household_id there, and pass it to the split and the helper. The bare person frames in packages/microcosm-build/tests/engine_free/us/test_us_alimony.py then need a state column.
    • Keep US_ASEC_OTHER_INCOME_OUTPUT_COLUMNS unchanged. Write miscellaneous_income as OI_VAL less alimony, strike benefits and the matched dividend. A fourth output column would cost three things. us_runtime/stacked_spine.py raises at import unless _EXPLICIT_ORIGIN_BATTERY_METRIC_DECLARATIONS covers it. pool_transfer_target_families (us_runtime/multispine_pool.py) would make it a pool transfer target beside strike_benefits. packages/microcosm-build/tests/engine_free/us/test_us_pool_input_consumers.py then requires an engine consumer or a reviewed allowlist entry for it, and policyengine-us has no variable for a reported dividend. And every base that a build staged before the change would fail both reuse and the gate.
    • Recompute the matched amount in both gate checks. The build runs us_alimony_signal_gate on the base frame at line 13685 of tools/build_us_fiscal_refresh_release.py, ahead of the take-up stages. Its row check (miscellaneous_mismatch) must expect OI_VAL less alimony, strike benefits and the matched dividend, and its sum check (conservation_mismatch) must add the matched dividend back. The gate receives a whole frame, so it can map state the same way.
    • Rebuild staged bases from raw ASEC. Without raw OI_VAL and OI_OFF, the split reuses a staged table's columns and the gate skips its source checks, so a base that a build staged before this change would keep reported dividends in miscellaneous_income with no error.
    • Leave puf_tax_detail rows alone, because their miscellaneous_income comes from imputation and has no tie to OI_VAL.

Picking the cutoff at stage time

This emulation sets the flag on the Alaska subset as the prototype does, but picks the draw cutoff on stand-in stage-time weights that it scales to the released total. It then counts eligible flagged people on the released weights, across 500 seeds. The release has no design-weight column, so the emulation uses stand-ins and doesn't run the real refit. When we group Alaska's units by source identity and weight them by members who pass the status test, they amount to about 102 effective units.

Weights used to pick the cutoff Mean SD 5th to 95th percentile Seeds within 2% of 624,489
A_FNLWGT, all rows 619,325 24,891 576,046 to 658,966 (-7.8% to +5.5%) 37%
A_FNLWGT, ASEC rows only 624,531 27,831 571,018 to 666,784 (-8.6% to +6.8%) 35%
Equal weight per ASEC person 620,792 23,475 576,541 to 655,721 (-7.7% to +5.0%) 40%
Released weights (the prototype's own basis) 624,451 2,304 620,469 to 627,896 (-0.6% to +0.5%) 100%

Contract and dependencies

  • Add the variable to us/take_up_contract.json with populace_treatment: count_calibrated, engine_class: data_seeded, entity: person, default: true, and a calibration block that names the anchor and the Ledger fact family. _validate_count_calibration refuses a sourced rate on this treatment, so use rate.status: no_administrative_rate_exists, as Medicaid does. Add the flag to us/spec/take_up.yaml as a program, following the file's header at the time, and name the new stage as its final owner, as Medicaid's entry names medicaid_take_up. Today the header reads "GENERATED by tools/generate_us_bundle_from_constants.py; migration tombstone 2026-11-16".
  • Whoever first bumps the engine past a policyengine-us release containing #10763 must, in the same PR, add a contract row and a program for this flag, count this flag in US_TAKE_UP_PROGRAM_COUNT (spec_engine/engine_abi.py), and regenerate us/engine_abi.lock.json. Main locks policyengine-us 2.2.1 with a count of 17. Draft Lock the US engine to policyengine-us 2.60.4 #1365 moves the lock to 2.60.4 and raises the count to 18 for takes_up_medicare_part_d_if_eligible, so this flag makes the 19th program.
    • assert_take_up_contract_current (build tool line 13078 and tools/us_bundle_generation/contracts.py line 843) fails when the engine carries a takes_up_* variable the contract omits, and test_checked_in_table_matches_installed_engine fails when the contract lists a variable the engine lacks.
    • _take_up_program_bindings (spec_engine/engine_abi.py line 250) requires exactly US_TAKE_UP_PROGRAM_COUNT programs, and engine_abi_lock_payload_from_domains raises "take_up/programs: mapping is not total over the fresh engine ABI" (line 315) when the engine's take-up variables differ from the bundle's. The spec loader runs that check through assert_engine_abi_lock_current (spec_engine/loader.py line 385).
    • If nobody has built the stage by then, follow CHIP's interim pattern for a flag that stays unseeded until count calibration lands. Give the flag's contract row populace_treatment: rate_unsourced, a rate.status and a followup naming this issue, and give its program ownership: engine, an engine_default pipeline and final_owner_stage: policyengine_us_default. Change both when the stage lands.

Done when

  • In the released file, at released weights, eligible flagged people for 2024 equal 624,489 within the delivered-weight band, and the delivered-weight gate fails the build on a miss.
  • The criteria in Alaska's reported dividend stays in miscellaneous_income, so the simulated dividend counts it twice #1443 hold: Alaska's share with miscellaneous_income above 0 falls near comparable states', a test fails on any Alaska ASEC record that still carries a matching amount in miscellaneous_income, the build's provenance records the amount rule, and the release notes state the change in Alaska's market income.
  • Tests cover the anchor (every anchored unit takes up, and a matching record coded 12 or 20 anchors nothing and keeps its amount in strike_benefits or alimony_income), clones sharing a flag, reproducible draws, and conservation of OI_VAL.
  • The new data ships only after the usual data-release decision.

Expected effect

We ran real microsimulations of 2027 on the same records; parentheses give the range across 20 prototype seeds. In the last column, the prototype also applies step 8. The prototype picks its cutoff on released weights, so these ranges understate what a single build would show without the weight target in step 4.

2027 policyengine-us main This PR With prototype flag Reported dividends removed
Dividend $756.5M $746.4M $638.0M ($631.9M to $642.8M) $638.0M ($631.9M to $642.8M)
Energy relief $0.0M $0.0M $0.0M $0.0M
Household net income $38,783.9M $38,774.7M $38,677.4M ($38,672.5M to $38,681.6M) $38,496.1M ($38,491.2M to $38,500.2M)
Change from main n/a -$9.2M -$106.5M (-$111.4M to -$102.4M) -$287.8M (-$292.7M to -$283.7M)
SPM poverty rate 8.85% 8.85% 8.86% (8.85% to 8.93%) 8.86% (8.85% to 8.93%)
Child SPM poverty rate 10.82% 10.82% 10.82% 10.82%

SPM poverty in this file moves in steps: 1,821 of Alaska's 2,444 person records weigh under 1, and the 877 SPM units amount to about 147 effective units. One two-person unit at a weight of about 1,505 moves the rate by 0.4 points.

Prototype scripts

seed_flag.py sets the flag on copies of the Alaska subset:

"""PROTOTYPE of the proposed Microcosm stage. No Populace release contains its output.

Writes copies of the Alaska-only Populace subset with
takes_up_ak_permanent_fund_dividend_if_eligible set, so the PR's impact comes from
real microsimulations of what the data stage would do:

- Anyone in a unit where someone reported a dividend gets the flag (anchor).
- Other units get a seeded blake2b draw on their stable source identity, the
  key microcosm's take_up.py uses, so support-channel clones share a flag.
- It picks the draw cutoff so that eligible flagged people equal Alaska's 2024
  paid applications, 624,489, on the file's 2024 weights.

With --clean, it also takes reported dividends out of miscellaneous_income
(microcosm#1443). Microcosm puts ASEC-channel OI_VAL in miscellaneous_income,
apart from the alimony and strike benefit codes.

usage: seed_flag.py SRC OUT LEVEL SEED MATCH CLEAN
  LEVEL spm_unit|person; MATCH exact|tolerant; CLEAN clean|keep
"""

import hashlib
import json
import sys

import numpy as np
import pandas as pd

VAR = "takes_up_ak_permanent_fund_dividend_if_eligible"
TARGET_2024 = 624_489
ELIGIBLE = {"CITIZEN", "LEGAL_PERMANENT_RESIDENT", "REFUGEE", "ASYLEE"}
# Whole payments Alaska made, and the 2022 and 2024 components people may report alone.
PAID = {
    2021: [1114],
    2022: [3284, 2622, 662],
    2023: [1312],
    2024: [1702, 1403.83, 298.17],
}
TABLES = ["_time_period", "household", "person", "tax_unit", "spm_unit", "family", "marital_unit"]

src, out, level, seed, match, clean = sys.argv[1:7]
assert level in ("spm_unit", "person") and match in ("exact", "tolerant") and clean in ("clean", "keep")
tables = {t: pd.read_hdf(src, t) for t in TABLES}
p = tables["person"].copy()
hh = tables["household"].set_index("household_id")
w = p["person_household_id"].map(hh["household_weight"]).astype(float).to_numpy()
eligible = p["immigration_status_str"].isin(ELIGIBLE).to_numpy()


def is_dividend(value, year):
    """A reported amount equal to one to eight dividends Alaska paid in this or the prior year."""
    if not value > 0:
        return False
    tol = 0.5 if match == "exact" else 100
    for y in (year, year - 1):
        for d in PAID.get(y, []):
            for k in range(1, 9):
                if abs(value - k * d) <= tol:
                    return True
    return False


reporter = np.array([is_dividend(v, int(y)) for v, y in zip(p["OI_VAL"], p["source_year"])])
sid = list(zip(p["source_year"], p["source_household_id"], p["source_person_id"]))
if level == "person":
    key = pd.Series([f"{a}:{b}:{c}" for a, b, c in sid], index=p.index)
else:
    frame = pd.DataFrame(
        {
            "unit": p["person_spm_unit_id"],
            "year": p["source_year"],
            "hh": p["source_household_id"],
            "pid": p["source_person_id"],
        }
    )
    first = frame.groupby("unit").agg(year=("year", "first"), hh=("hh", "first"), pid=("pid", "min"))
    unit_key = first["year"].astype(str) + ":" + first["hh"].astype(str) + ":" + first["pid"].astype(str)
    key = p["person_spm_unit_id"].map(unit_key)

units = pd.DataFrame({"key": key.to_numpy(), "w_e": w * eligible, "rep": reporter}).groupby("key").agg(
    w_e=("w_e", "sum"), anchored=("rep", "any")
)
units["u"] = [
    int.from_bytes(hashlib.blake2b(f"{seed}:{VAR}:{k}".encode(), digest_size=8).digest(), "big") / 2**64
    for k in units.index
]
anchored_weight = float(units.loc[units["anchored"], "w_e"].sum())
free = units.loc[~units["anchored"]].sort_values("u")
cum = anchored_weight + free["w_e"].cumsum().to_numpy()
# Keep the cutoff whose total falls closest to the target.
gaps = np.abs(np.concatenate(([anchored_weight], cum)) - TARGET_2024)
n_free = int(gaps.argmin())
granted = set(free.index[:n_free])
units["flag"] = units["anchored"] | units.index.isin(granted)
flag = key.map(units["flag"]).to_numpy(dtype=bool)
p[VAR] = flag

removed = 0.0
if clean == "clean":
    asec = (p["person_support_channel"] == "asec").to_numpy()
    mask = reporter & asec
    removed = float((w[mask] * p.loc[mask, "miscellaneous_income"].to_numpy()).sum())
    p.loc[mask, "miscellaneous_income"] = 0.0

tables["person"] = p
with pd.HDFStore(out, "w") as store:
    store.put("_time_period", tables["_time_period"], format="table")
    for t in TABLES[1:]:
        store.put(t, tables[t], format="table")

meta = {
    "PROTOTYPE_NOT_RELEASED": True,
    "source": src,
    "level": level,
    "seed": seed,
    "match": match,
    "clean": clean,
    "target_2024": TARGET_2024,
    "alaska_people_2024": float(w.sum()),
    "eligible_people_2024": float((w * eligible).sum()),
    "reporter_people": float(w[reporter].sum()),
    "reporter_records": int(reporter.sum()),
    "anchored_eligible_people": anchored_weight,
    "units": int(len(units)),
    "anchored_units": int(units["anchored"].sum()),
    "granted_units": n_free,
    "eligible_flagged_people_2024": float((w * eligible * flag).sum()),
    "eligible_share_flagged": float((w * eligible * flag).sum() / (w * eligible).sum()),
    "free_share_granted": float(free["w_e"].iloc[:n_free].sum() / free["w_e"].sum()),
    "misc_income_removed": removed,
    "misc_share_after": float(w[p["miscellaneous_income"].to_numpy() > 0].sum() / w.sum()),
}
json.dump(meta, open(out + ".json", "w"), indent=1)
print(json.dumps(meta))

match_rule_check.py applies the amount rule to every state:

"""Apply the amount rule to every state in the full file, to see how often it matches outside Alaska.

A record matches when OI_VAL falls within $100 of one to eight dividends Alaska
paid in its source year or the prior year (the seed_flag.py 'tolerant' rule), unless it
carries OI_OFF code 12 or 20, which microcosm's split already sends to strike_benefits
and alimony_income. No Alaska record with OI_VAL above 0 carries either code, so
the skip changes nothing in seed_flag.py's Alaska runs. A match outside
Alaska counts as a false positive unless the person received a dividend and moved before the
survey, so this bounds the rule's error from above.
"""
import sys
import numpy as np
import pandas as pd

src = sys.argv[1]
PAID = {2021: [1114], 2022: [3284, 2622, 662], 2023: [1312], 2024: [1702, 1403.83, 298.17]}
cols = ["person_household_id", "OI_VAL", "OI_OFF", "source_year", "person_support_channel", "miscellaneous_income"]
p = pd.read_hdf(src, "person", columns=cols)
h = pd.read_hdf(src, "household", columns=["household_id", "state_fips", "household_weight"]).set_index("household_id")
p["fips"] = p["person_household_id"].map(h["state_fips"])
p["w"] = p["person_household_id"].map(h["household_weight"]).astype(float)
amounts = {y: np.array([k * d for yy in (y, y - 1) for d in PAID.get(yy, []) for k in range(1, 9)]) for y in p["source_year"].unique()}
SEPARATE_CODES = (12, 20)
def match(v, code, y, tol):
    if not v > 0 or code in SEPARATE_CODES:
        return False
    return bool((np.abs(amounts[y] - v) <= tol).any())
pos = p[p["OI_VAL"] > 0].copy()
pos["tol100"] = [match(v, c, y, 100) for v, c, y in zip(pos["OI_VAL"], pos["OI_OFF"], pos["source_year"])]
pos["tol0"] = [match(v, c, y, 0.5) for v, c, y in zip(pos["OI_VAL"], pos["OI_OFF"], pos["source_year"])]
skipped = pos["OI_OFF"].isin(SEPARATE_CODES) & np.array(
    [match(v, 0, y, 100) for v, y in zip(pos["OI_VAL"], pos["source_year"])]
)
print("records the code skip removes from the $100 rule:", int(skipped.sum()), "in", pos.loc[skipped, "fips"].nunique(), "states")
tot = p.groupby("fips")["w"].sum()
rows = pos.groupby("fips").apply(lambda d: pd.Series({
    "oi_pos_share": d["w"].sum(), "tol100_share": d.loc[d.tol100, "w"].sum(), "tol0_share": d.loc[d.tol0, "w"].sum()}), include_groups=False)
rows = rows.div(tot, axis=0)
us_ex_ak = rows.drop(index=2)
print("AK shares of people", rows.loc[2].round(4).to_dict())
print("median other state", us_ex_ak.median().round(4).to_dict())
print("max other state", us_ex_ak.max().round(4).to_dict())
print("WA", rows.loc[53].round(4).to_dict(), "OR", rows.loc[41].round(4).to_dict())
ak = p[p["fips"] == 2]
print("AK unweighted share of records with miscellaneous_income > 0:", round(float((ak["miscellaneous_income"] > 0).mean()), 4))
print("WA", round(float((p[p.fips == 53]["miscellaneous_income"] > 0).mean()), 4), "OR", round(float((p[p.fips == 41]["miscellaneous_income"] > 0).mean()), 4))

misc_share_check.py measures the share of people with miscellaneous_income above 0, before and after the removal:

"""Measure the share of people with miscellaneous_income above 0, as microcosm#1443's first criterion counts it.

Weights each person record by its household's weight in the file. Reads Alaska
before the removal from the Alaska subset, Alaska after it from the prototype's
cleaned copy (seed_flag.py with MATCH=tolerant and CLEAN=clean, which zeroes the
reported dividends the $100 rule matches, whatever the seed), and the other
states from the full file.
Reads inputs only. PROTOTYPE: no Populace release contains the cleaned copy.

usage: misc_share_check.py FULL_H5 AK_H5 AK_CLEANED_H5 OUT_JSON
"""

import json
import sys

import pandas as pd


def shares(path, fips=None):
    p = pd.read_hdf(path, "person", columns=["person_household_id", "miscellaneous_income"])
    h = pd.read_hdf(path, "household", columns=["household_id", "state_fips", "household_weight"]).set_index(
        "household_id"
    )
    p["fips"] = p["person_household_id"].map(h["state_fips"]).astype(int)
    p["w"] = p["person_household_id"].map(h["household_weight"]).astype(float)
    out = {}
    for code in fips or sorted(p["fips"].unique()):
        s = p[p["fips"] == code]
        positive = s["miscellaneous_income"] > 0
        out[int(code)] = {
            "records": int(len(s)),
            "records_positive": int(positive.sum()),
            "unweighted_share": float(positive.mean()),
            "people": float(s["w"].sum()),
            "people_positive": float(s.loc[positive, "w"].sum()),
            "weighted_share": float(s.loc[positive, "w"].sum() / s["w"].sum()),
        }
    return out


full, ak, ak_clean, out = sys.argv[1:5]
result = {
    "inputs": {"full": full, "alaska": ak, "alaska_cleaned": ak_clean},
    "alaska_before": shares(ak, [2])[2],
    "alaska_after": shares(ak_clean, [2])[2],
    "full_file": shares(full),
}
others = pd.Series({k: v["weighted_share"] for k, v in result["full_file"].items() if k != 2})
result["other_states_weighted_share"] = {
    "median": float(others.median()),
    "max": float(others.max()),
    "washington": result["full_file"][53]["weighted_share"],
    "oregon": result["full_file"][41]["weighted_share"],
}
json.dump(result, open(out, "w"), indent=1)
for key in ("alaska_before", "alaska_after"):
    print(key, {k: round(v, 4) for k, v in result[key].items()})
print("full-file Alaska", {k: round(v, 4) for k, v in result["full_file"][2].items()})
print("other states weighted", {k: round(v, 4) for k, v in result["other_states_weighted_share"].items()})

zero_amount_check.py measures what a zero amount would add to step 1's rule:

"""What a zero amount would add to the $100 match rule on Alaska's records.

match_rule_check.py's PAID table holds only amounts Alaska paid, so it skips
the years when Alaska paid no energy relief. A rule that took "each amount" from both
parameters would also take those zeros, and every multiple of 0 equals 0, so
the $100 rule would match any OI_VAL from $1 to $100.

usage: zero_amount_check.py AK_H5 OUT_JSON
"""

import json
import sys

import numpy as np
import pandas as pd

PAID = {2021: [1114], 2022: [3284, 2622, 662], 2023: [1312], 2024: [1702, 1403.83, 298.17]}
# gov.states.ak.dor.energy_relief holds 0 for 2015 to 2021, 2023, 2025 and 2027 on.
ZERO_RELIEF_YEARS = {2021, 2023}
TOLERANCE = 100


def amounts(source_year, with_zero):
    values = [
        k * d for y in (source_year, source_year - 1) for d in PAID.get(y, []) for k in range(1, 9)
    ]
    if with_zero and ZERO_RELIEF_YEARS & {source_year, source_year - 1}:
        values.append(0.0)
    return np.array(values)


def matches(value, source_year, with_zero):
    return bool(np.any(np.abs(amounts(source_year, with_zero) - value) <= TOLERANCE))


def main():
    path, out = sys.argv[1:3]
    person = pd.read_hdf(path, "person")
    household = pd.read_hdf(path, "household").set_index("household_id")
    weight = person.person_household_id.map(household.household_weight).astype(float)
    positive = person.OI_VAL > 0
    paid_only = np.array(
        [p and matches(v, y, False) for p, v, y in zip(positive, person.OI_VAL, person.source_year)]
    )
    with_zero = np.array(
        [p and matches(v, y, True) for p, v, y in zip(positive, person.OI_VAL, person.source_year)]
    )
    added = with_zero & ~paid_only
    units_paid = set(person.person_spm_unit_id[paid_only])
    units_added = set(person.person_spm_unit_id[added]) - units_paid
    in_added_units = person.person_spm_unit_id.isin(units_added)
    result = {
        "records_matched_paid_amounts_only": int(paid_only.sum()),
        "people_matched_paid_amounts_only": float(weight[paid_only].sum()),
        "records_added_by_a_zero_amount": int(added.sum()),
        "people_on_added_records": float(weight[added].sum()),
        "weighted_oi_val_on_added_records": float((weight * person.OI_VAL)[added].sum()),
        "added_records": person.loc[added, ["person_source_id", "source_year", "OI_OFF", "OI_VAL", "age"]]
        .assign(weight=weight[added])
        .to_dict("records"),
        "source_people_on_added_records": int(person.person_source_id[added].nunique()),
        "spm_units_newly_anchored": len(units_added),
        "people_in_newly_anchored_spm_units": float(weight[in_added_units].sum()),
    }
    json.dump(result, open(out, "w"), indent=1, default=float)
    print(json.dumps(result, indent=1, default=float))


if __name__ == "__main__":
    main()

age_dating_check.py dates Alaska's people aged 0:

"""Which Alaska people aged 0 can qualify for the 2024 dividend, by dating.

Populace's source_year holds the ASEC income year (AsecSource.year, which
microcosm's asec_pool.py passes as income_year). Census surveys that year's income in
February to April of the next year and records age as of the survey date. A
child born during the dividend year can't qualify: the child fails AS
43.23.005(a)(3), and under (c) only children born in the two calendar years
before the dividend year qualify. A child born earlier can qualify under (c) or (a).

Assumes births spread evenly over the 365 days before the survey date. Three
datings of a record's survey:

- own_survey: each record keeps its own survey, in source_year + 1;
- survey_in_dividend_year: every survey falls in the dividend year;
- birth_year: the dividend year minus age, as policyengine-us's birth_year
  formula reads it, which puts every child aged 0 in the dividend year.

usage: age_dating_check.py AK_H5 OUT_JSON
"""

import json
import sys

import pandas as pd

DIVIDEND_YEAR = 2024
# Day of the year of each survey date, in a 365-day year.
SURVEY_DAYS = {"mid_february": 46, "mid_march": 74, "mid_april": 105}


def share_born_before(survey_year, survey_day, year):
    """Share of children under one at the survey date born before `year`."""
    # Days from January 1 of the survey year: births fall in
    # (survey_day - 365, survey_day]; January 1 of `year` sits at
    # (year - survey_year) * 365 + 1.
    cutoff = (year - survey_year) * 365
    share = (cutoff - (survey_day - 365)) / 365
    return min(max(share, 0.0), 1.0)


def qualifying_share(people_by_source_year, dating, survey_day):
    total = sum(people_by_source_year.values())
    can = 0.0
    for source_year, people in people_by_source_year.items():
        if dating == "own_survey":
            survey_year = source_year + 1
        elif dating == "survey_in_dividend_year":
            survey_year = DIVIDEND_YEAR
        else:
            return 0.0
        can += people * share_born_before(survey_year, survey_day, DIVIDEND_YEAR)
    return can / total


def main():
    path, out = sys.argv[1:3]
    person = pd.read_hdf(path, "person")
    household = pd.read_hdf(path, "household").set_index("household_id")
    weight = person.person_household_id.map(household.household_weight).astype(float)
    zero = person.age == 0

    age0 = {
        int(year): float(people)
        for year, people in weight[zero].groupby(person.source_year[zero]).sum().items()
    }
    age0_records = {
        int(year): int(count)
        for year, count in person.source_year[zero].value_counts().sort_index().items()
    }
    everyone = {
        int(year): float(people)
        for year, people in weight.groupby(person.source_year).sum().items()
    }
    total0 = sum(age0.values())
    # Spread the same age-0 total over source years like all Alaska weight.
    total_all = sum(everyone.values())
    spread = {year: total0 * people / total_all for year, people in everyone.items()}

    result = {
        "dividend_year": DIVIDEND_YEAR,
        "age_0_records": int(zero.sum()),
        "age_0_people": total0,
        "age_0_records_by_source_year": age0_records,
        "age_0_people_by_source_year": age0,
        "age_0_share_by_source_year": {y: v / total0 for y, v in age0.items()},
        "all_people_share_by_source_year": {
            y: v / total_all for y, v in everyone.items()
        },
        "datings": {},
    }
    for dating in ("own_survey", "survey_in_dividend_year", "birth_year"):
        rows = {}
        for name, day in SURVEY_DAYS.items():
            share = qualifying_share(age0, dating, day)
            rows[name] = {
                "share_can_qualify": share,
                "people_who_can_qualify": share * total0,
                "people_who_cannot": (1 - share) * total0,
                "share_if_spread_like_all_weight": qualifying_share(
                    spread, dating, day
                ),
            }
        result["datings"][dating] = rows
    # Under own_survey, the share born two calendar years before the dividend
    # year: records from the survey in the year before it, with births before
    # that year.
    result["own_survey_share_born_two_years_before"] = {
        name: sum(
            people * share_born_before(year + 1, day, DIVIDEND_YEAR - 1)
            for year, people in age0.items()
            if year + 1 == DIVIDEND_YEAR - 1
        )
        / total0
        for name, day in SURVEY_DAYS.items()
    }
    json.dump(result, open(out, "w"), indent=1)
    print(json.dumps(result, indent=1))


if __name__ == "__main__":
    main()

stage_time_emulation.py picks the cutoff on stand-in stage-time weights:

"""Emulate choosing the prototype's fill cutoff on stage-time weights.

Microcosm assigns take-up before calibrate_l0_refit, so a stage that picks its
cutoff at that point sees different weights from the released ones. This script
sets the flag as seed_flag.py does, but chooses the cutoff on stand-in
stage-time weights that it scales to the released total, then counts eligible flagged
people on the released weights. The release has no design-weight column, so the script
uses stand-ins and doesn't run the real refit.

PROTOTYPE: these flags appear in no Populace release.

usage: stage_time_emulation.py AK_H5 [SEEDS]
"""

import hashlib
import sys

import numpy as np
import pandas as pd

path = sys.argv[1]
seeds = int(sys.argv[2]) if len(sys.argv) > 2 else 500
TARGET = 624_489
ELIGIBLE = {"CITIZEN", "LEGAL_PERMANENT_RESIDENT", "REFUGEE", "ASYLEE"}
PAID = {2021: [1114], 2022: [3284, 2622, 662], 2023: [1312], 2024: [1702, 1403.83, 298.17]}
VAR = "takes_up_ak_permanent_fund_dividend_if_eligible"

p = pd.read_hdf(path, "person")
h = pd.read_hdf(path, "household").set_index("household_id")
w = p.person_household_id.map(h.household_weight).astype(float).to_numpy()
elig = p.immigration_status_str.isin(ELIGIBLE).to_numpy()
rep = np.array(
    [
        v > 0 and any(abs(v - k * d) <= 100 for yy in (int(y), int(y) - 1) for d in PAID.get(yy, []) for k in range(1, 9))
        for v, y in zip(p.OI_VAL, p.source_year)
    ]
)
frame = pd.DataFrame(dict(u=p.person_spm_unit_id, y=p.source_year, h=p.source_household_id, i=p.source_person_id))
first = frame.groupby("u").agg(y=("y", "first"), h=("h", "first"), i=("i", "min"))
key = p.person_spm_unit_id.map(first.y.astype(str) + ":" + first.h.astype(str) + ":" + first.i.astype(str)).to_numpy()
asec = (p.person_support_channel == "asec").to_numpy()

raw = p.A_FNLWGT.astype(float).to_numpy()
standins = {
    "A_FNLWGT, all rows": raw,
    "A_FNLWGT, ASEC rows only": raw * asec,
    "Equal weight per ASEC person": asec.astype(float),
    "Released weights (the prototype's own basis)": w,
}

units = pd.DataFrame(dict(k=key, we=w * elig, a=rep)).groupby("k").agg(we=("we", "sum"), a=("a", "any"))
draws = {}
for s in range(1, seeds + 1):
    draws[s] = np.array(
        [int.from_bytes(hashlib.blake2b(f"{s}:{VAR}:{k}".encode(), digest_size=8).digest(), "big") / 2**64 for k in units.index]
    )

kish = units.we.sum() ** 2 / (units.we**2).sum()
print(f"Units by source identity: {len(units)}; effective units, Kish, eligible people on released weights: {kish:.1f}")
free = ~units.a.to_numpy()
print(f"Non-anchored eligible people: {units.we[free].sum():,.0f}; anchored: {units.we[~free].sum():,.0f}")
for name, d in standins.items():
    d = d / d.sum() * w.sum()
    de = pd.DataFrame(dict(k=key, de=d * elig)).groupby("k").de.sum().reindex(units.index).to_numpy()
    a = units.a.to_numpy()
    we = units.we.to_numpy()
    out = []
    for s in range(1, seeds + 1):
        idx = np.where(~a)[0]
        order = idx[np.argsort(draws[s][idx])]
        cum = np.r_[de[a].sum(), de[a].sum() + de[order].cumsum()]
        n = int(np.abs(cum - TARGET).argmin())
        flag = a.copy()
        flag[order[:n]] = True
        out.append(we[flag].sum())
    out = np.array(out)
    lo, hi = np.quantile(out, [0.05, 0.95])
    within = np.mean(np.abs(out / TARGET - 1) <= 0.02)
    print(
        f"{name}: mean {out.mean():,.0f}, SD {out.std():,.0f}, 5th to 95th {lo:,.0f} to {hi:,.0f} "
        f"({lo / TARGET - 1:+.1%} to {hi / TARGET - 1:+.1%}), seeds within 2%: {within:.0%}"
    )

🤖 Generated with Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions