Skip to content
11 changes: 10 additions & 1 deletion scripts/first_estimates_birth_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@
)
POST_REVIEW_SOURCE_EXCLUSIONS = (
Path("src/populace_dynamics/artifacts.py"),
Path("src/populace_dynamics/data/asec_firm_size.py"),
Path("src/populace_dynamics/data/sipp_jobs.py"),
Path("src/populace_dynamics/firms/banding.py"),
Path("src/populace_dynamics/firms/targets.py"),
# Entry-11 PSID data-layer additions are downstream source readers and
# registries. They are outside the reviewed birth-evidence projection
Expand All @@ -157,7 +160,13 @@
POST_REVIEW_SHARED_SOURCE_BLOBS = {
Path(
"src/populace_dynamics/artifacts.py"
): "c03afa29cbdaf722c2cf62608dbb01f061f6558d"
): "c03afa29cbdaf722c2cf62608dbb01f061f6558d",
Path(
"src/populace_dynamics/data/asec_firm_size.py"
): "dee60e8c330833ef4944dd5033c213915b416fa9",
Path(
"src/populace_dynamics/firms/banding.py"
): "e488ac266da29d2b5259575be0a7402fdbab73ab",
}
IMPLEMENTATION_REPLAY_ROWS = {
"birth_source.derived_projection_age": 4_077,
Expand Down
47 changes: 46 additions & 1 deletion src/populace_dynamics/data/asec_firm_size.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,15 @@
import numpy as np
import pandas as pd

from populace_dynamics.firms.banding import noemp_to_canonical

__all__ = [
"ASEC_FIRM_SIZE_YEARS",
"CLASS_OF_WORKER_LABELS",
"NOEMP_BANDS",
"firm_size_tabulation",
"noemp_band_map",
"noemp_canonical_map",
"read_asec_firm_size",
]

Expand Down Expand Up @@ -144,6 +147,42 @@ def noemp_band_map(year: int) -> dict[int, str]:
return dict(NOEMP_BANDS)


def noemp_canonical_map(year: int) -> dict[int, str]:
"""Return the NOEMP code -> canonical IC2 band-label map.

The IC2 seam (#192 step 2; #208). Bands are **not** re-derived
here: every label comes from
:func:`populace_dynamics.firms.banding.noemp_to_canonical`, so
the person side and the target side share one vocabulary and one
definition of the 50 edge. :data:`NOEMP_BANDS` is retained as the
source-resolution label (it splits 500-999 from 1000+, a
distinction canonical IC2 does not carry); ``firm_size_band`` and
``canonical_band`` are therefore both emitted, at different
coarseness, rather than one being derived from the other's
string.

Raises:
ValueError: If ``year`` is outside the verified range.
"""
_check_supported_year(year)
labels: dict[int, str] = {}
for code in NOEMP_BANDS:
span = noemp_to_canonical(code, year)
# Total by construction: NOEMP 1-6 are firm-size reports in
# every supported vintage, so the NIU ``None`` branch is
# unreachable here and an inexact span would be an IC2 defect
# (every NOEMP band nests a canonical band) — assert rather
# than silently emitting a straddle label.
if span is None or not span.exact:
raise AssertionError(
f"NOEMP {code} in ASEC {year} did not resolve to a "
f"single canonical band (got {span!r}); the IC2 "
"mapping and this reader have diverged."
)
labels[code] = span.label
return labels


def _resolve_data_dir(data_dir: Path | None) -> Path:
"""Resolve the ASEC data directory from arg, env var, default."""
if data_dir is not None:
Expand Down Expand Up @@ -216,7 +255,9 @@ def read_asec_firm_size(
One row per person in the NOEMP universe (``WKSWORK > 0``,
i.e. worked last calendar year), with columns ``person_id``,
``year``, ``income_year``, ``noemp``,
``firm_size_band``, ``noemp_allocated``, ``ljcw``,
``firm_size_band`` (source-resolution label),
``canonical_band`` (the IC2 vocabulary, via
``firms/banding.py``), ``noemp_allocated``, ``ljcw``,
``class_of_worker``, ``industry_major``,
``industry_detailed``, ``wkswork``, and ``weight``
(``MARSUPWT / 100`` — the raw column carries two implied
Expand All @@ -236,6 +277,7 @@ def read_asec_firm_size(
FileNotFoundError: If no staged person file can be found.
"""
bands = noemp_band_map(year)
canonical = noemp_canonical_map(year)
if path is not None:
person_path = Path(path).expanduser()
if not person_path.exists():
Expand Down Expand Up @@ -352,6 +394,9 @@ def read_asec_firm_size(
# Total mappings: the domain + universe checks guarantee
# NOEMP in 1-6 and LJCW in 1-7 here, so no fallback.
"firm_size_band": universe["NOEMP"].map(bands),
# The IC2 seam: canonical labels straight from
# firms/banding.py, never re-derived here (#208).
"canonical_band": universe["NOEMP"].map(canonical),
"noemp_allocated": universe["I_NOEMP"] > 0,
"ljcw": universe["LJCW"],
"class_of_worker": universe["LJCW"].map(CLASS_OF_WORKER_LABELS),
Expand Down
58 changes: 56 additions & 2 deletions src/populace_dynamics/data/sipp_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,15 @@
import numpy as np
import pandas as pd

from populace_dynamics.firms.banding import sipp_empsize_to_canonical

__all__ = [
"SIPP_JOB_YEARS",
"MAX_JOB_SLOTS",
"CLWRK_LABELS",
"JBORSE_LABELS",
"EMPSIZE_CANONICAL_SPANS",
"EMPSIZE_CANONICAL_EXACT",
"read_sipp_job_months",
"job_spells",
]
Expand Down Expand Up @@ -87,6 +91,32 @@
#: live in firms/banding.py, verified on #195).
_EMPSIZE_CODES = frozenset(range(1, 9))

#: EMPSIZE code -> canonical band-span label, straight from
#: :func:`populace_dynamics.firms.banding.sipp_empsize_to_canonical`
#: (the IC2 seam, #192 step 2 / #208). **This is establishment size,
#: not firm size** (#192 finding 1): SIPP's redesign dropped the
#: all-locations question, so these labels describe the worker's
#: location and are a proxy-chain input to firm size, never a
#: substitute for it. The column is named accordingly.
#:
#: SIPP's inclusive upper bounds ("10 to 25", "26 to 50", ...)
#: straddle the canonical 10/50/100/500 edges, so most codes yield an
#: *inexact* span rendered as e.g. ``"10-49|50-99"``. That is the
#: contract: an ambiguous source band surfaces as an unmatchable
#: category rather than being collapsed to a plausible wrong band.
EMPSIZE_CANONICAL_SPANS: dict[int, str] = {
code: span.label
for code in sorted(_EMPSIZE_CODES)
if (span := sipp_empsize_to_canonical(code)) is not None
}

#: EMPSIZE code -> whether its canonical span is a single band.
EMPSIZE_CANONICAL_EXACT: dict[int, bool] = {
code: span.exact
for code in sorted(_EMPSIZE_CODES)
if (span := sipp_empsize_to_canonical(code)) is not None
}

_MISSING = -9
_MISSING_ID = -999

Expand Down Expand Up @@ -238,8 +268,16 @@ def read_sipp_job_months(
size — passed through **raw**: valid codes 1-8, the -9
sentinel for item nonresponse on employer jobs, and NaN for
self-employment/other arrangements whose establishment size
is structurally NIU; downstream consumers such as
``firms/banding.py`` must expect that mix), ``industry``
is structurally NIU; ``firms/banding.py`` expects that mix),
``estab_size_band``/``estab_size_band_exact`` (the canonical
IC2 span for that code, from ``firms/banding.py`` — NaN both
wherever ``empsize_code`` is NaN *and* at the -9 item-
nonresponse sentinel, which has no band; **establishment**
size, so not interchangeable with the ASEC reader's
``canonical_band``. ``estab_size_band_exact`` is object-dtype
True/False/NaN, so ``~df[...]`` and truthiness tests mask the
NaNs as if they were straddles — compare explicitly),
``industry``
(string, passed through **unvalidated** — a full
Census-industry allow-list is impractical, so sentinels can
appear here and flow into ``job_spells``'s modal logic),
Expand Down Expand Up @@ -432,6 +470,11 @@ def read_sipp_job_months(
"clwrk": clwrk,
"jborse": jborse,
"empsize_code": empsize,
# The IC2 seam. Establishment size, not firm size —
# see EMPSIZE_CANONICAL_SPANS. NaN where EMPSIZE is
# missing or the slot has no establishment.
"estab_size_band": empsize.map(EMPSIZE_CANONICAL_SPANS),
"estab_size_band_exact": empsize.map(EMPSIZE_CANONICAL_EXACT),
"industry": raw[f"TJB{n}_IND"],
"earnings": earnings.where(earnings != _MISSING_ID),
"age": raw["TAGE"],
Expand Down Expand Up @@ -510,6 +553,10 @@ def job_spells(job_months: pd.DataFrame) -> pd.DataFrame:
within person), ``start_year``/``start_month``,
``end_year``/``end_month``, ``n_months``, ``job_id``,
``industry``/``empsize_code``/``class_of_worker`` (modal),
``estab_size_band`` (the canonical span of the modal
``empsize_code``; no ``_exact`` companion is emitted here, so
a spell consumer must detect a straddle by looking for the
``"|"`` separator in the label),
``attributes_constant`` (False when any of the three varied
within the spell — surfaced, never silently averaged),
``total_earnings``, ``earnings_share`` (spell earnings over
Expand Down Expand Up @@ -551,6 +598,7 @@ def job_spells(job_months: pd.DataFrame) -> pd.DataFrame:
"job_id",
"industry",
"empsize_code",
"estab_size_band",
"class_of_worker",
"attributes_constant",
"total_earnings",
Expand Down Expand Up @@ -631,6 +679,12 @@ def job_spells(job_months: pd.DataFrame) -> pd.DataFrame:
"job_id": int(job_id),
"industry": modal["industry"],
"empsize_code": modal["empsize_code"],
# Derived from the *modal code*, not by taking a mode
# of labels: one source (firms/banding.py), and the
# band can never disagree with the code beside it.
"estab_size_band": EMPSIZE_CANONICAL_SPANS.get(
modal["empsize_code"]
),
"class_of_worker": modal["class_of_worker"],
"attributes_constant": bool(constant),
"total_earnings": (
Expand Down
44 changes: 44 additions & 0 deletions src/populace_dynamics/firms/banding.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@
"CanonicalBand",
"BandSpan",
"CANONICAL_BANDS",
"CANONICAL_BAND_LABELS",
"SPAN_LABEL_SEPARATOR",
"band_of_count",
"cps_firmsize_to_canonical",
"noemp_to_canonical",
Expand Down Expand Up @@ -124,9 +126,40 @@ def lo(self) -> int:
def hi(self) -> float:
return self.value[1]

@property
def label(self) -> str:
"""Stable display label for the band (reader-level vocabulary).

Readers emit this string, never a reader-local band name, so
that person-side and target-side *reader* band columns share
one vocabulary (#192 step 2; the seam #208 left latent).

This is a reader/display-level convention, **not** the frozen
contract's wire type: the contract defines ``firm_size_band``
as the :class:`CanonicalBand` enum, and only an ADR 0003
amendment can make the string the wire format.
"""
return CANONICAL_BAND_LABELS[self]


CANONICAL_BANDS: tuple[CanonicalBand, ...] = tuple(CanonicalBand)

#: The canonical band vocabulary. These strings — not any reader's
#: local band names — are what every **reader band column** carries.
#: The frozen contract's ``firm_size_band`` remains the
#: :class:`CanonicalBand` enum; these labels are the display/reader
#: rendering of it, and are not an alternative wire type.
CANONICAL_BAND_LABELS: dict[CanonicalBand, str] = {
CanonicalBand.LT10: "1-9",
CanonicalBand.B10_49: "10-49",
CanonicalBand.B50_99: "50-99",
CanonicalBand.B100_499: "100-499",
CanonicalBand.B500_PLUS: "500+",
}

#: Separator joining the band labels of an inexact (straddling) span.
SPAN_LABEL_SEPARATOR = "|"


@dataclass(frozen=True)
class BandSpan:
Expand All @@ -153,6 +186,17 @@ def band(self) -> CanonicalBand:
)
return self.bands[0]

@property
def label(self) -> str:
"""Wire label: the band label, or the straddled run joined.

An inexact span renders as e.g. ``"10-49|50-99"`` rather than
collapsing to one band, so a consumer that silently treats it
as exact produces an unmatchable category instead of a
plausible wrong one.
"""
return SPAN_LABEL_SEPARATOR.join(b.label for b in self.bands)


def band_of_count(n: int) -> CanonicalBand:
"""Canonical band containing an exact employment count ``n >= 1``.
Expand Down
4 changes: 2 additions & 2 deletions tests/README-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ pytest --collect-only -q -m oracle_policyengine | tail -1

| Tier | Tests at HEAD |
|---|---:|
| `unit` | 1,511 |
| `unit` | 1,563 |
| `artifact` | 2,543 |
| `integration_psid` | 848 |
| `reproduction_legacy` | 520 |
| `oracle_policyengine` | 159 |
| **Total** | **5,581** |
| **Total** | **5,633** |
Loading