Repository navigation
Description
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.randomraises "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_incarceratedrecords 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'sageequals ASECA_AGEon all 2,444 Alaska records, and the Census Bureau records age as of a February to April survey date in the year aftersource_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_incomeon 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_incomeabove 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.
- Anchor on reported dividends. An Alaska SPM unit with a member whose
OI_VALmatches takes up. Skip records withOI_OFFcode 12 or 20, because the split already sends those amounts tostrike_benefitsandalimony_income. On a match there, step 8's formula would leavemiscellaneous_incomenegative, and neither the split's checks nor the gate's would catch it. No Alaska record withOI_VALabove 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 fromgov.states.ak.dor.permanent_fund_dividendandgov.states.ak.dor.energy_reliefforsource_yearand 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 anyOI_VALfrom $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'sPAIDtable holds no zero. Read the amounts from the engine's parameters if the builder can, so the two can't drift. - Draw the rest. Draw other Alaska units with
_stable_unit_drawson the units from_units_with_source_identity, so support-channel clones share a flag and reruns reproduce the file. - Calibrate at stage time. Run
calibrate_binary_assignmentamong 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 inus/chronicle_feed_scope.json, rebuild the feed from that list withtools/build_us_chronicle_feed.py, and update the digest inus/chronicle_feed.json,us/target_parity_manifest.jsonandus/target_parity_feed_families.json. - Carry the count through weight calibration. Microcosm assigns take-up before
calibrate_l0_refit(tools/build_us_fiscal_refresh_release.pylines 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 toINDICATOR_LEDGER_TARGETSinus_runtime/fiscal_targets.pyas a state-level indicator sum of person-levelak_permanent_fund_dividend, under a new target role._direct_reference_from_factsends each entry to_us_hierarchy_seed(line 3946). That function raises unless microcosm-calibrate's label registries (calibration_provider_labelandcalibration_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. - 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_deliveryand_medicaid_diagnostics_for_existing_outputshow the shape. - 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.
- 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_forkeeps those people unpaid. - Remove reported dividends where the split happens.
derive_us_alimony_from_asec, whichus_runtime/cps_carried.pycalls, rewritesmiscellaneous_incomewhenever rawOI_VALandOI_OFFexist, 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_yearon each person row, butbuild_pooled_asec_unit_framemovesstate_fipsto the household table (_attach_household_attributesinus_runtime/asec_pool.py, line 203). Line 202 ofpackages/microcosm-build/tests/engine_free/us/test_us_asec_pool.pyasserts that the person table lacks it.derive_us_cps_carried_inputshandsderive_us_alimony_from_asecthe person table alone (us_runtime/cps_carried.pyline 224). So map householdstate_fipsonto person rows throughperson_household_idthere, and pass it to the split and the helper. The bare person frames inpackages/microcosm-build/tests/engine_free/us/test_us_alimony.pythen need a state column. - Keep
US_ASEC_OTHER_INCOME_OUTPUT_COLUMNSunchanged. Writemiscellaneous_incomeasOI_VALless alimony, strike benefits and the matched dividend. A fourth output column would cost three things.us_runtime/stacked_spine.pyraises at import unless_EXPLICIT_ORIGIN_BATTERY_METRIC_DECLARATIONScovers it.pool_transfer_target_families(us_runtime/multispine_pool.py) would make it a pool transfer target besidestrike_benefits.packages/microcosm-build/tests/engine_free/us/test_us_pool_input_consumers.pythen 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_gateon the base frame at line 13685 oftools/build_us_fiscal_refresh_release.py, ahead of the take-up stages. Its row check (miscellaneous_mismatch) must expectOI_VALless 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_VALandOI_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 inmiscellaneous_incomewith no error. - Leave
puf_tax_detailrows alone, because theirmiscellaneous_incomecomes from imputation and has no tie toOI_VAL.
- Give the split a state. The pooled ASEC sets
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.jsonwithpopulace_treatment: count_calibrated,engine_class: data_seeded,entity: person,default: true, and acalibrationblock that names the anchor and the Ledger fact family._validate_count_calibrationrefuses a sourced rate on this treatment, so userate.status: no_administrative_rate_exists, as Medicaid does. Add the flag tous/spec/take_up.yamlas a program, following the file's header at the time, and name the new stage as its final owner, as Medicaid's entry namesmedicaid_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 regenerateus/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 fortakes_up_medicare_part_d_if_eligible, so this flag makes the 19th program.assert_take_up_contract_current(build tool line 13078 andtools/us_bundle_generation/contracts.pyline 843) fails when the engine carries atakes_up_*variable the contract omits, andtest_checked_in_table_matches_installed_enginefails when the contract lists a variable the engine lacks._take_up_program_bindings(spec_engine/engine_abi.pyline 250) requires exactlyUS_TAKE_UP_PROGRAM_COUNTprograms, andengine_abi_lock_payload_from_domainsraises "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 throughassert_engine_abi_lock_current(spec_engine/loader.pyline 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, arate.statusand afollowupnaming this issue, and give its programownership: engine, anengine_defaultpipeline andfinal_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_incomeabove 0 falls near comparable states', a test fails on any Alaska ASEC record that still carries a matching amount inmiscellaneous_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_benefitsoralimony_income), clones sharing a flag, reproducible draws, and conservation ofOI_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