Repository navigation
Declare the US SPM measurement universe from the source record type - #977
Merged
Merged
Conversation
policyengine-us#9462 makes the SPM measurement universe a source declaration:
a dataset supplies `spm_unit_spm_universe_status` for every SPM unit, and the
model never infers scope from composition. Microcosm had no producer for that
input.
Add `us_runtime.spm_universe_source`. ACS housing units (`TYPEHUGQ == 1`) and
every ASEC record are `INCLUDED`; ACS group quarters (`TYPEHUGQ` in {2, 3})
are `OUTSIDE`. Both arms come from Census SEHSD-WP2020-09 pages 6-7 and they
are not symmetric: the ACS exclusion is a data limitation Census states
explicitly, while the CPS ASEC sample frame already equals the CPS SPM
universe, so the ASEC ruling is made at the spine and carries its own refusal
if a vintage ever arrives with a record-type field to re-derive it against.
Eleven named refusals fail closed rather than guess. Two earn their keep
beyond the obvious: `SPM_UNIVERSE_UNIT_SPANS_KINDS`, natively unreachable but
the point at which a bad merge would otherwise fabricate a majority kind; and
`SPM_UNIVERSE_DEGRADED_PARTITION`, which proves the ASEC arm's SPM partition
is the native `SPM_ID` one rather than the documented household fallback that
any single missing `SPM_ID` triggers frame-wide. That check is ACS-exempt on
purpose: ACS PUMS supplies no `SPM_ID`, so one unit per household is the
native ACS partition, not a degradation.
Never an inference. The status is read from the source's own record type,
fixed before any composition is computed. A zero-adult *housing* unit is
`INCLUDED` — marking it `OUTSIDE` would brand a data defect as
out-of-universe and make the universe move whenever the role column moved.
Both the anti-inference case and invariance to age and role are pinned by
test. The status is never a calibration target, never a selection criterion,
never a gate, and deliberately never a `spine_agreement` surface: it is meant
to differ by spine, which is its entire content.
Not wired. No build path calls it, the export adapter is untouched, the
enrichment lane does not carry it, and no consumer changes. Registry entries
moved: the spine-blindness inventory (classification plus the provenance-owner
allowlist, both load-bearing) and the CI test grouping, which resolves to
`us-qs` by suffix. `source_enrichment.PRODUCER_SOURCE_FILES` is deliberately
not touched; see docs/us-spm-measurement-universe.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 22, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PUF support clone (puf_support._clone_entity_table) deep-copies the person table and remaps only id and membership columns, so clone copy 1 carries the native SPM_ID under new SPM unit ids. Keyed on SPM_ID alone, SPM_UNIVERSE_DEGRADED_PARTITION refused every support-cloned ASEC frame, including the real operator's output on invented rows. The native unit key is now (clone copy, SPM_ID); degraded copies still refuse. A missing person clone index is now read as the native copy in both the ASEC partition check and the one-native-person group-quarters check, so a pool that lost the index on one arm can no longer pass the GQ check vacuously. SPM_UNIVERSE_COLUMN_EXISTS now covers every entity table, not only the SPM unit table. Tests: the real clone operator's output (requires_us), clone-copy negative controls, missing-index cases, channel precedence and explicit channel_column refusals, and every ASEC record-type field on both tables. Docs record #976's merge and Max's 2026-09-22 rulings; the ASEC authority constant records the sign-off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tive When the person table carries person_support_clone_index, a missing, non-finite, negative or non-integral value now raises SPM_UNIVERSE_INVALID_CLONE_INDEX before the group-quarters and degraded-partition checks run. Reading a missing index as the native copy could merge two clone copies that share one frame unit into a single key and let a genuine cross-copy collision pass the partition check (gate round 1 counterexample). The maintained readers of this column already refuse such values. A frame with no clone-index column stays all-native. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
marked this pull request as ready for review
September 23, 2026 05:52
MaxGhenis
added a commit
that referenced
this pull request
Sep 23, 2026
No textual conflicts. build_us_fiscal_refresh_release.py (restated age-band ledger filters, #986) and test_us_spine_blindness.py (spm_universe_source.py, #977) auto-merged alongside this branch's spm_independence_role stage and module registrations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
PolicyEngine/policyengine-us#9462 makes the SPM measurement universe a
source declaration: a dataset supplies
spm_unit_spm_universe_status∈{
INCLUDED,OUTSIDE,UNRESOLVED} for every SPM unit,OUTSIDEunits getmissing SPM amounts and poverty indicators, and an undeclared dataset raises
SPM_UNIVERSE_REQUIRED. Microcosm had no producer for that input.This adds one:
microcosm.build.us_runtime.spm_universe_source.TYPEHUGQ == 1)INCLUDEDTYPEHUGQ∈ {2, 3})OUTSIDEINCLUDEDBoth arms come from Census SEHSD-WP2020-09 pages 6–7 and they are not
symmetric. The ACS exclusion is a data limitation Census states explicitly —
the public PUMS cannot distinguish noninstitutional group quarters, so the ACS
SPM sample is limited to people living in households. The ASEC ruling is the
opposite shape: the CPS ASEC sample frame already equals the CPS SPM universe,
so there is no ASEC record outside it to declare. That makes the ASEC ruling
necessarily spine-level, and it carries its own refusal so a future vintage
that admits a record-type field cannot be silently declared in-universe
(design note Q-E).
Status is stored as the enum member name string —
"OUTSIDE", not2,not
"SPMUniverseStatus.OUTSIDE", not the label text — because the exportadapter normalizes a stored cell to a member name and validates it against the
engine's own enum domain.
Never an inference
The ACS problem was discovered as SPM units with no classified adult, and
the tempting wrong design marks zero-adult units
OUTSIDE. That would brand agenuine household-unit data defect as out-of-universe and hide it, and it
would make the universe move whenever the role column moved. The producer
reads
TYPEHUGQ, which the source fixes before any composition is computed.test_a_zero_adult_housing_unit_is_includedis the anti-inference test, withthe identical composition under
TYPEHUGQ 2as its negative control;test_the_declaration_is_invariant_to_age_and_rolepins it in bothdirections.
Never a target, a gate, or an agreement surface
The status is not a calibration target, not a selection criterion and not a
release gate. Poverty in this repo is a comparison only. It is also
deliberately not a
spine_agreementsurface: it is meant to differ byspine — that is its entire content — so registering it there would assert that
the ACS and ASEC arms should agree about a fact they are defined to disagree
about. The module docstring says so on the record.
OUTSIDEmoves no weight, no membership and no row. The fiscal population isunchanged; only its SPM measurability is declared.
Twelve refusals, all fail-closed
SPM_UNIVERSE_COLUMN_EXISTS,…_UNDECLARED_SPINE,…_UNKNOWN_HOUSEHOLD_KIND,…_OFF_ARM_HOUSEHOLD_KIND,…_MIXED_HOUSEHOLD,…_GQ_MULTI_PERSON,…_ORPHAN_MEMBER,…_EMPTY_UNIT,…_UNIT_SPANS_KINDS,…_DEGRADED_PARTITION,…_ASEC_RECORD_TYPE_UNREVIEWED,…_INVALID_CLONE_INDEX.UNRESOLVEDis the engine's absencesentinel, so a producer that cannot decide refuses — it never writes it.
Two are worth naming:
SPM_UNIVERSE_UNIT_SPANS_KINDSis natively unreachable (ACSgroup-quarters households are one-person placeholders). It earns its keep
the moment a future spine, a donor merge or a clone-index bug puts a
group-quarters person into a multi-person unit, where picking a majority
kind would be a fabricated universe decision.
SPM_UNIVERSE_DEGRADED_PARTITIONcloses the largest silent-failure modehere.
assign_us_unit_structureuses the nativeSPM_IDwhen fully presentand falls back to
household_idotherwise — any missingSPM_IDtriggersthat fallback frame-wide, and
asec_pool._globalize_source_unit_columndeliberately tolerates a missing
SPM_ID. DeclaringINCLUDEDover adegraded partition would attest to a unit structure that does not exist, so
the ASEC arm must prove its partition is a one-to-one densification of
(support-clone copy, SPM_ID). The copy is part of the key because the PUFsupport clone copies
SPM_IDverbatim under new unit ids (see themerge-prep review below). The check is ACS-exempt on purpose:
acs_pumsreaches
assign_us_unit_structurewith noSPM_IDat all, so one SPM unitper household is the native ACS partition, not a degradation.
What is wired, and what is not
Wired. The producer, its refusals, its tests, and a doc page.
Not wired, deliberately. No build path calls it. The export adapter is
untouched, so the column reaches no release H5 — the adapter change is blocked
upstream (design note §4 / Q-A). The enrichment lane does not carry it.
check_spm_compositionandreform_validationare unchanged.Registry entries this moves
tests/test_us_spine_blindness.py—_OTHER_US_RUNTIME_MODULES(classification) and
_SOURCE_SPINE_PROVENANCE_OWNERS. The second isnot in the design note's checklist and is load-bearing: removing it fails
test_runtime_population_operators_are_source_spine_blindwith sixcontraband reads, because reading the provenance tag is the point of a
per-arm universe ruling.
test_us_spm_universe_source.pyresolves tous-qsbysuffix;
tools/ci_test_groups.py --verifyis green and--list us-qscontains it.
Not moved, deliberately:
source_enrichment.PRODUCER_SOURCE_FILES. Thedesign note lists it, but that tuple is the SPM-role enrichment producer's
reviewed source closure (mirrored in
tools/build_us_spm_role_enrichment.py),and
validate_source_enrichment_candidaterefuses a recorded inventory whosekey set differs from it. Adding a seventh entry would make the already-recorded
six-key provenance of the published role-enrichment candidate fail validation,
for a module that lane neither imports nor executes. It belongs in the
enrichment-lane generalization instead. Ruled 2026-09-22: keep it out (see
Rulings).
Tests
packages/microcosm-build/tests/test_us_spm_universe_source.py, 65 cases oninvented fixtures (48 as first built, 13 added in merge prep, 4 in gate round 1) — nothing opens a
build artifact, a pinned source or a release. One case is
requires_us: itruns the real
clone_us_frame_for_puf_supportover invented rows. Covering the rule on each arm and eachTYPEHUGQcode, the ASECspine ruling, the anti-inference case with its negative control, clone
inheritance, the stored member-name encoding with three negative controls
(integer index,
repr(), label text), every one of the twelve refusals with aminimal frame that triggers exactly it, negative controls beside the
refusals that would otherwise over-refuse (a group-quarters placeholder with
a clone is accepted; the degraded-partition check does not fire on the ACS
arm; the ASEC record-type guard does not fire on an ACS-only frame), and the
frame wrapper leaving weights, membership, rows, metadata and mass log intact.
Three structural tests keep the module honest: the channel ruling table is
checked against each canonical constant by
ast-parsing its defining module(no import — each pulls the country engine:
acs_pumstook ~38 s andstacked_spine~36 s warm in separate processes on 2026-09-22, and theauthoring lane measured
stacked_spineat ~90 s cold, which is why a leafproducer names them instead), the spine column name is checked
against
base_pool.spine_column's own f-string template, and every refusalcode the module actually raises is required to equal the declared tuple.
As first built, at b13dc8c (the merge-prep rerun is in its own section below):
The second run is the evidence for "this module moves no other registry": the
release input coverage manifest, the US spec bundle, the spec inventory
coverage and the source-enrichment contract are all green unchanged.
Context
docs/us-spm-measurement-universe-producer.mdon branchus-spm-measurement-universe-producer-design(§2 the rule, §3 the registrysurvey, §7 the plan). This is its PR 1.
us-person-rate-nullable), the §6_person_ratefix,merged 2026-09-22 and now on this branch through the main merge.
single-year H5 resolves only its base year.
Draft. Merge prep is done (below); marking ready and merging are the main
session's call.
Rulings (Max, 2026-09-22)
Max ruled on the four open questions ("i approve all"). The branch follows
each one:
PRODUCER_SOURCE_FILES: keep the module out. It is not insource_enrichment.PRODUCER_SOURCE_FILES(source_enrichment.py:120-127on main), whose key set
validate_source_enrichment_candidaterequires tomatch exactly (
source_enrichment.py:572-576).SPM_ID: the refusal stays strict. An absent or partly missingSPM_IDon the ASEC arm refuses withSPM_UNIVERSE_DEGRADED_PARTITION(
_assert_native_asec_partition). The merge-prep fix below changes onlyhow clone copies are keyed. It does not relax the missing-
SPM_IDrule.puf_tax_detail: it keeps refusing. It is not inSPM_UNIVERSE_CHANNEL_ARMS, so a household tagged only with that channelrefuses with
SPM_UNIVERSE_UNDECLARED_SPINE(
test_refuses_a_channel_with_no_universe_ruling). A PUF clone inside apooled frame still resolves through its
household_spinetag, whichtakes precedence
(
test_the_spine_tag_takes_precedence_over_the_support_channel).ASEC_SPM_INCLUDED_AUTHORITY, withSPM_UNIVERSE_METHODOLOGYcitingCensus SEHSD-WP2020-09 (Fox, Glassman and Pacas), pages 6–7. I re-read the
quoted passage and Table 2 ("Poverty universe") on 2026-09-22. The
constant's comment now records the sign-off.
Merge-prep review (2026-09-22)
Merged
origin/main(63897ed) with no conflicts. I reviewed the diffagainst the code and fixed one medium defect and two low ones. Each fix has
tests that fail without it.
SPM_UNIVERSE_DEGRADED_PARTITIONrefused everysupport-cloned ASEC frame.
puf_support._clone_entity_tabledeep-copies the person table and remaps only the id and membership columns
(
puf_support.py:2085-2120,_entity_id_columnsat:2189-2195). Clonecopy 1 therefore carries the native
SPM_IDunder new SPM unit ids. Thecheck keyed on
SPM_IDalone, so it read that clone as a degradedpartition. I measured this by running the real
clone_us_frame_for_puf_supporton three invented ASEC persons at thepre-fix head: it refused with "3 native unit(s), 6 frame unit(s)". The
native unit key is now
(clone copy, SPM_ID). A degraded partition insideone copy still refuses, whether two units are collapsed or one is split.
anything.
_assert_group_quarters_are_single_native_recordscounted onlypersons with
clone_index == 0, so a person with a NaN index was nevercounted. That can happen when a pool lacks the column on one arm. A missing
index now reads as the native copy in both checks, which can only add
collisions.
SPM_UNIVERSE_COLUMN_EXISTSchecked only the SPM unittable. If another entity already carried the column, the frame
constructor failed on its global-name rule instead. That was still a
refusal, but unnamed. The check now covers every entity table.
channel_columnpath and both of its refusals, the spine tag's precedence over the support
channel,
H_LIVQRTas an ASEC refusal, and a record-type field on theperson table.
_person_ratenow reads thepoverty indicator as nullable. The graph-native
spm_input_contractandacs_spm_scopereferences point to unmerged branches (first at774a900), and the comments now say so. Import timings are re-measured
above.
Claims I checked against the code:
TYPEHUGQis staged on the householdtable (
acs_pums.py:63-71), and GQ rows areNP == 1,WGTP == 0placeholders (
acs_pums.py:394-414). The ACS arm reachesassign_us_unit_structurewith noSPM_ID(acs_pums.py:282-297). Anymissing
SPM_IDfalls back to households (units.py:125-129).asec_pool._globalize_source_unit_columnkeeps a missingSPM_IDmissing(
asec_pool.py:359-381). The Frame constructor refuses duplicated group idsand orphaned or dangling membership (
bundle.py:218-243). The export adapternormalizes enum cells to member names and validates them against the engine
domain (
policyengine_us.py:589-600,:1086-1113).check_spm_compositionhas no universe predicate. I also re-proved the
_SOURCE_SPINE_PROVENANCE_OWNERSentry by removing it: the six contrabandreads return.
Deduced, not measured: the module is not wired to any build path, so today
no frame reaches it. The clone defect would have blocked the first ASEC-arm
call site rather than mislabel data.
Gate round 1 (2026-09-22)
The peer found one low defect: the merge-prep fix read a missing support-clone index as the native copy and claimed that could only make the checks stricter. It could not for the degraded-partition check: two ASEC rows in different clone copies that share one frame SPM unit refuse when their indices are present (two native keys, one frame unit) but would merge into one key and pass if both indices were missing. Fixed by refusing instead of guessing: when the person table carries
person_support_clone_index, a missing, non-finite, negative or non-integral value now raisesSPM_UNIVERSE_INVALID_CLONE_INDEXbefore either check runs, matching the maintained readers of that column (acs_transfer.pyrefuses a non-finite clone index;stacked_spineandpuf_supportparse it witherrors="raise"or refuse a missing one). A frame with no clone-index column was never support-cloned and stays all-native. Tests: the peer's counterexample (refuses on the partition with indices present, on the clone index with them missing), three invalid values, and the two earlier missing-index tests retargeted to the new refusal; 65 pass.🤖 Generated with Claude Code