Add label-verified CPS ASEC firm-size (NOEMP) reader with band-regime maps - #194
Add label-verified CPS ASEC firm-size (NOEMP) reader with band-regime maps#194daphnehanse11 wants to merge 3 commits into
Conversation
|
@daphnehanse11 is attempting to deploy a commit to the PolicyEngine Team on Vercel. A member of the Team first needs to authorize it. |
vahid-ahmadi
left a comment
There was a problem hiding this comment.
Reviewed against the 194 diff and the C1/C2 discussion on #192. Overall: this is exactly the reader Workstream B needs to consume for the C2 evidence, the regime-break handling is right (the 2011–2018 vs 2019+ code 2/3 swap matches what our independent data audit verified against IPUMS/Census), and the refuse-don't-guess posture (unverified years, path/year mismatch, dictionary domains, universe check) fits the repo's label-verified reader discipline well. Findings, ranked:
1. 🔴 person_id will silently collide on real 2017+ CSVs — read PERIDNUM as a string. PERIDNUM is a 22-digit identifier; pd.read_csv without a dtype will overflow int64 (19 digits) and fall back to float64, whose 53-bit mantissa holds only ~15–16 significant digits — so distinct persons collapse to the same rounded value before astype(str) runs, and the resulting ids also render as 1.100234e+21-style strings. The fixture tests use small ids so nothing catches it, and TestRealData checks len(out) > 10_000 but not uniqueness. Fix: pd.read_csv(..., dtype={"PERIDNUM": "string"}) (and add a uniqueness assertion to TestRealData).
2. 🟡 tests/tier_counts.json will conflict with #195. This PR moves unit 410→427; #195 (Workstream B) moves it 410→464 on the same base. Whichever merges second needs 481. Not a defect — just flagging so the second merge doesn't get a surprise CI failure; happy to rebase #195 if this lands first.
3. 🟡 The error path in _domain_error can itself raise on mixed-type columns. sorted(bad.unique()) — bad holds the original values (pre-to_numeric), so a column containing both a stray string and an out-of-range number gives sorted(['A', 7.0]) → TypeError, masking the intended ValueError. sorted(map(str, bad.unique())) keeps the message and is total.
4. 🟢 LJCW universe asymmetry. The universe check refuses a nonzero NOEMP off WKSWORK > 0, but a row with WKSWORK > 0 and LJCW = 0 passes through with class_of_worker = "niu". If the dictionary states the same universe for LJCW, the symmetric check would catch a malformed file the current one misses; if it doesn't, a comment saying so would prevent someone "fixing" it later.
5. 🟢 Perf/memory nit. pd.read_csv(person_path, usecols=None, low_memory=False) loads all ~800 ASEC columns to then keep 8. Reading with usecols=lambda c: c in set(_REQUIRED_COLUMNS) keeps the friendly missing-column error (check what actually arrived afterward) and cuts peak memory ~100× on the real files.
Also noted: I_NOEMP and MARSUPWT get no domain validation (negative weights would flow into firm_size_tabulation silently) — fine to defer, but worth a line in the docstring if intentional.
Finding 1 is the only one I'd hold the merge for; everything else is polish. The firm_size_tabulation shape (weighted persons + allocated_share per cell) is exactly what the C2 banding memo on the #195 side wants to cite — once both land I'll wire it into the reconciliation table.
🤖 Generated with Claude Code
|
Thanks — all five addressed in 56e2bb3:
Also took the deferred suggestion since it was two lines: 🤖 Generated with Claude Code |
vahid-ahmadi
left a comment
There was a problem hiding this comment.
All five addressed and verified in the new diff — the 22-digit PERIDNUM regression test is exactly the right pin, and the two-regime universe verification on point 4 goes beyond what I asked. CI green. One merge-order note: with your +22 and #195's +54, whichever lands second sets unit to 486 (410 + 22 + 54).
🤖 Generated with Claude Code
|
Automated review pass (full read of the reader, band regimes, and tests; suite run locally since fork PRs don't get pytest/lint CI here — 29 pass / 1 skip, black + ruff clean). Verdict: mergeable quality. The reader is solid — band-regime maps check out against the NOEMP vintages, and the PERIDNUM-as-string fix is right. Two things before merge:
One heads-up for the C2 seam (not blocking this PR): this reader emits raw Census NOEMP codes (0–6) + native labels, while #195's 🤖 Generated with Claude Code |
Workstream A week-1 piece of the employer-firm plan (PolicyEngine#192): a regime-aware reader for the ASEC firm-size label. NOEMP keeps the same 0:6 code domain every year while codes 2-3 change meaning (10-49/50-99 in 2011-2018, 10-24/25-99 in 2019+, verified against all fifteen Census data dictionaries 2011-2025), so the reader hard-codes the per-year map, refuses unverified years and path/year mismatches, and enforces the WKSWORK>0 universe and code domains at read time. Records carry LJCW class of worker, longest-job industry, the I_NOEMP allocation flag, and MARSUPWT; firm_size_tabulation emits the weighted band evidence the C2 banding decision consumes. Staging follows the PSID pattern (~/PolicyEngine/asec-data, POPULACE_DYNAMICS_ASEC_DIR override). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…or paths - Read PERIDNUM as string: 22-digit ids overflow int64 and a float64 fallback rounds distinct persons together before astype(str); adds a last-digit-apart fixture test and a uniqueness assertion to the real-data pass. - Enforce the shared NOEMP/LJCW universe symmetrically (nonzero exactly where WKSWORK > 0); the 2011-2018 dictionaries state it as WORKYN = 1 and 2019+ as WKSWORK > 0, which coincide. - Make _domain_error total on mixed-type columns (sorted(map(str, ...))). - Validate I_NOEMP (0-9) and MARSUPWT (non-negative) domains. - Read only the eight required columns (usecols) instead of all ~800. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Refuse blank or duplicated PERIDNUM (the join key now gets the same refuse-on-mismatch treatment as every coded column; NaN ids vanish silently from downstream groupbys). - Reject non-integral code values (2.9 truncated to band 10_24 with no error) and non-finite weights. - Move WEIND/INDUSTRY into the domain loop: friendly errors instead of raw pandas cast failures, and validation no longer depends on the universe filter. - firm_size_tabulation: keep NaN group keys (dropna=False), NaN allocated_share for zero-weight groups, documented. - Friendly message for an empty staged file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
f8b6093 to
9e0754e
Compare
|
Rebased onto master (57e0baa, post-#197) and recounted with the full-collection policy test on the merged tree: On the C2 seam heads-up: agreed and tracked — the adapter + seam test proposal is in the #195 review; this side holds raw NOEMP codes until that contract lands. 🤖 Generated with Claude Code |
|
Superseded by #204 — same commits on an in-repo branch, so CI can actually report (upstream Actions never received this fork's PR events). All review feedback from this thread is addressed at the new head. |
…rdening
Fixes the two critical findings and the should-address items from the
branch review (daphnehanse11, MaxGhenis):
banding.py
- Split the CPS person-side seam: `cps_firmsize_to_canonical` now takes
an explicit `coding` ("ipums_firmsize" | "census_noemp") argument and
a new `noemp_to_canonical` entry point encodes the raw Census NOEMP
code set (#194). The same integer means different bands under each
coding (e.g. code 4, 2011-2018: NOEMP -> 100-499, FIRMSIZE -> 10-49),
so the mapper refuses to guess. (Joint contract: #194's loader emits
NOEMP and must call the census_noemp route.)
- Per-vintage valid IPUMS code sets: vintage-impossible codes now raise
(e.g. `cps_firmsize_to_canonical(3, 2023)`, code 3 is 1988-1991-only)
instead of silently borrowing another vintage's interval; pre-1992
years are refused.
- `_span` guards inverted intervals; `band_of_count` rejects
non-integral counts with ValueError; `lehd_firmsize_to_canonical`
raises KeyError (not ValueError) on non-numeric input.
targets.py
- `load_bds_firm_size` pins the full 19-column list (was first 5), so a
corrupted tail column fails loudly instead of coercing to NaN.
- The lru_cached loaders now return a fresh `.copy()` per call; a caller
mutating a loaded frame can no longer corrupt a later load.
- QWI hire/separation rates guard the EmpTotal denominator with `.where`,
matching the J2J convention (empty cell -> NaN, not inf).
fetch script + provenance
- QWI/J2J sha256 pins now point at release-stamped R2026Q1 directories,
not the mutable `latest_release` alias, so the extracts stay
byte-reproducible after LEHD rotates releases.
- Downloads land in a temp file renamed on success (no partial-file
misdiagnosis); the >1 MB size check raises instead of a bare assert.
ADR 0003
- C1 `person_id` is an opaque string key, not int (PERIDNUM overflows
int64 / rounds in float64, per #194).
- Records the NOEMP-vs-FIRMSIZE coding distinction as C2 point 5.
tests
- +21 unit tests: the NOEMP/FIRMSIZE coding disagreement, vintage-
impossible rejection, per-table code-domain completeness pins, loader
copy-isolation, SUSB per-sector detail totals, and the helper edge
cases. tier_counts unit 464 -> 485.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pipeline (#192) (#195) * Canonical firm-size banding (C2) + ADR 0003 with C1 spell schema Workstream B week-1 deliverable (issue #192): the canonical administrative-enterprise-size band set (edges 10/50/100/500, 50 mandatory for ACA/state-mandate thresholds), with total, explicitly ambiguity-carrying mappings from CPS ASEC FIRMSIZE (both the 2011-2018 and standard vintages), SIPP EJB1_EMPSIZE, SUSB detailed ENTRSIZE classes, LEHD QWI/J2J firm-size codes, and BDS fsize categories. ADR 0003 records C2, the C1 spell schema (incl. class_of_worker and the person-table geography join), the firm-size x tenure conditioning bridge (SIPP 2008 panel), and the target/gate partition rule with the QWI job-vs-person adjustment as a pre-registered C3 item, folding in the four contract-affecting week-1 review findings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Employer-firm target extracts: SUSB/BDS/QWI/J2J with provenance Reproducible fetch script (pinned URLs + sha256 of the 2026-07-14 raw downloads) writing four small aggregate extracts (<1 MB each): SUSB 2022 enterprise-size x sector, BDS 1978-2022 firm-size series, QWI R2026Q1 national firm-size x sector flows (all-sex/all-age margin), and J2J R2026Q1 national firm-size x sector flows (NAICS 92 dropped: firm size undefined for public-sector employers). The Census data API now requires a key, so the pinned sources are the keyless static files on www2.census.gov / lehd.ces.census.gov. Provenance note records URLs, vintages, transformations, and the jobs-not-persons / mean-earnings unit caveats. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Label-verified loaders for the employer-firm target extracts ons_rates.py-pattern loaders with pinned totals (SUSB US employment and firm counts), schema checks, LEHD status-flag-aware missingness validation, and derived per-job quarterly rates bounded to [0, 1]. Tests pin the accounting identities (SUSB detail sums to total, BDS DHS reallocation identity, J2J flow-nesting inequalities, E7 earnings-gradient sign) and cross-check extract labels against the banding intervals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address branch review: CPS standard-table property coverage, J2J universe caveat - Register CPS_FIRMSIZE_INTERVALS_STANDARD in the generic banding property tests (totality, span containment), with the code-3 vintage-exclusive overlap waived explicitly. - ADR 0003: record the J2J (oslp) vs SUSB/QWI (private-only) employer-universe mismatch as a third pre-registered unit rule for the C3 cell definitions. - Note the Census SUSB size-code gap (20/21 unused, 31 = 1,000-1,499). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Register the 54 new firms tests in the tier manifest (unit: 410 -> 464) The tier policy auto-classifies both new modules as unit; only the committed count needed updating. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ADR 0003: record the SNAP ABAWD hours-granularity deferral in C1 Registers the downstream consumer from issue #192 and makes the hours gap an explicit contract limitation with a scheduled first amendment, per the C1 honesty request. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: NOEMP/FIRMSIZE coding seam, vintage guards, loader hardening Fixes the two critical findings and the should-address items from the branch review (daphnehanse11, MaxGhenis): banding.py - Split the CPS person-side seam: `cps_firmsize_to_canonical` now takes an explicit `coding` ("ipums_firmsize" | "census_noemp") argument and a new `noemp_to_canonical` entry point encodes the raw Census NOEMP code set (#194). The same integer means different bands under each coding (e.g. code 4, 2011-2018: NOEMP -> 100-499, FIRMSIZE -> 10-49), so the mapper refuses to guess. (Joint contract: #194's loader emits NOEMP and must call the census_noemp route.) - Per-vintage valid IPUMS code sets: vintage-impossible codes now raise (e.g. `cps_firmsize_to_canonical(3, 2023)`, code 3 is 1988-1991-only) instead of silently borrowing another vintage's interval; pre-1992 years are refused. - `_span` guards inverted intervals; `band_of_count` rejects non-integral counts with ValueError; `lehd_firmsize_to_canonical` raises KeyError (not ValueError) on non-numeric input. targets.py - `load_bds_firm_size` pins the full 19-column list (was first 5), so a corrupted tail column fails loudly instead of coercing to NaN. - The lru_cached loaders now return a fresh `.copy()` per call; a caller mutating a loaded frame can no longer corrupt a later load. - QWI hire/separation rates guard the EmpTotal denominator with `.where`, matching the J2J convention (empty cell -> NaN, not inf). fetch script + provenance - QWI/J2J sha256 pins now point at release-stamped R2026Q1 directories, not the mutable `latest_release` alias, so the extracts stay byte-reproducible after LEHD rotates releases. - Downloads land in a temp file renamed on success (no partial-file misdiagnosis); the >1 MB size check raises instead of a bare assert. ADR 0003 - C1 `person_id` is an opaque string key, not int (PERIDNUM overflows int64 / rounds in float64, per #194). - Records the NOEMP-vs-FIRMSIZE coding distinction as C2 point 5. tests - +21 unit tests: the NOEMP/FIRMSIZE coding disagreement, vintage- impossible rejection, per-table code-domain completeness pins, loader copy-isolation, SUSB per-sector detail totals, and the helper edge cases. tier_counts unit 464 -> 485. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Close Max's pre-freeze residual: make `coding` a required keyword Addresses the re-review on #195 (MaxGhenis, APPROVED with one residual + three nits): - banding.py: `cps_firmsize_to_canonical` no longer defaults `coding`. Three codes (4/6 in 2011-2018, 5 in 2019+) are valid integers under *both* the IPUMS FIRMSIZE and Census NOEMP codings with different bands, so a default let a NOEMP-holding caller mis-band silently. It is now a required keyword — the footgun is closed in code, not just in the ADR contract. New `test_cps_coding_is_required` pins it. - Module docstring: the collision example now uses same-vintage code 4 (2011-2018: NOEMP 100-499 vs IPUMS 10-49), clearer than the prior cross-vintage code-2 example. - ADR C2.1: "CPS ASEC NOEMP/FIRMSIZE" now reads "under either coding" and points at C2.5. - Fetch script J2J docstring: "2015Q2 on" -> "2015Q1 on" to match the `year >= 2015` filter and the provenance note. - tests updated to pass explicit coding; tier_counts unit 509 -> 510. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * C2 banding: correct the phantom 2019+ label; close review F1-F3 Implements @daphnehanse11's real-data finding (#192, mirrored by #204's NOEMP_BANDS unification) and closes her re-review findings on #195: - Band-label correction: the 2019+ ASEC "10-24"/"25-99" dictionary labels are a phantom relabeling — the instrument collected 10-49/50-99 continuously since 2011 (discontinuity + SUSB evidence). CPS_NOEMP_ INTERVALS_2019_PLUS codes 2/3 and CPS_FIRMSIZE_INTERVALS_STANDARD codes 2/5 now carry 10-49/50-99, so every ASEC vintage resolves the 50 edge exactly and no CPS firm-size code returns an inexact span. ADR C2.3/C2.4 updated: the 50-cut is directly observed, the two identification routes are no longer needed; replication artifact tracked for the C3 record. - F1: ADR C2.5 no longer describes an "ipums_firmsize default" — it documents the required-keyword signature 85584a3 shipped, and uses the same-vintage code-4 collision example. - F2: band_of_count accepts numbers.Integral (numpy int dtypes from a Series.map survive) and whole-valued reals; bool still rejected. - F3: the IPUMS FIRMSIZE leg refuses pre-2011 years, symmetric with the NOEMP leg (only the 2011+ era is dispatchable). Tests updated (post-2019 code 5 now exact; NOEMP 2019+ matches 2011-2018; numpy-int band_of_count; 1992-2010 refusal); 77 passed, ruff/black clean. The ADR C2 prose implements Daphne's finding; flagging it for her co-sign since she owns the evidence (offered to co-draft on #192). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ADR C2.4: cite the committed evidence artifact (#211, runs/) for the band finding Per the re-review nit: the replication is derived evidence, not a source extract, so it lives under runs/ (reported-anchor convention) rather than data/external/ — committed in #211 with its build script and pinning tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Daphne Hansell <128793799+daphnehanse11@users.noreply.github.com>
…192) ADR 0003 froze IC1 as "one tidy table, written by workstream A, read by workstream B", but nothing in the repository enforced it. This adds the schema, its validator, and the adapter from the SIPP spell reader, so the two sides meet at a checked contract instead of a convention. firms/assignment.py consumes IC1; a mis-shaped frame now fails here rather than producing a plausible-looking roster. Three contract rules are enforced rather than documented: - The column set is exact, not a minimum. An hours column is rejected by name with its own message, because IC1's hours deferral is live (the registered consumer is SNAP ABAWD compliance, whose 80-hours-per-month test needs month-resolved hours) and a consumer finding an hours column would reasonably assume it was ratified. Adding one is the first scheduled amendment, by joint PR. - person_id must be an opaque string. The ASEC PERIDNUM is 22 digits, so int64 overflows and float64 rounds distinct persons together (#194 review). A numeric key silently merges people. - Self-employed and unpaid-family spells carry no firm-size band. That is a category error, not a missing value, and from_sipp_spells clears any band the raw SIPP slot carried through. calibration_universe applies ADR 0003's universe rule explicitly: private-sector spells only, because SUSB excludes government establishments, NAICS 92, crop/animal production and non-employers, and QWI in-scope jobs are non-federal. Calibrating against jobs the targets never counted would bias every margin by the excluded share. Dropped counts are recorded on attrs so the exclusion is visible in any artifact built from the result. from_sipp_spells performs a named, lossy promotion rather than a rename: SIPP 2014+ measures establishment size at the worker's location while IC2's canonical variable means enterprise size, so the adapter records the proxy on attrs["size_concept"]. That promotion is the most consequential approximation on the person side and it is not hidden behind a column name. Verified end to end against the real calibrated firm frame: 5,000 IC1 spells validate, the calibration universe drops federal and self-employed spells with counts recorded, and the survivors assign to observed firm instances at rate 1.0000 with observed_link=False. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #193 (Workstream A week-1 piece of #192).
What
data/asec_firm_size.py: a label-verified reader for the CPS ASEC firm-size variable (NOEMP) — the employer-firm plan's designated firm-size training label — following thefamily.pypattern of dictionary-adjudicated per-year maps verified at read time, plusfirm_size_tabulation, the weighted evidence artifact the C2 banding decision consumes.Why regime-aware
NOEMP keeps the identical code domain
(0:6)in every year while codes 2–3 silently change meaning. Verified against all fifteen Census public-use data dictionaries (2011–2025, www2.census.gov/programs-surveys/cps/datasets, read 2026-07-14):A year-blind read mis-bands those codes with no error, and the regime break is what hides the ACA 50-FTE threshold post-2019 (C2 discussion on #192). The reader hard-codes the per-year map, refuses unverified years, refuses a
pppub{yy}filename that contradicts the requested year, and enforces theWKSWORK > 0universe and dictionary code domains at read time.Record contents
One row per person in the longest-job-last-year universe: raw code + regime-labeled band,
I_NOEMPallocation flag,LJCWclass of worker (labeled — SUSB/QWI calibration universes exclude government and self-employment, see the C1 discussion on #192), longest-job industry (major + detailed),WKSWORK,MARSUPWT.Staging
Raw microdata stays out of the repo, PSID-style: Census person files as
pppub{yy}.csv[.gz]under~/PolicyEngine/asec-data,POPULACE_DYNAMICS_ASEC_DIRoverride.Tests
17 tests (unit tier;
tier_counts.json410 → 427): both regimes' banding, regime boundaries (2011/2018/2019/2025), unverified-year and path/year-mismatch refusal, missing-column / out-of-domain / off-universe failures, allocation and class-of-worker flags, env-var staging, tabulation weighted counts + allocated share, and a skipif-gated real-data pass. Full unit tier: 424 passed, 3 skipped.Non-goals
No band harmonization across regimes (that is C2's decision, which this feeds), no imputation, no C1 spell emission, no tenure-supplement loader (separate piece).
🤖 Generated with Claude Code