From 83cac3cd1c4e1304b69965b7222e334b84fb77f5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 15:03:31 -0400 Subject: [PATCH 1/9] U2 births: count out seeds outside the birth law's support (d1724) The d1649 step-4 pass stopped in derive_u2_births: career's derived-age clause asserts a seed-derived birth year in [1889, 2016], and U2's universe takes every positive-weight in-family person at any age. Before the law runs, count out each universe person whose birth year only the seed coordinate could give (the law, run without seeds, leaves them unresolved) and whose seed implies a year before 1889 or after 2016. The law's precedence is unchanged: a marriage-history or earnings year wins whatever the seed implies. Seed codes 1, 999 and 0 stay on their existing paths. A counted-out person never re-enters through the annuitant extension, whose own row filter already keeps it inside the support. Each side's count is disclosed per row as n_seed_birth_year_counted_out and decides nothing. career.py is unchanged. Two invented variants (outside and at the bounds) and a new test module cover both sides, the 1889 and 2016 boundaries, the disclosed counts, that nothing else changes, and a per-person career oracle property. Co-Authored-By: Claude Opus 5.5 --- scripts/track_u2_structure.py | 5 + .../uniform_cut_track_u2/cohort.py | 114 ++- .../uniform_cut_track_u2/invented.py | 166 +++++ tests/track_u2/test_u2_birth_bounds.py | 671 ++++++++++++++++++ tests/track_u2/test_u2_scripts.py | 2 + 5 files changed, 948 insertions(+), 10 deletions(-) create mode 100644 tests/track_u2/test_u2_birth_bounds.py diff --git a/scripts/track_u2_structure.py b/scripts/track_u2_structure.py index 3a08ee55f..a41e5f0e2 100644 --- a/scripts/track_u2_structure.py +++ b/scripts/track_u2_structure.py @@ -7,6 +7,11 @@ committed-registry role context and writes ``track-u2-structure.json``: dispositions, observations, roles, pairings, design strata, the family income and WEALTH1 identity counts per wave and the registry status. +Each row's counts include ``n_seed_birth_year_counted_out``: the persons +counted out before the birth law on each side of its derived-age +support, before 1889 and after 2016 (d1724, specification section 16d; +:func:`populace_dynamics.uniform_cut_track_u2.cohort.derive_u2_births`), +disclosed and deciding nothing. It also writes ``track-u2-preregistration-evidence.json``: the frozen input identities section 14 says execution rechecks -- the SHA-256 of diff --git a/src/populace_dynamics/uniform_cut_track_u2/cohort.py b/src/populace_dynamics/uniform_cut_track_u2/cohort.py index b70984f0f..c51fbc3ef 100644 --- a/src/populace_dynamics/uniform_cut_track_u2/cohort.py +++ b/src/populace_dynamics/uniform_cut_track_u2/cohort.py @@ -29,7 +29,12 @@ U1's per-row ``earliest_presence_wave``, which :class:`U2CohortSpec` refuses). Births are derived once (:func:`derive_u2_births`) and reused by every row. Support waves identify people; they add no observation -and no design unit to a row. +and no design unit to a row. Max's ruling of 2026-10-19 (d1724; section +16d) counts out, before the law runs, each person whose birth year the +law could take only from the seed coordinate and whose seed implies a +year outside the law's derived-age support +(``career.DERIVED_BIRTH_MIN``-``DERIVED_BIRTH_MAX``, 1889-2016), as +Track M does; each side's count is disclosed and decides nothing. **Roles** (section 3 relationship-code amendment) come from a :class:`~populace_dynamics.uniform_cut_track_u2.sources.RoleContext`. @@ -126,6 +131,7 @@ "PSID_FILES", "SNAPSHOT_KEY", "ROWS", + "SEED_BIRTH_COUNTED_OUT", "SEED_WAVE_RULE", "SUPPORT_WAVES", "TARGET_AGE", @@ -162,6 +168,17 @@ #: changes on U0's population (:mod:`.rows`). ROWS: tuple[str, ...] = ("U0", "U1") SEED_WAVE_RULE = "earliest_presence_in_common_support_waves" +#: d1724 (section 16d): the two sides of the birth law's derived-age +#: support. ``career.derive_birth_years`` asserts that a birth year it +#: derives from a seed coordinate (age code 2-125) lies in +#: ``[DERIVED_BIRTH_MIN, DERIVED_BIRTH_MAX]``; ``career.py`` is read-only +#: (section 14), so :func:`derive_u2_births` counts out, before the law, +#: each person the seed clause would decide and whose seed implies a year +#: on either side. +SEED_BIRTH_COUNTED_OUT: tuple[str, str] = ( + f"before_{career.DERIVED_BIRTH_MIN}", + f"after_{career.DERIVED_BIRTH_MAX}", +) U1_EVEN_BIRTH_YEAR_WEIGHT = 0.5 U1_SINGLE_OBSERVATION_WEIGHT = 1.0 U1_EVEN_BIRTH_AGES: tuple[int, int] = (66, 68) @@ -198,6 +215,10 @@ ("died", 81, 89), ) _MAX_AGE_CODE = 125 +#: The seed age codes career's derived-age clause turns into a birth year +#: (``seed year - age``); 1, 999 and missing are unresolved there, and any +#: other code is refused there. +_DERIVED_SEED_AGES = (2, 125) _FAMILY_UNIT_SCALE = 100_000 _UNRESOLVED_STATES = ("unknown", "no_marriage_history") _OMITTED_ROWS = ( @@ -955,12 +976,22 @@ def _check_role_context( # =========================================================================== @dataclass(frozen=True) class U2Births: - """Birth-year records for the universe and the annuitant extension.""" + """Birth-year records for the universe and the annuitant extension. + + ``counted_out`` maps each side of :data:`SEED_BIRTH_COUNTED_OUT` to the + persons counted out of the universe before the law ran (d1724); they + have no record, so their birth year is ``None``. + """ records: Mapping[int, career.BirthYearRecord] universe: frozenset[int] seed: pd.DataFrame inputs_sha256: str + counted_out: Mapping[str, frozenset[int]] = field( + default_factory=lambda: MappingProxyType( + {side: frozenset() for side in SEED_BIRTH_COUNTED_OUT} + ) + ) def birth_year(self, person_id: int) -> int | None: record = self.records.get(int(person_id)) @@ -1048,16 +1079,68 @@ def check_person_joins(inputs: U2Inputs) -> None: ) +def _count_out_seed_births( + seed: pd.DataFrame, history: pd.DataFrame, earnings: pd.DataFrame +) -> tuple[pd.DataFrame, dict[str, frozenset[int]]]: + """The seed rows the law can take, and the persons counted out (d1724). + + The law's derived-age clause (seed age codes 2-125, year ``seed year + - age``) asserts a year in ``[career.DERIVED_BIRTH_MIN, + career.DERIVED_BIRTH_MAX]``. It is reached only by a person whose + birth year neither the marriage history nor the observed earnings + give, so the law itself, run on the same frames without seed + coordinates, names those persons (its unresolved records). Each of + them whose seed implies a year outside the support is counted out on + its side. Everyone else keeps the law's precedence: a person with a + history or earnings year keeps it whatever the seed implies, and seed + codes 1, 999 and every other code stay on their existing paths. + """ + + people = set(int(pid) for pid in seed["person_id"]) + clauses = career.derive_birth_years( + history[history["person_id"].isin(people)], + earnings[earnings["person_id"].isin(people)], + required_person_ids=people, + ) + seed_only = { + record.person_id + for record in clauses + if record.source is career.BirthSource.UNRESOLVED + } + ages = seed["age"].astype("int64") + implied = seed["year"].astype("int64") - ages + derived = seed["person_id"].isin(seed_only) & ages.between( + *_DERIVED_SEED_AGES + ) + before = derived & (implied < career.DERIVED_BIRTH_MIN) + after = derived & (implied > career.DERIVED_BIRTH_MAX) + counted_out = { + side: frozenset(int(pid) for pid in seed.loc[rows, "person_id"]) + for side, rows in zip( + SEED_BIRTH_COUNTED_OUT, (before, after), strict=True + ) + } + kept = seed[~(before | after)].reset_index(drop=True) + return kept, counted_out + + def derive_u2_births(inputs: U2Inputs) -> U2Births: """The birth-year law over the common support waves (section 3). Universe: every person with a positive-weight, in-family presence in any support wave; seed coordinate: the earliest such presence - (``(wave - 1) - age``). Annuitant extension: in-family persons of - the administrative birth-support codes (10, 20, 22, 88, 90; not 92) - and marriage-history spouses of target-cohort members outside the - universe, seeded by their earliest in-family presence at any weight; - the law runs without a required population for them, so a conflict + (``(wave - 1) - age``). Before the law runs, a person whose birth + year only the seed could give and whose seed implies a year outside + the law's derived-age support (1889-2016) is counted out of the + universe on that side (d1724, section 16d; + :func:`_count_out_seed_births`); ``counted_out`` holds them, and no + other person's record changes. Annuitant extension: in-family + persons of the administrative birth-support codes (10, 20, 22, 88, + 90; not 92) and marriage-history spouses of target-cohort members + outside the universe, seeded by their earliest in-family presence at + any weight whose implied year the law supports (code 999 kept); a + counted-out person is not outside the universe, so never joins it. + The law runs without a required population for them, so a conflict leaves the person unresolved (the wave-age fallback, counted). """ @@ -1080,10 +1163,12 @@ def derive_u2_births(inputs: U2Inputs) -> U2Births: } ) ) - seed = _seed_frame(seeds) - universe = frozenset(int(pid) for pid in seed["person_id"]) history = inputs.marriage_history earnings = inputs.observed_earnings + presence = _seed_frame(seeds) + present = frozenset(int(pid) for pid in presence["person_id"]) + seed, counted_out = _count_out_seed_births(presence, history, earnings) + universe = frozenset(int(pid) for pid in seed["person_id"]) records = career.derive_birth_years( history[history["person_id"].isin(universe)], earnings[earnings["person_id"].isin(universe)], @@ -1111,7 +1196,7 @@ def derive_u2_births(inputs: U2Inputs) -> U2Births: ) | anchor["person_id"].isin(spouses) ) - & ~anchor["person_id"].isin(universe) + & ~anchor["person_id"].isin(present) ] ages = rows["age"].astype("int64") seed_birth = (wave - 1) - ages @@ -1150,6 +1235,7 @@ def derive_u2_births(inputs: U2Inputs) -> U2Births: universe=universe, seed=seed, inputs_sha256=input_frames_sha256(inputs), + counted_out=MappingProxyType(counted_out), ) @@ -1535,6 +1621,14 @@ def build_u2_cohort( "n_unresolved_birth_year": sum( births.birth_year(pid) is None for pid in outside ), + # d1724 (section 16d): the persons counted out of the universe + # before the birth law, on each side of its derived-age support + # (their seed, their only birth-year source, implies a year + # before 1889 or after 2016). Disclosed; decides nothing. + "n_seed_birth_year_counted_out": { + side: len(births.counted_out[side]) + for side in SEED_BIRTH_COUNTED_OUT + }, "plan_birth_years": plan_years, "role_context": role_context.kind, "births_inputs_sha256": births.inputs_sha256, diff --git a/src/populace_dynamics/uniform_cut_track_u2/invented.py b/src/populace_dynamics/uniform_cut_track_u2/invented.py index 201664d1b..6ad48beca 100644 --- a/src/populace_dynamics/uniform_cut_track_u2/invented.py +++ b/src/populace_dynamics/uniform_cut_track_u2/invented.py @@ -1595,6 +1595,151 @@ def _code_88_target_with_ofum_income_2019( return dataclasses.replace(changed, marriage_history=history) +def _host(base: cohort.U2Inputs, waves: tuple[int, ...]) -> int: + """U1's first observed target who is in-family with a positive weight + in every one of ``waves`` (a family that supplies an observation).""" + + built = cohort.build_u2_cohort( + base, + cohort.U2CohortSpec(row="U1"), + role_context=sources.RoleContext.declared(), + ) + for person in sorted(set(built.observations["person_id"].astype(int))): + if all( + bool( + ( + base.anchors[wave]["person_id"].eq(person) + & base.anchors[wave]["sequence"].between(1, 20) + & base.anchors[wave]["weight"].gt(0) + ).any() + ) + for wave in waves + ): + return person + raise AssertionError(f"no U1 target is present in every wave {waves}") + + +def _added_member( + base: cohort.U2Inputs, + person_id: int, + *, + host: int, + ages: Mapping[int, int], + relationship: int, + sex: str, +) -> cohort.U2Inputs: + """``base`` with one OFUM member in ``host``'s family in each wave of + ``ages``, at that age, with a positive weight, and absent elsewhere. + + The member takes the host's stratum and cluster, so no row's design + frame gains a unit, and the next free sequence number in each family. + """ + + anchors = {} + for wave, frame in base.anchors.items(): + row = { + "person_id": person_id, + "interview": 0, + "sequence": 0, + "relationship": 0, + "age": 0, + "reported_birth_year": pd.NA, + "weight": 0.0, + } + if wave in ages: + interview = int( + frame.loc[frame["person_id"].eq(host), "interview"].iloc[0] + ) + family = frame[ + frame["interview"].eq(interview) + & frame["sequence"].between(1, 20) + ] + row.update( + interview=interview, + sequence=int(family["sequence"].max()) + 1, + relationship=relationship, + age=int(ages[wave]), + weight=500.0, + ) + anchors[wave] = pd.concat( + [frame, pd.DataFrame([row])], ignore_index=True + ).astype(frame.dtypes.to_dict()) + design = base.design.set_index("person_id").loc[host] + design = pd.concat( + [ + base.design, + pd.DataFrame( + [ + { + "person_id": person_id, + "stratum": int(design["stratum"]), + "cluster": int(design["cluster"]), + } + ] + ), + ], + ignore_index=True, + ).astype("int64") + persons = pd.concat( + [base.persons, pd.DataFrame([{"person_id": person_id, "sex": sex}])], + ignore_index=True, + ).astype({"person_id": "int64", "sex": "string"}) + return dataclasses.replace( + base, anchors=anchors, design=design, persons=persons + ) + + +#: The members the two d1724 variants add (section 16d): each is placed +#: by its seed, its earliest positive-weight in-family presence, whose +#: implied birth year is ``(wave - 1) - age``. None has a marriage +#: history or earnings, so the seed is its only birth-year source. Each +#: entry: person id, ``{wave: age}`` (the waves it is present in, in the +#: family of :func:`_host` for those waves), relationship (30 child, 50 +#: parent, 60 grandchild; each OFUM), sex. +_SEED_BIRTH_OUTSIDE_MEMBERS = ( + # 2020 - 3 = 2017: after 2016, counted out (also present in 2023). + (_PERSON_BASE - 11, {2021: 3, 2023: 5}, 60, "female"), + # 2022 - 2 = 2020: after 2016, counted out. + (_PERSON_BASE - 12, {2023: 2}, 30, "male"), + # 2012 - 124 = 1888: before 1889, counted out. + (_PERSON_BASE - 13, {2013: 124}, 50, "female"), +) +_SEED_BIRTH_AT_BOUNDS_MEMBERS = ( + # 2012 - 123 = 1889: the support's first year, kept. + (_PERSON_BASE - 14, {2013: 123}, 50, "male"), + # 2018 - 2 = 2016: the support's last year, kept (later waves agree). + (_PERSON_BASE - 15, {2019: 2, 2021: 4, 2023: 6}, 60, "female"), +) + + +def _with_members( + base: cohort.U2Inputs, members: tuple[tuple[Any, ...], ...] +) -> cohort.U2Inputs: + changed = base + for person_id, ages, relationship, sex in members: + changed = _added_member( + changed, + person_id, + host=_host(base, tuple(sorted(ages))), + ages=ages, + relationship=relationship, + sex=sex, + ) + return changed + + +def _seed_birth_years_outside_career_bounds( + base: cohort.U2Inputs, +) -> cohort.U2Inputs: + return _with_members(base, _SEED_BIRTH_OUTSIDE_MEMBERS) + + +def _seed_birth_years_at_career_bounds( + base: cohort.U2Inputs, +) -> cohort.U2Inputs: + return _with_members(base, _SEED_BIRTH_AT_BOUNDS_MEMBERS) + + # Appended in order, so every earlier variant is unchanged. _VARIANT_BUILDERS = { "code_88_first_year_cohabitor": _code_88_first_year_cohabitor, @@ -1621,6 +1766,10 @@ def _code_88_target_with_ofum_income_2019( "second_trigger_in_excluded_unit_2019": ( _second_trigger_in_excluded_unit_2019 ), + "seed_birth_years_outside_career_bounds": ( + _seed_birth_years_outside_career_bounds + ), + "seed_birth_years_at_career_bounds": _seed_birth_years_at_career_bounds, } _VARIANT_NOTES: dict[str, str] = { "code_88_first_year_cohabitor": ( @@ -1706,6 +1855,23 @@ def _code_88_target_with_ofum_income_2019( "and disposition is unchanged, but B2's person count (d1242) rises " "by one, since it counts persons, not family units, of any sex" ), + "seed_birth_years_outside_career_bounds": ( + "three OFUM members with no marriage history or earnings join " + "families that supply U1 observations, each seeded outside the " + "birth law's derived-age support: a grandchild aged 3 in 2021 " + "(implied 2017) and a child aged 2 in 2023 (2020), and a parent " + "aged 124 in 2013 (1888). Under d1724 (section 16d) all three are " + "counted out before the law, two after 2016 and one before 1889, " + "and every observation, disposition and other count is unchanged" + ), + "seed_birth_years_at_career_bounds": ( + "two OFUM members with no marriage history or earnings are seeded " + "at the ends of the birth law's derived-age support: a parent aged " + "123 in 2013 (born 1889) and a grandchild aged 2 in 2019 (born " + "2016). Neither is counted out (d1724, section 16d): both join " + "the universe with derived birth years outside the cohort, and " + "every observation and disposition is unchanged" + ), } #: The generator's named variants (see :data:`_VARIANT_NOTES`). INVENTED_VARIANTS: tuple[str, ...] = tuple(_VARIANT_BUILDERS) diff --git a/tests/track_u2/test_u2_birth_bounds.py b/tests/track_u2/test_u2_birth_bounds.py new file mode 100644 index 000000000..c7a5dd76d --- /dev/null +++ b/tests/track_u2/test_u2_birth_bounds.py @@ -0,0 +1,671 @@ +"""The birth law's derived-age support and the d1724 count-out (16d). + +INVENTED DATA - NOT A COMPARISON. Every person, age, weight and year +here is invented: the seeded U2 generator's base population, its two +d1724 variants, and members these tests add to it as caller frames. + +``career.derive_birth_years`` (read-only, section 14) asserts that a +birth year it derives from a seed coordinate (age code 2-125, ``seed year +- age``) lies in ``[DERIVED_BIRTH_MIN, DERIVED_BIRTH_MAX]`` (1889-2016). +The d1649 structural pass stopped on that assertion. Max's ruling +d1724 (section 16d) counts out, before the law runs, each universe person +the seed clause would decide whose seed implies a year outside the +support, discloses each side's count, and changes nothing else. These +tests hold, for every input: + +* the universe and the two counted-out sets partition the presence set + (every positive-weight, in-family person of any support wave); +* a person is counted out exactly when the law, run on that person + alone, would stop on the support assertion, on the side the seed + implies; every kept person's record is the one the law gives alone; +* no counted-out person can be in the 1946-55 cohort, since the seed is + that person's only birth-year source and it lies outside 1889-2016; +* the annuitant extension never reaches the assertion, and never + re-admits a counted-out person; +* each count is disclosed in every row's structural summary and decides + nothing: observations, dispositions and every other count are as if + the counted-out persons were absent. +""" + +from __future__ import annotations + +import dataclasses + +import pandas as pd +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from populace_dynamics.estimates import career +from populace_dynamics.uniform_cut_track_u2 import cohort, invented, sources + +OUTSIDE = "seed_birth_years_outside_career_bounds" +AT_BOUNDS = "seed_birth_years_at_career_bounds" +BEFORE, AFTER = cohort.SEED_BIRTH_COUNTED_OUT +BASE_ID = 700_000 +#: The members the two generator variants add (``invented.py``). +AFTER_2016 = (BASE_ID - 11, BASE_ID - 12) +BEFORE_1889 = (BASE_ID - 13,) +AT_1889, AT_2016 = BASE_ID - 14, BASE_ID - 15 +#: Members these tests add as caller frames (ids no variant uses). +ADDED = BASE_ID - 100 + + +@pytest.fixture(scope="module") +def outside(u2_inputs): + return invented.invented_variant(u2_inputs, OUTSIDE) + + +@pytest.fixture(scope="module") +def at_bounds(u2_inputs): + return invented.invented_variant(u2_inputs, AT_BOUNDS) + + +@pytest.fixture(scope="module") +def host(u2_inputs): + """A reference person in-family with a positive weight in every + support wave, whose families the added members join.""" + + waves = cohort.SUPPORT_WAVES + for person in sorted(set(u2_inputs.persons["person_id"].astype(int))): + rows = [ + u2_inputs.anchors[w][u2_inputs.anchors[w]["person_id"].eq(person)] + for w in waves + ] + if all( + len(r) == 1 + and int(r["relationship"].iloc[0]) == 10 + and 1 <= int(r["sequence"].iloc[0]) <= 20 + and float(r["weight"].iloc[0]) > 0 + for r in rows + ): + return person + raise AssertionError("no reference person is present in every wave") + + +def _added( + inputs: cohort.U2Inputs, + person: int, + host: int, + waves: dict[int, tuple[int, int, float]], + *, + birth_year: int | None = None, + earnings: tuple[tuple[int, int], ...] = (), +) -> cohort.U2Inputs: + """``inputs`` plus ``person`` in ``host``'s family in each wave of + ``waves`` (``{wave: (age code, relationship, weight)}``), absent + elsewhere, as caller frames; ``birth_year`` adds a marriage-history + birth year and ``earnings`` ``(period, age)`` earnings rows.""" + + anchors = {} + for wave, frame in inputs.anchors.items(): + row = { + "person_id": person, + "interview": 0, + "sequence": 0, + "relationship": 0, + "age": 0, + "reported_birth_year": pd.NA, + "weight": 0.0, + } + if wave in waves: + age, relationship, weight = waves[wave] + interview = int( + frame.loc[frame["person_id"].eq(host), "interview"].iloc[0] + ) + family = frame[ + frame["interview"].eq(interview) + & frame["sequence"].between(1, 20) + ] + row.update( + interview=interview, + sequence=int(family["sequence"].max()) + 1, + relationship=relationship, + age=age, + weight=weight, + ) + anchors[wave] = pd.concat( + [frame, pd.DataFrame([row])], ignore_index=True + ).astype(frame.dtypes.to_dict()) + design = inputs.design.set_index("person_id").loc[host] + changed = dataclasses.replace( + inputs, + anchors=anchors, + design=pd.concat( + [ + inputs.design, + pd.DataFrame( + [ + { + "person_id": person, + "stratum": int(design["stratum"]), + "cluster": int(design["cluster"]), + } + ] + ), + ], + ignore_index=True, + ).astype("int64"), + persons=pd.concat( + [ + inputs.persons, + pd.DataFrame([{"person_id": person, "sex": "female"}]), + ], + ignore_index=True, + ).astype({"person_id": "int64", "sex": "string"}), + ) + if birth_year is not None: + history = changed.marriage_history + record = {column: pd.NA for column in history.columns} + record.update( + person_id=person, + sex="female", + birth_year=birth_year, + n_records=1, + is_marriage=False, + ) + changed = dataclasses.replace( + changed, + marriage_history=pd.concat( + [history, pd.DataFrame([record])], ignore_index=True + ).astype(history.dtypes.to_dict()), + ) + if earnings: + frame = changed.observed_earnings + rows = pd.DataFrame( + [ + { + "person_id": person, + "period": period, + "earnings": 1000.0, + "role": "head", + "age": age, + "weight": 1.0, + "earnings_acc": 0, + } + for period, age in earnings + ] + ) + changed = dataclasses.replace( + changed, + observed_earnings=pd.concat( + [frame, rows], ignore_index=True + ).astype(frame.dtypes.to_dict()), + ) + return cohort.replace_provenance(changed, kind=cohort.CALLER_FRAMES) + + +def _alone(inputs: cohort.U2Inputs, person: int, seed: bool = True): + """The law run on ``person`` alone, from its earliest positive-weight + in-family presence (or without a seed coordinate).""" + + history = inputs.marriage_history + earnings = inputs.observed_earnings + coordinates = None + if seed: + for wave in cohort.SUPPORT_WAVES: + frame = inputs.anchors[wave] + row = frame[ + frame["person_id"].eq(person) + & frame["sequence"].between(1, 20) + & frame["weight"].gt(0) + ] + if len(row): + coordinates = pd.DataFrame( + { + "person_id": [person], + "year": [wave - 1], + "anchor_wave": [wave], + "age": [int(row["age"].iloc[0])], + } + ) + break + (record,) = career.derive_birth_years( + history[history["person_id"].eq(person)], + earnings[earnings["person_id"].eq(person)], + seed_coordinates=coordinates, + required_person_ids={person}, + ) + return record + + +def _presence(inputs: cohort.U2Inputs) -> set[int]: + present = set() + for wave in cohort.SUPPORT_WAVES: + frame = inputs.anchors[wave] + present |= set( + frame.loc[ + frame["sequence"].between(1, 20) & frame["weight"].gt(0), + "person_id", + ].astype(int) + ) + return present + + +# --------------------------------------------------------------------------- +# The counted-out persons and the boundary years +# --------------------------------------------------------------------------- +def test_the_d1649_failure_is_reproduced_without_the_count_out( + outside, monkeypatch +): + """With the count-out disabled, the invented variant stops the law + on the support assertion, the failure category of the d1649 pass; + with it, the derivation completes.""" + + births = cohort.derive_u2_births(outside) + assert set(births.universe) == _presence(outside) - { + *AFTER_2016, + *BEFORE_1889, + } + + def disabled(seed, history, earnings): + return seed, {side: frozenset() for side in (BEFORE, AFTER)} + + monkeypatch.setattr(cohort, "_count_out_seed_births", disabled) + with pytest.raises(AssertionError, match=r"lies outside \[1889, 2016\]"): + cohort.derive_u2_births(outside) + + +def test_seeds_after_2016_are_counted_out(outside): + births = cohort.derive_u2_births(outside) + assert births.counted_out[AFTER] == frozenset(AFTER_2016) + for person in AFTER_2016: + assert person not in births.universe + assert person not in births.records + assert births.birth_year(person) is None + assert person not in set(births.seed["person_id"]) + + +def test_a_seed_before_1889_is_counted_out(outside): + births = cohort.derive_u2_births(outside) + assert births.counted_out[BEFORE] == frozenset(BEFORE_1889) + (person,) = BEFORE_1889 + assert person not in births.universe + assert births.birth_year(person) is None + + +def test_seeds_at_1889_and_2016_stay_in(at_bounds): + births = cohort.derive_u2_births(at_bounds) + assert births.counted_out == {BEFORE: frozenset(), AFTER: frozenset()} + for person, year in ((AT_1889, 1889), (AT_2016, 2016)): + assert person in births.universe + record = births.records[person] + assert record.birth_year == year + assert record.source is career.BirthSource.DERIVED_PROJECTION_AGE + assert career.DERIVED_BIRTH_MIN == 1889 + assert career.DERIVED_BIRTH_MAX == 2016 + assert cohort.SEED_BIRTH_COUNTED_OUT == ("before_1889", "after_2016") + + +@pytest.mark.parametrize( + "waves, side, year", + [ + ({2013: (124, 50, 500.0)}, BEFORE, 1888), + ({2013: (125, 50, 500.0)}, BEFORE, 1887), + ({2013: (123, 50, 500.0)}, None, 1889), + ({2019: (2, 30, 500.0)}, None, 2016), + ({2021: (4, 30, 500.0)}, None, 2016), + ({2021: (3, 30, 500.0)}, AFTER, 2017), + ({2023: (2, 30, 500.0)}, AFTER, 2020), + # The seed is the earliest positive-weight presence (section 3); + # a later presence never replaces it. + ({2013: (124, 50, 500.0), 2015: (75, 50, 500.0)}, BEFORE, 1888), + ({2013: (124, 50, 0.0), 2015: (75, 50, 500.0)}, None, 1939), + ], +) +def test_each_bound_and_side(u2_inputs, host, waves, side, year): + inputs = _added(u2_inputs, ADDED, host, waves) + births = cohort.derive_u2_births(inputs) + if side is None: + assert births.records[ADDED].birth_year == year + assert all(ADDED not in out for out in births.counted_out.values()) + else: + assert births.counted_out[side] == {ADDED} + other = AFTER if side == BEFORE else BEFORE + assert births.counted_out[other] == frozenset() + wave, (age, _, _) = min( + (w, value) for w, value in waves.items() if value[2] > 0 + ) + assert (wave - 1) - age == year + + +# --------------------------------------------------------------------------- +# Precedence, other codes and the target cohort +# --------------------------------------------------------------------------- +def test_a_history_or_earnings_year_keeps_its_precedence(u2_inputs, host): + """The law's precedence is unchanged: a person whose marriage history + or earnings give a birth year keeps it, whatever the seed implies, + and is never counted out -- here a 1951 cohort member whose seed age + implies 1887, and a person whose seed implies 2020.""" + + inputs = _added( + u2_inputs, ADDED, host, {2013: (125, 50, 500.0)}, birth_year=1951 + ) + inputs = _added( + inputs, + ADDED - 1, + host, + {2023: (2, 30, 500.0)}, + earnings=((1980, 20), (1982, 22)), + ) + births = cohort.derive_u2_births(inputs) + assert births.counted_out == {BEFORE: frozenset(), AFTER: frozenset()} + exact = births.records[ADDED] + assert (exact.birth_year, exact.source) == ( + 1951, + career.BirthSource.EXACT_MARRIAGE, + ) + inferred = births.records[ADDED - 1] + assert (inferred.birth_year, inferred.source) == ( + 1960, + career.BirthSource.INFERRED_PERIOD_AGE, + ) + assert births.birth_year(ADDED) in cohort.ALL_BIRTH_YEARS + + +@pytest.mark.parametrize( + "age, year", [(1, None), (999, None), (60, 1962)], ids=str +) +def test_codes_1_and_999_stay_unresolved_and_are_never_counted_out( + u2_inputs, host, age, year +): + """Seed codes 1 and 999 keep the law's unresolved path, in the + universe, even where ``seed year - code`` lies outside 1889-2016.""" + + inputs = _added(u2_inputs, ADDED, host, {2023: (age, 30, 500.0)}) + births = cohort.derive_u2_births(inputs) + assert ADDED in births.universe + assert births.birth_year(ADDED) == year + if year is None: + assert births.records[ADDED].source is career.BirthSource.UNRESOLVED + assert births.counted_out == {BEFORE: frozenset(), AFTER: frozenset()} + + +def test_code_0_keeps_the_law_s_refusal(u2_inputs, host): + """Code 0 (Inap.) on an in-family, positive-weight seed stays on the + law's existing path: an unrecognized seed-age code refuses.""" + + inputs = _added(u2_inputs, ADDED, host, {2023: (0, 30, 500.0)}) + with pytest.raises(ValueError, match="unrecognized PSID seed-age codes"): + cohort.derive_u2_births(inputs) + + +def test_no_counted_out_person_can_be_in_the_target_cohort(outside): + """Each counted-out person has no birth-year source but the seed (the + law, run without it, leaves the person unresolved), and the seed's + year lies outside 1889-2016, which contains 1946-55.""" + + births = cohort.derive_u2_births(outside) + assert career.DERIVED_BIRTH_MIN < min(cohort.ALL_BIRTH_YEARS) + assert max(cohort.ALL_BIRTH_YEARS) < career.DERIVED_BIRTH_MAX + counted = births.counted_out[BEFORE] | births.counted_out[AFTER] + assert counted == {*AFTER_2016, *BEFORE_1889} + for person in counted: + alone = _alone(outside, person, seed=False) + assert alone.source is career.BirthSource.UNRESOLVED + for built in _builds(outside, births).values(): + assert not counted & set(built.observations["person_id"]) + assert not counted & set(built.dispositions["person_id"]) + + +# --------------------------------------------------------------------------- +# The annuitant extension +# --------------------------------------------------------------------------- +def test_the_extension_cannot_reach_the_bound(u2_inputs, host): + """The extension seeds only rows whose implied year the law supports + (code 999 kept): a zero-weight reference person seen at 124 in 2013 + is seeded by the 2015 presence instead, and one seen only at 3 in + 2021 gets no record; neither stops the law.""" + + inputs = _added( + u2_inputs, + ADDED, + host, + {2013: (124, 20, 0.0), 2015: (70, 20, 0.0)}, + ) + inputs = _added(inputs, ADDED - 1, host, {2021: (3, 10, 0.0)}) + births = cohort.derive_u2_births(inputs) + assert ADDED not in births.universe and ADDED - 1 not in births.universe + assert births.records[ADDED].birth_year == 2014 - 70 + assert ADDED - 1 not in births.records + assert births.counted_out == {BEFORE: frozenset(), AFTER: frozenset()} + + +def test_a_counted_out_person_never_joins_the_extension(u2_inputs, host): + """A counted-out person had a positive-weight presence, so is not + outside the universe: the extension, which would have seeded this + code-20 person from the zero-weight 2015 presence, leaves it out.""" + + inputs = _added( + u2_inputs, + ADDED, + host, + {2013: (124, 20, 500.0), 2015: (70, 20, 0.0)}, + ) + births = cohort.derive_u2_births(inputs) + assert births.counted_out[BEFORE] == {ADDED} + assert ADDED not in births.records + assert births.birth_year(ADDED) is None + + +# --------------------------------------------------------------------------- +# Disclosure, and nothing else changes +# --------------------------------------------------------------------------- +def _builds(inputs, births): + declared = sources.RoleContext.declared() + return { + row: cohort.build_u2_cohort( + inputs, + cohort.U2CohortSpec(row=row), + role_context=declared, + births=births, + ) + for row in cohort.ROWS + } + + +def test_the_two_counts_are_disclosed_in_every_row( + u2_inputs, outside, at_bounds +): + for inputs, counts in ( + (u2_inputs, {BEFORE: 0, AFTER: 0}), + (outside, {BEFORE: 1, AFTER: 2}), + (at_bounds, {BEFORE: 0, AFTER: 0}), + ): + births = cohort.derive_u2_births(inputs) + for built in _builds(inputs, births).values(): + assert built.diagnostics["n_seed_birth_year_counted_out"] == counts + summary = cohort.structural_summary(built) + assert summary["n_seed_birth_year_counted_out"] == counts + assert list(summary["n_seed_birth_year_counted_out"]) == [ + "before_1889", + "after_2016", + ] + + +def test_the_count_out_changes_nothing_else(u2_inputs, outside): + """The variant adds only counted-out members: the births, every row's + observations, dispositions, design frame, income rows and every other + count equal the base population's.""" + + base = cohort.derive_u2_births(u2_inputs) + births = cohort.derive_u2_births(outside) + assert births.universe == base.universe + assert dict(births.records) == dict(base.records) + pd.testing.assert_frame_equal(births.seed, base.seed) + before, after = _builds(u2_inputs, base), _builds(outside, births) + for row in cohort.ROWS: + pd.testing.assert_frame_equal( + after[row].observations, before[row].observations + ) + pd.testing.assert_frame_equal( + after[row].dispositions, before[row].dispositions + ) + pd.testing.assert_frame_equal( + cohort.design_frame(outside, after[row].spec), + cohort.design_frame(u2_inputs, before[row].spec), + ) + changed = {"n_seed_birth_year_counted_out", "births_inputs_sha256"} + for key in set(before[row].diagnostics) | set(after[row].diagnostics): + if key not in changed: + assert ( + after[row].diagnostics[key] == before[row].diagnostics[key] + ), key + left = cohort.structural_summary(after[row]) + right = cohort.structural_summary(before[row]) + assert set(left) == set(right) + for key in left: + if key != "n_seed_birth_year_counted_out": + assert left[key] == right[key], key + pd.testing.assert_frame_equal( + cohort.income_rows(after["U0"], outside), + cohort.income_rows(before["U0"], u2_inputs), + ) + + +def test_the_boundary_members_change_only_the_universe_counts( + u2_inputs, at_bounds +): + base = cohort.derive_u2_births(u2_inputs) + births = cohort.derive_u2_births(at_bounds) + assert births.universe == base.universe | {AT_1889, AT_2016} + before, after = _builds(u2_inputs, base), _builds(at_bounds, births) + for row in cohort.ROWS: + pd.testing.assert_frame_equal( + after[row].observations, before[row].observations + ) + pd.testing.assert_frame_equal( + after[row].dispositions, before[row].dispositions + ) + for key in ("n_universe", "n_outside_birth_years"): + assert ( + after[row].diagnostics[key] == before[row].diagnostics[key] + 2 + ) + assert after[row].diagnostics["n_target_birth_year"] == ( + before[row].diagnostics["n_target_birth_year"] + ) + + +# --------------------------------------------------------------------------- +# Property: the count-out is exactly where the law would stop +# --------------------------------------------------------------------------- +#: Every age code the loader admits for an in-family person but 0, whose +#: refusal the law keeps (tested above). +_ANY_AGE = st.one_of(st.integers(1, 125), st.just(999)) +_RELATIONSHIPS = st.sampled_from((10, 20, 30, 50, 60)) +#: Mostly positive, so the first row is mostly the seed. +_WEIGHTS = st.sampled_from((500.0, 500.0, 500.0, 0.0)) + + +@st.composite +def _first_row(draw) -> tuple[int, int]: + """A (wave, age code) drawn mostly near either end of the support: + the oldest codes in 2013 (1887-1890) or young children in 2019-2023 + (2016-2021, code 1 included).""" + + kind = draw(st.sampled_from(("low", "low", "high", "high", "any"))) + if kind == "low": + return 2013, draw(st.integers(122, 125)) + if kind == "high": + wave = draw(st.sampled_from((2019, 2021, 2023))) + return wave, draw(st.integers(1, 6)) + return draw(st.sampled_from(cohort.SUPPORT_WAVES)), draw(_ANY_AGE) + + +@st.composite +def _member(draw) -> dict: + """A member whose first row is drawn by :func:`_first_row`, plus up to + two later rows of any code; any relationship and weight; mostly + seed-only (no history or earnings year).""" + + first, age = draw(_first_row()) + rows = {first: (age, draw(_RELATIONSHIPS), draw(_WEIGHTS))} + later = [wave for wave in cohort.SUPPORT_WAVES if wave > first] + if later: + for wave in draw( + st.lists(st.sampled_from(later), unique=True, max_size=2) + ): + rows[wave] = (draw(_ANY_AGE), draw(_RELATIONSHIPS), draw(_WEIGHTS)) + sourced = st.sampled_from((False, False, False, True)) + return { + "waves": rows, + "birth_year": ( + draw(st.integers(1880, 2025)) if draw(sourced) else None + ), + "earnings_age": draw(st.integers(14, 90)) if draw(sourced) else None, + } + + +_CASES = ( + {"waves": {2013: (124, 50, 500.0)}, "birth_year": None}, + {"waves": {2023: (2, 30, 500.0)}, "birth_year": None}, + {"waves": {2013: (123, 50, 500.0)}, "birth_year": None}, + {"waves": {2019: (2, 60, 500.0)}, "birth_year": None}, + {"waves": {2013: (125, 10, 500.0)}, "birth_year": 1950}, +) + + +@settings( + max_examples=40, + deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given(members=st.lists(_member(), min_size=1, max_size=4)) +@example( + members=[{"earnings_age": None, **case} for case in _CASES] # one of each +) +def test_the_count_out_is_exactly_where_the_law_would_stop( + u2_inputs, host, members +): + """For any added members: the universe and the two counted-out sets + partition the presence set; a person is counted out exactly when the + law run on that person alone stops on the support assertion, on the + side the seed implies; every kept person's record is the law's alone; + the base population's records are unchanged; and the derivation is + deterministic.""" + + inputs = u2_inputs + added = [] + for index, member in enumerate(members): + person = ADDED - index + earnings = () + if member["earnings_age"] is not None: + earnings = ((1990, member["earnings_age"]),) + inputs = _added( + inputs, + person, + host, + member["waves"], + birth_year=member["birth_year"], + earnings=earnings, + ) + added.append(person) + births = cohort.derive_u2_births(inputs) + present = _presence(inputs) + counted = births.counted_out[BEFORE] | births.counted_out[AFTER] + assert not births.counted_out[BEFORE] & births.counted_out[AFTER] + assert not counted & births.universe + assert counted | births.universe == present + base = cohort.derive_u2_births(u2_inputs) + for person in base.universe: + assert births.records[person] == base.records[person] + for person in added: + if person not in present: + assert person not in counted + continue + try: + alone = _alone(inputs, person) + except AssertionError as stop: + assert "lies outside" in str(stop) + year = int(str(stop).split()[3]) + side = BEFORE if year < career.DERIVED_BIRTH_MIN else AFTER + assert person in births.counted_out[side] + assert _alone(inputs, person, seed=False).source is ( + career.BirthSource.UNRESOLVED + ) + continue + assert person in births.universe + assert births.records[person] == alone + again = cohort.derive_u2_births(inputs) + assert dict(again.records) == dict(births.records) + assert again.counted_out == births.counted_out diff --git a/tests/track_u2/test_u2_scripts.py b/tests/track_u2/test_u2_scripts.py index 54c519838..9b9a86db5 100644 --- a/tests/track_u2/test_u2_scripts.py +++ b/tests/track_u2/test_u2_scripts.py @@ -185,6 +185,8 @@ def test_dry_run_records_every_branch_and_refusal(dry_run): "spouse_slot_occupant_without_family_spouse_2019", "code_88_target_with_ofum_income_2019", "second_trigger_in_excluded_unit_2019", + "seed_birth_years_outside_career_bounds", + "seed_birth_years_at_career_bounds", ] assert all(entry["regenerated"] for entry in variants.values()) # Amendment 1c: the two code-88 variants build under the declared From cf4209ae2119f139882cb77a83843e818ece276e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 15:18:08 -0400 Subject: [PATCH 2/9] =?UTF-8?q?U2=20spec:=20record=20d1724=20as=20=C2=A716?= =?UTF-8?q?d,=20the=20=C2=A73=20note;=20re-pin;=20recount=20tiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §3 keeps its draft-3 lines (the registries cite them), so the note d1724 names stands in a new §16d, as §§16b-16c amend §3: the count-out rule, how the ruling's words are read (only persons the seed would date, so the law's precedence is kept), why no cohort member is lost, Track M's precedent and the disclosure. §17 and §20 step 4 point to it; step 4 records the stopped d1649 pass and the one further pass d1724 allows; §19 logs the change. The d1090 record test cuts §16c at §16d, §16d is pinned line by line with 16 mutations, and the spec pin moves. The invented variants' members now join, in their first wave, families that supply a U1 observation in that wave. Tiers by collection: unit 9,916 -> 9,941, integration_psid 1,423 -> 1,441 (total 16,463). Co-Authored-By: Claude Opus 5.5 --- data/external/track_u2/u1_identity.json | 4 +- docs/design/boomers2004_1946_55_comparison.md | 48 +++- docs/design/u2_m2_exposure.md | 16 ++ .../uniform_cut_track_u2/invented.py | 24 +- tests/README-tiers.md | 6 +- tests/tier_counts.json | 4 +- tests/track_u2/test_adjudication_applied.py | 272 +++++++++++++++++- 7 files changed, 352 insertions(+), 22 deletions(-) diff --git a/data/external/track_u2/u1_identity.json b/data/external/track_u2/u1_identity.json index e8b49a6b7..046b3ec88 100644 --- a/data/external/track_u2/u1_identity.json +++ b/data/external/track_u2/u1_identity.json @@ -55,12 +55,12 @@ }, "u2_specification": { "path": "docs/design/boomers2004_1946_55_comparison.md", - "note": "Moved out of the unchanged-byte list by u2-m1b: Part C writes the proposed revision u2-draft-4 into the specification. The draft-3 hash is the milestone-1 pin; the current hash pins u2-draft-4 with Max's 2026-09-30 rulings recorded in §16b (d637) and his 2026-10-10 ruling on blockers B1 and B2 recorded in §16c (d1090); every line a registry cites keeps its draft-3 number and text.", + "note": "Moved out of the unchanged-byte list by u2-m1b: Part C writes the proposed revision u2-draft-4 into the specification. The draft-3 hash is the milestone-1 pin; the current hash pins u2-draft-4 with Max's 2026-09-30 rulings recorded in §16b (d637) and his 2026-10-10 ruling on blockers B1 and B2 recorded in §16c (d1090), with his 2026-10-19 ruling on the birth law's support recorded in §16d (d1724); every line a registry cites keeps its draft-3 number and text.", "draft_3_sha256": "7badf89e896ece28abcea1cb2db9bd55d468346b405ed06eb357aaf5c7b51df2", "draft_3_commit": "f7412e00cdcdba6ee1156b74fa93039f5329cb8f", "draft_3_git_blob": "e07ee0d589d20f3b6d100e5d4ce173f9a653f5e9", "version": "u2-draft-4", - "sha256": "c0c2685f8e23756895b66d09a6bbd618f930c488cc5ad5c52415f42b228d8f98", + "sha256": "e376cb7c8140eb2a7233067b52e757655b29951d4ce1dcdd137aabd9117f2067", "cited_lines_sha256": { "74": "7b299c9e4473fd51d04bdfec51af83268e517181ab734d3e76ccd1c744f6daa9", "75": "44454cc04e6a50863035d69ccb53edec175b4ad70a19d8877437901d2deb8428", diff --git a/docs/design/boomers2004_1946_55_comparison.md b/docs/design/boomers2004_1946_55_comparison.md index e0013ce8e..9e534474f 100644 --- a/docs/design/boomers2004_1946_55_comparison.md +++ b/docs/design/boomers2004_1946_55_comparison.md @@ -1647,11 +1647,47 @@ Both rules are fixed by this ruling, before any count, and each is vacuous if no - Each registry effect is its own reviewed commit (§16a, "Registry effect of the rulings"). For B1 and B2 the effect replaces eight refusals, the seven role refusals and `pension:2015.route.respondent_slots`, with the rules above and releases their dependents. §§3–15 keep their draft-3 line numbers, and their draft-3 text except §15's version key (line 1047), because the registries cite them. - No count, statistic or outcome was computed to record this ruling, and no file on the restricted list was opened for reading. Reading and exposure are recorded in `docs/design/u2_m1b_exposure.md` (2026-10-10 section). +## 16d. Max's ruling on the birth law's support (2026-10-19, d1724) + +> **RULED on 2026-10-19.** Max ruled on 2026-10-19 (decision d1724), after the first §20 step-4 structural pass stopped before writing any output. This section is the §3 note the ruling names. §3 keeps its draft-3 text and line numbers, because the registries cite them (§16c), so the note stands here and amends §3 as §§16b and 16c do; where it and §3 differ, it governs. §§16a–16c are unchanged. + +The decision put to Max, as recorded in the chief-of-staff decision log: + +> "dynamics U2 §20 step 4, second run: after the d1649 run stopped on a career-bound assertion with no output, fix U2's birth universe so it counts out people whose seed coordinate implies a birth year outside career's [1889, 2016] (as Track M does; career.py untouched), then rerun step 4 once under d1649's scope" + +The ruling, as recorded there: + +> "Yes: fix, then rerun once. A reviewed PR counts out the out-of-bounds people in U2's cohort code and discloses the counts, with a §3 note; career.py is untouched. Once it merges and master's Tests pass at the run head, step 4 (structure, then component diagnostics) runs once more under d1649's scope, with --authorized-by this decision. Max, dynamics coordinator session 2026-10-19 (guided AskUserQuestion)." + +**What stopped the first pass.** The pass Max authorized on 2026-10-16 (d1649) ran once, on 2026-10-19 at `640adccf`, and exited without writing any output; the component diagnostics were not run (evidence: `EV/track-u2-step4-pass-20261019/MANIFEST.md`, SHA-256 `1e91407b…a366fa`). It stopped in the birth law, `estimates.career.derive_birth_years`, called from `cohort.derive_u2_births`. For a person the law dates by the seed coordinate (age code 2–125), the law takes the birth year as the seed year minus the age and asserts that it lies between `career.DERIVED_BIRTH_MIN` and `career.DERIVED_BIRTH_MAX`, 1889 and 2016. §3's universe takes every positive-weight, in-family person of any support wave, at any age (draft-3 lines 114 and 126), so a child aged 2 or 3 in the 2021 wave or 2–5 in the 2023 wave, or a person aged 124 or 125 in the 2013 wave, reaches that assertion. The masked error record does not show which end was crossed, so the rule covers both. This record states no count or value from the pass. + +**The note to §3 (amends draft-3 line 114).** Before the law runs, a person of the universe is counted out when both hold: + +1. the law would take the person's birth year from the seed coordinate: neither the marriage history nor the observed earnings give one, as the law itself shows when run on the same frames without seed coordinates (it leaves the person unresolved); and +2. the seed's age code is 2–125, and the year it implies is before 1889 or after 2016. + +A counted-out person leaves the universe and the frames the law reads. Every other person keeps the law's precedence and the record the law gives, whatever the seed implies: a marriage-history or earnings year still wins. Seed codes 1 and 999 stay unresolved, and any other code keeps the law's refusal. The seed is still the earliest positive-weight presence; a later presence never replaces it. A counted-out person had a positive-weight presence, so is not outside the universe, and the annuitant extension (line 188) does not seed it either; where such a person is an annuitant, its age takes the inherited wave-age fallback, counted. The extension seeds only presences whose implied year the law supports, as before, so it cannot reach the assertion. + +**How the ruling's words are read.** The decision speaks of "people whose seed coordinate implies a birth year outside" the support. This record reads that as the people whose birth year the seed coordinate gives: condition 1 above. The seed gives no birth year to a person with a marriage-history or earnings year. Counting such a person out would let the seed override the precedence §3 keeps (line 114: "with its existing history inputs and precedence") and could remove a member of the cohort. The two readings differ only for a person with both an out-of-support seed and a history or earnings year. + +**Why no member of the cohort is lost.** A counted-out person's only birth-year source is a seed whose year lies outside 1889–2016, which contains 1946–55, so no counted-out person can belong to the cohort in any row. + +**Precedent.** Track M counts out, before the same law, the in-family, positive-weight persons aged 0–5 in its one seed wave, 2023, whose seed (income year 2022 minus age) implies a year after 2016 (`src/populace_dynamics/min_benefit_track_m/structure.py`, the funnel count `age_0_5_counted_out_before_birth_law`, and `min_benefit_track_m/cohort.py`). U2 seeds from six waves, so its rule tests the implied year on both sides, not an age; it applies only to age codes 2–125, so codes 0 and 1 keep the law's paths; and it applies only to persons the seed would date, so the law's precedence is kept. + +**Disclosure.** Each side's count is disclosed in every row's structural counts, as `n_seed_birth_year_counted_out` with the keys `before_1889` and `after_2016`, and decides nothing. The rule is fixed before any count, and none was computed for this record. + +**What the ruling leaves unchanged.** + +- `src/populace_dynamics/estimates/career.py` is unchanged. +- No row, plan cell, observation weight, disposition rule, design frame, role rule or income concept changes. U2 stays unregistered and unauthorized (§20). +- The ruling authorizes one more step-4 pass, structure and then component diagnostics, under d1649's scope with `--authorized-by d1724`, once this change merges and master's Tests pass at the run head (§20 step 4). +- No count, statistic or outcome was computed to record this ruling, no PSID record was read, and no file on the restricted list was opened for reading. Reading and exposure are recorded in `docs/design/u2_m2_exposure.md` (d1724 section). + ## 17. Review record and outstanding work The adversarial review of `u2-draft-1` returned **RATIFIABLE AFTER EDITS**. Rev1 (`u2-draft-2`) applied those edits. Its round-diff check returned **REVISE**, identifying R1–R8. Round 2 (`u2-draft-3`) applied every exact edit and retained the earlier resolutions. -Milestone 1 then built the documentary source registries (`data/external/track_u2/`, master record `docs/design/u2_m1_source_adjudication.md`). The independent adjudication of that work (`EV/phase2-20260927/out/u2-adjudicate.md`) returned **ERRORS FOUND**: 18 wrong citations, now corrected; 16 items resolvable from documents, now resolved; 8 routes refused, now encoded as explicit refusals; and 24 items that need conventions, still open. Milestone 1b researched the three documentary blockers from official PSID documentation and wrote `u2-draft-4` (§16a). Max ruled on §16a on 2026-09-30, and §16b records the rulings. PSID answered the three documentary questions on 2026-10-07, Max ruled on blockers B1 and B2 on 2026-10-10, and §16c records that ruling. This document claims no new referee verdict on the specification; §20 steps 2 and 3 record the completed implementation and the reviews of every later change. +Milestone 1 then built the documentary source registries (`data/external/track_u2/`, master record `docs/design/u2_m1_source_adjudication.md`). The independent adjudication of that work (`EV/phase2-20260927/out/u2-adjudicate.md`) returned **ERRORS FOUND**: 18 wrong citations, now corrected; 16 items resolvable from documents, now resolved; 8 routes refused, now encoded as explicit refusals; and 24 items that need conventions, still open. Milestone 1b researched the three documentary blockers from official PSID documentation and wrote `u2-draft-4` (§16a). Max ruled on §16a on 2026-09-30, and §16b records the rulings. PSID answered the three documentary questions on 2026-10-07, Max ruled on blockers B1 and B2 on 2026-10-10, and §16c records that ruling. After the first structural pass stopped, Max ruled on the birth law's support on 2026-10-19, and §16d records that ruling. This document claims no new referee verdict on the specification; §20 steps 2 and 3 record the completed implementation and the reviews of every later change. What remains before ratification: @@ -1889,6 +1925,14 @@ Revision verification comprised the two touched test files and `tests/track_u2/t - Max confirmed §16c's proposed clarification (d1242): B2's disclosed count is the number of code-90 and code-92 persons in 2019, 2021 and 2023 family units that would otherwise supply any U2 observation. `sources.PERSON_COUNTED_EXCLUSIONS` now names B2 as well as B1, so the structural pass discloses both counts, and each decides nothing (§16c). Each is disclosed per row in two forms: per wave, whose sum counts a person who brings the exclusion in two waves twice, and as distinct persons across the waves, which is the number d1242 names. §16c's text, which records the proposal, is unchanged. - No count, statistic or outcome was computed, no model or estimator was run on real data, and no file on the restricted list was opened for reading. The revision's reading is recorded with the round-3 closeout lane in `docs/design/u2_m2_exposure.md`. +**`u2-draft-4`, Max's ruling on the birth law's support recorded and applied (2026-10-19, d1724):** + +- Added §16d, the §3 note d1724 names. The first step-4 pass (d1649) stopped on the birth law's support assertion without writing any output. Before the law runs, a universe person whom only the seed coordinate would date, and whose seed implies a year before 1889 or after 2016, is now counted out on that side, as Track M counts out young children. The law's precedence is kept, codes 0, 1 and 999 keep their paths, no member of the cohort can be counted out, and each count is disclosed and decides nothing. §§16a–16c are unchanged. +- The cohort code applies it: `cohort.derive_u2_births` counts the persons out (`U2Births.counted_out`), the annuitant extension never re-admits them, and every row's structural counts disclose `n_seed_birth_year_counted_out`. `career.py` is unchanged. Two invented variants put members outside and at both ends of the support, and `tests/track_u2/test_u2_birth_bounds.py` holds both sides, the years 1889 and 2016, the counts, the unchanged cohort and other counts, and a property test against the law run on each person alone. +- §17's review paragraph and §20 step 4 point to §16d; step 4 records the stopped pass and the one further pass d1724 authorizes. +- Changed no line before §16a. The specification pin in `data/external/track_u2/u1_identity.json` moves to the new hash; its `version` stays `u2-draft-4`. The d1090 record test now cuts §16c's record at §16d, and a new test pins §16d line by line. +- No count, statistic or outcome was computed, no model or estimator was run on real data, and no file on the restricted list was opened for reading. Reading and exposure are recorded in `docs/design/u2_m2_exposure.md` (d1724 section). + ## 20. Ratification and execution record **Pending.** @@ -1896,7 +1940,7 @@ Revision verification comprised the two touched test files and `tests/track_u2/t 1. Resolve the proposed decisions and source-verification blockers. **Decisions: done (Max, d514, 2026-09-28, adopting every §16 default).** Source-verification blockers remain for steps 2–5. **`u2-draft-4` amendments 1–6 (§16a): ruled by Max on 2026-09-30 (d637; §16b).** B3 is resolved by option 1. B1 and B2 were held until PSID answered; **Max ruled on them on 2026-10-10 (d1090; §16c)**: B1 by option 2 and B2 by option 1, each an exclusion under a named disposition. Max confirmed the reading of amendment 1's parts 1a–1c in §16b on 2026-10-12 (d745), so this step is done. 2. Complete implementation, manifests and parameter capture. **Done (2026-10-16).** Implementation: milestone 2 (#487) and its three review rounds, the last landing as #525 (`f7a84d0b`); and #544, which records with the frame digest the pandas version and string inference setting it was taken under and refuses a frozen load or registered run under any other (`test_u2_loader.py::test_any_other_frame_environment_refuses_the_registered_check`, `::test_a_frozen_load_refuses_another_frame_environment`). Manifests: the documentary source records, 1,766 registry entries and 26 SSI and Census records, are all RESOLVED, none REFUSED or TO VERIFY (`test_adjudication_applied.py::test_status_counts_after_independent_adjudication`), and the loader's source preflight passes (`test_u2_mappings.py::test_source_preflight_passes_at_the_committed_registries`; #524, #540). A RESOLVED record is a documentary finding; reconciliation on real records is step 4's. Parameter capture: the U2 SSI capture, the Track M threshold capture and U1's inherited life tables, each pinned by hash in the code (§17 item 5; `test_u2_parameters.py::test_threshold_capture_is_the_pinned_track_m_file`; `parameters._LIFE_TABLE_PINS`). The final parameter block is step 5's. 3. Complete independent mapping review, invented tests, historical-isolation checks and exact U1 differential evidence. **Done (2026-10-16).** Independent mapping review: the independent adjudication of milestone 1's registries (ERRORS FOUND); milestone 1b applied its corrections in #486, whose independent review requested changes on 11 findings and whose blind verifier then returned U2-M1B: VERIFIED (`test_adjudication_applied.py::test_dispositions_match_the_adjudication`; §17). Every later registry change had an independent review before it merged: the d1090 rounds (§19), ending in APPROVE at round 4 (`e7a558f`) and at #524's merged head (`742590b`; #524's merge audit); the d637 and d745 commit reviews, the last a delta review, APPROVE (§19); and #540's gating review, an Opus 5.5 review with a shell, APPROVE (#540's merge audit). Invented tests and historical isolation: the milestone-2 report's §13 and §14 audit (#487, #525), closed by three review rounds and a fourth-pass mutation run in which a test kills every mutant; §13's optional structure-only U1 load needs Max's approval and is not run. Exact U1 differential evidence: §17 item 7. -4. Complete the authorized pre-registration structural, reconciliation and F17 component pass; resolve mapping failures. The pass reads real PSID records, so it needs Max's authorization, recorded as a chief-of-staff decision. `scripts/track_u2_structure.py` and `scripts/track_u2_component_diagnostics.py` refuse to run without `--authorized-by `, exiting non-zero before any PSID path is resolved or opened, and the loader refuses an unfrozen load without the same record. The flag records the decision id, the git head and the UTC time in the pass's output; it only records the authorization and does not grant it. +4. Complete the authorized pre-registration structural, reconciliation and F17 component pass; resolve mapping failures. The pass reads real PSID records, so it needs Max's authorization, recorded as a chief-of-staff decision. `scripts/track_u2_structure.py` and `scripts/track_u2_component_diagnostics.py` refuse to run without `--authorized-by `, exiting non-zero before any PSID path is resolved or opened, and the loader refuses an unfrozen load without the same record. The flag records the decision id, the git head and the UTC time in the pass's output; it only records the authorization and does not grant it. **First pass (d1649): stopped.** It ran once, on 2026-10-19 at `640adccf`, and stopped in the birth law without writing any output; the component diagnostics were not run (§16d). **Max's ruling of 2026-10-19 (d1724; §16d)** authorizes one more pass, structure and then component diagnostics, under d1649's scope with `--authorized-by d1724`, once the §16d change merges and master's Tests pass at the run head. 5. Materialize the final parameter block and U2 rulings record; require exact code/specification agreement. 6. Ratify the specification by merge. 7. Obtain and independently check the fresh forecast. diff --git a/docs/design/u2_m2_exposure.md b/docs/design/u2_m2_exposure.md index 7a2d63ee9..2fdf4cc78 100644 --- a/docs/design/u2_m2_exposure.md +++ b/docs/design/u2_m2_exposure.md @@ -353,3 +353,19 @@ The hook refused nothing at any of these runs. Its only refusals were the self-t **The d1242 change.** After #544 merged (`2ef10532`), the same lane applied Max's d1242 ruling: B2 joins `sources.PERSON_COUNTED_EXCLUSIONS`, with its tests and a §19 entry. It read only the decision ledger's d1242, d1597 and d1649 entries, the code it changed and the roles tests. It ran the U2 files under the same hook, which refused nothing. Answering #546's review, it added an invented variant (a second, sex-unknown code-92 member in one excluded 2019 unit) and a distinct-person form of each count. For this it read the review, the invented-data generator, the runner and the scripts test. It ran the reviewer's mutations and the U2 files under the same hook, which refused nothing. **Values:** this lane and its lanes stated and inferred no 1946–55 value or direction. Every number they printed is invented (offsets, invented cells, invented-run counts and dispositions), a count, a hash, a timing or a public parameter from the committed captures. + +## d1724 lane (2026-10-19) + +A Claude Code session (Claude Opus 5.5), started by the dynamics coordinator session (`local_6085d52a-…`), applied Max's ruling d1724 (specification §16d) in a standalone clone of `origin/master` at `640adccf`. It read no PSID record and no comparator, and it does not forecast U2. + +**Read:** + +- `EV/RESTRICTED-FILES.md` itself, in full, as its brief required, to know what not to open. It opened nothing the file lists. +- The coordinator's shared notes folder: the step-4 pass session's value-free note and the d637 registry session's note. +- The chief-of-staff decision ledger's d1649 and d1724 entries (decision, ruling and status lines). +- In `EV/track-u2-step4-pass-20261019/`: the directory listing (file names only) and the SHA-256 of `MANIFEST.md`, computed without printing it. It did not open `track-u2-structure.stderr.txt` (restricted), its masked copy, or any other file there. +- The repository: the U2 package, `estimates/career.py` (`derive_birth_years` and its constants, unchanged), Track M's `structure.py` and `cohort.py` (the count-out precedent), the U2 scripts and tests, `tests/conftest.py`, the specification (its header and §§3, 16c, 17, 19 and 20, and the tests' pins) and this file. + +**Ran:** the U2 test files, the new `tests/track_u2/test_u2_birth_bounds.py` among them, with `POPULACE_DYNAMICS_PSID_DIR` set to an empty directory, which stayed empty; so the checks that need PSID documentation skipped. It ran `career.derive_birth_years` and master's `cohort.derive_u2_births` (read from Git at `640adccf`) on invented inputs only, to show the fix equals master wherever master's law completes. It ran Black, Ruff, the tier recount by collection and the mutation checks listed in the PR. + +**Values:** it stated and inferred no 1946–55 value or direction. Every number it printed is invented (generator persons, ages and counts), a test count, a hash, or a constant of the code (`career.DERIVED_BIRTH_MIN` and `DERIVED_BIRTH_MAX`, 1889 and 2016). diff --git a/src/populace_dynamics/uniform_cut_track_u2/invented.py b/src/populace_dynamics/uniform_cut_track_u2/invented.py index 6ad48beca..b4a30de7c 100644 --- a/src/populace_dynamics/uniform_cut_track_u2/invented.py +++ b/src/populace_dynamics/uniform_cut_track_u2/invented.py @@ -1596,15 +1596,18 @@ def _code_88_target_with_ofum_income_2019( def _host(base: cohort.U2Inputs, waves: tuple[int, ...]) -> int: - """U1's first observed target who is in-family with a positive weight - in every one of ``waves`` (a family that supplies an observation).""" + """The first target with a U1 observation in the earliest of ``waves`` + who is in-family with a positive weight in every one of them: its + family supplies an observation in the member's first wave.""" built = cohort.build_u2_cohort( base, cohort.U2CohortSpec(row="U1"), role_context=sources.RoleContext.declared(), ) - for person in sorted(set(built.observations["person_id"].astype(int))): + obs = built.observations + first = obs.loc[obs["wave"].eq(min(waves)), "person_id"].astype(int) + for person in sorted(set(first)): if all( bool( ( @@ -1616,7 +1619,7 @@ def _host(base: cohort.U2Inputs, waves: tuple[int, ...]) -> int: for wave in waves ): return person - raise AssertionError(f"no U1 target is present in every wave {waves}") + raise AssertionError(f"no U1 target can host a member in {waves}") def _added_member( @@ -1856,17 +1859,20 @@ def _seed_birth_years_at_career_bounds( "by one, since it counts persons, not family units, of any sex" ), "seed_birth_years_outside_career_bounds": ( - "three OFUM members with no marriage history or earnings join " - "families that supply U1 observations, each seeded outside the " - "birth law's derived-age support: a grandchild aged 3 in 2021 " + "three OFUM members with no marriage history or earnings join, in " + "their first wave, families that supply a U1 observation in that " + "wave, each seeded outside the birth law's derived-age support: a " + "grandchild aged 3 in 2021 " "(implied 2017) and a child aged 2 in 2023 (2020), and a parent " "aged 124 in 2013 (1888). Under d1724 (section 16d) all three are " "counted out before the law, two after 2016 and one before 1889, " "and every observation, disposition and other count is unchanged" ), "seed_birth_years_at_career_bounds": ( - "two OFUM members with no marriage history or earnings are seeded " - "at the ends of the birth law's derived-age support: a parent aged " + "two OFUM members with no marriage history or earnings join, in " + "their first wave, families that supply a U1 observation in that " + "wave, seeded at the ends of the birth law's derived-age support: a " + "parent aged " "123 in 2013 (born 1889) and a grandchild aged 2 in 2019 (born " "2016). Neither is counted out (d1724, section 16d): both join " "the universe with derived birth years outside the cohort, and " diff --git a/tests/README-tiers.md b/tests/README-tiers.md index 62c0f6f75..f4b62addf 100644 --- a/tests/README-tiers.md +++ b/tests/README-tiers.md @@ -39,9 +39,9 @@ pytest --collect-only -q -m oracle_policyengine | tail -1 | Tier | Tests at HEAD | |---|---:| -| `unit` | 9,916 | +| `unit` | 9,941 | | `artifact` | 4,253 | -| `integration_psid` | 1,423 | +| `integration_psid` | 1,441 | | `reproduction_legacy` | 520 | | `oracle_policyengine` | 308 | -| **Total** | **16,420** | +| **Total** | **16,463** | diff --git a/tests/tier_counts.json b/tests/tier_counts.json index 88eaa58e6..c671731e6 100644 --- a/tests/tier_counts.json +++ b/tests/tier_counts.json @@ -1,9 +1,9 @@ { "schema_version": 1, "counts": { - "unit": 9916, + "unit": 9941, "artifact": 4253, - "integration_psid": 1423, + "integration_psid": 1441, "reproduction_legacy": 520, "oracle_policyengine": 308 } diff --git a/tests/track_u2/test_adjudication_applied.py b/tests/track_u2/test_adjudication_applied.py index bd70e3574..90db68d3b 100644 --- a/tests/track_u2/test_adjudication_applied.py +++ b/tests/track_u2/test_adjudication_applied.py @@ -1604,7 +1604,8 @@ def _heading_line(lines, number): def d1090_locations(spec): """The text of each place the d1090 record stands in ``spec``, cut at - the section heading lines.""" + the section heading lines. Section 16c's record ends where section + 16d (d1724, a later ruling) begins.""" lines = spec.split("\n") def section(first, last=None): @@ -1615,7 +1616,7 @@ def section(first, last=None): return { "header_version": lines[2], "header_status": lines[3], - "record": section("16c", "17"), + "record": section("16c", "16d"), "outstanding": section("17", "18"), "execution": section("20"), } @@ -1623,8 +1624,9 @@ def section(first, last=None): #: The sections the record's places are cut at, and section 20. Each must #: open with exactly one ATX heading, in any Markdown spacing, outside code -#: (reviews of 0c4eab5 and 91695a5). -D1090_SECTIONS = ("16a", "16b", "16c", "17", "18", "20") +#: (reviews of 0c4eab5 and 91695a5). Section 16d (d1724) ends 16c's +#: record, so it is held to the same rule. +D1090_SECTIONS = ("16a", "16b", "16c", "16d", "17", "18", "20") #: Pinned lines that stand more than once in the whole specification, #: whitespace ignored, with their counts; every other pinned line stands #: once. "What the ruling does not change." also heads part of §16b. @@ -2109,6 +2111,268 @@ def test_the_d1090_checks_catch_each_mutation(place, old, new): check_d1090_record(mutated) +#: Max's ruling d1724 (2026-10-19): every non-blank line of section 16d, +#: the section 3 note the ruling names, in order. +D1724_RECORD = [ + "## 16d. Max's ruling on the birth law's support (2026-10-19, " "d1724)", + "> **RULED on 2026-10-19.** Max ruled on 2026-10-19 (decision " + "d1724), after the first \N{SECTION SIGN}20 step-4 structural pass " + "stopped before writing any output. This section is the " + "\N{SECTION SIGN}3 note the ruling names. \N{SECTION SIGN}3 keeps " + "its draft-3 text and line numbers, because the registries cite " + "them (\N{SECTION SIGN}16c), so the note stands here and amends " + "\N{SECTION SIGN}3 as \N{SECTION SIGN}\N{SECTION SIGN}16b and 16c " + "do; where it and \N{SECTION SIGN}3 differ, it governs. " + "\N{SECTION SIGN}\N{SECTION SIGN}16a\N{EN DASH}16c are unchanged.", + "The decision put to Max, as recorded in the chief-of-staff " + "decision log:", + '> "dynamics U2 \N{SECTION SIGN}20 step 4, second run: after the ' + "d1649 run stopped on a career-bound assertion with no output, fix " + "U2's birth universe so it counts out people whose seed coordinate " + "implies a birth year outside career's [1889, 2016] (as Track M " + "does; career.py untouched), then rerun step 4 once under d1649's " + 'scope"', + "The ruling, as recorded there:", + '> "Yes: fix, then rerun once. A reviewed PR counts out the ' + "out-of-bounds people in U2's cohort code and discloses the counts, " + "with a \N{SECTION SIGN}3 note; career.py is untouched. Once it " + "merges and master's Tests pass at the run head, step 4 (structure, " + "then component diagnostics) runs once more under d1649's scope, " + "with --authorized-by this decision. Max, dynamics coordinator " + 'session 2026-10-19 (guided AskUserQuestion)."', + "**What stopped the first pass.** The pass Max authorized on " + "2026-10-16 (d1649) ran once, on 2026-10-19 at `640adccf`, and " + "exited without writing any output; the component diagnostics were " + "not run (evidence: `EV/track-u2-step4-pass-20261019/MANIFEST.md`, " + "SHA-256 `1e91407b\N{HORIZONTAL ELLIPSIS}a366fa`). It stopped in " + "the birth law, `estimates.career.derive_birth_years`, called from " + "`cohort.derive_u2_births`. For a person the law dates by the seed " + "coordinate (age code 2\N{EN DASH}125), the law takes the birth " + "year as the seed year minus the age and asserts that it lies " + "between `career.DERIVED_BIRTH_MIN` and `career.DERIVED_BIRTH_MAX`, " + "1889 and 2016. \N{SECTION SIGN}3's universe takes every " + "positive-weight, in-family person of any support wave, at any age " + "(draft-3 lines 114 and 126), so a child aged 2 or 3 in the 2021 " + "wave or 2\N{EN DASH}5 in the 2023 wave, or a person aged 124 or " + "125 in the 2013 wave, reaches that assertion. The masked error " + "record does not show which end was crossed, so the rule covers " + "both. This record states no count or value from the pass.", + "**The note to \N{SECTION SIGN}3 (amends draft-3 line 114).** " + "Before the law runs, a person of the universe is counted out when " + "both hold:", + "1. the law would take the person's birth year from the seed " + "coordinate: neither the marriage history nor the observed earnings " + "give one, as the law itself shows when run on the same frames " + "without seed coordinates (it leaves the person unresolved); and", + "2. the seed's age code is 2\N{EN DASH}125, and the year it implies " + "is before 1889 or after 2016.", + "A counted-out person leaves the universe and the frames the law " + "reads. Every other person keeps the law's precedence and the " + "record the law gives, whatever the seed implies: a " + "marriage-history or earnings year still wins. Seed codes 1 and 999 " + "stay unresolved, and any other code keeps the law's refusal. The " + "seed is still the earliest positive-weight presence; a later " + "presence never replaces it. A counted-out person had a " + "positive-weight presence, so is not outside the universe, and the " + "annuitant extension (line 188) does not seed it either; where such " + "a person is an annuitant, its age takes the inherited wave-age " + "fallback, counted. The extension seeds only presences whose " + "implied year the law supports, as before, so it cannot reach the " + "assertion.", + "**How the ruling's words are read.** The decision speaks of " + '"people whose seed coordinate implies a birth year outside" the ' + "support. This record reads that as the people whose birth year the " + "seed coordinate gives: condition 1 above. The seed gives no birth " + "year to a person with a marriage-history or earnings year. " + "Counting such a person out would let the seed override the " + 'precedence \N{SECTION SIGN}3 keeps (line 114: "with its existing ' + 'history inputs and precedence") and could remove a member of the ' + "cohort. The two readings differ only for a person with both an " + "out-of-support seed and a history or earnings year.", + "**Why no member of the cohort is lost.** A counted-out person's " + "only birth-year source is a seed whose year lies outside " + "1889\N{EN DASH}2016, which contains 1946\N{EN DASH}55, so no " + "counted-out person can belong to the cohort in any row.", + "**Precedent.** Track M counts out, before the same law, the " + "in-family, positive-weight persons aged 0\N{EN DASH}5 in its one " + "seed wave, 2023, whose seed (income year 2022 minus age) implies a " + "year after 2016 " + "(`src/populace_dynamics/min_benefit_track_m/structure.py`, the " + "funnel count `age_0_5_counted_out_before_birth_law`, and " + "`min_benefit_track_m/cohort.py`). U2 seeds from six waves, so its " + "rule tests the implied year on both sides, not an age; it applies " + "only to age codes 2\N{EN DASH}125, so codes 0 and 1 keep the law's " + "paths; and it applies only to persons the seed would date, so the " + "law's precedence is kept.", + "**Disclosure.** Each side's count is disclosed in every row's " + "structural counts, as `n_seed_birth_year_counted_out` with the " + "keys `before_1889` and `after_2016`, and decides nothing. The rule " + "is fixed before any count, and none was computed for this record.", + "**What the ruling leaves unchanged.**", + "- `src/populace_dynamics/estimates/career.py` is unchanged.", + "- No row, plan cell, observation weight, disposition rule, design " + "frame, role rule or income concept changes. U2 stays unregistered " + "and unauthorized (\N{SECTION SIGN}20).", + "- The ruling authorizes one more step-4 pass, structure and then " + "component diagnostics, under d1649's scope with `--authorized-by " + "d1724`, once this change merges and master's Tests pass at the run " + "head (\N{SECTION SIGN}20 step 4).", + "- No count, statistic or outcome was computed to record this " + "ruling, no PSID record was read, and no file on the restricted " + "list was opened for reading. Reading and exposure are recorded in " + "`docs/design/u2_m2_exposure.md` (d1724 section).", +] + + +#: Where section 17's review paragraph and section 20 step 4 point to the +#: d1724 record: each sentence stands in its place exactly once. +D1724_POINTERS = { + "outstanding": "After the first structural pass stopped, Max ruled on " + "the birth law's support on 2026-10-19, and \N{SECTION SIGN}16d records " + "that ruling.", + "execution": "**First pass (d1649): stopped.** It ran once, on " + "2026-10-19 at `640adccf`, and stopped in the birth law without writing " + "any output; the component diagnostics were not run " + "(\N{SECTION SIGN}16d). **Max's ruling of 2026-10-19 (d1724; " + "\N{SECTION SIGN}16d)** authorizes one more pass, structure and then " + "component diagnostics, under d1649's scope with " + "`--authorized-by d1724`, once the \N{SECTION SIGN}16d change merges " + "and master's Tests pass at the run head.", +} + + +def check_d1724_record(spec): + """Section 16d records d1724: it stands once, between sections 16c and + 17, outside code, and holds exactly :data:`D1724_RECORD`, in order; + sections 17 and 20 point to it; and the d1090 checks (which refuse + hiding by comment, raw HTML or fence anywhere) still hold.""" + check_d1090_record(spec) + lines = spec.split("\n") + start = _heading_line(lines, "16d") + end = _heading_line(lines, "17") + assert _heading_line(lines, "16c") < start < end + assert not any(_fenced(lines)[start:end]) + assert [x for x in lines[start:end] if x.strip()] == D1724_RECORD + places = d1090_locations(spec) + for place, sentence in D1724_POINTERS.items(): + assert places[place].count(sentence) == 1, place + + +def test_section_16d_records_the_d1724_ruling(): + """§16d is the §3 note d1724 names: every line pinned, and §17 and §20 + step 4 point to it. The rule it states is the one the cohort code + applies (tests/track_u2/test_u2_birth_bounds.py).""" + spec = (ROOT / SPEC).read_text(encoding="utf-8") + check_d1724_record(spec) + section = spec[spec.index("## 16d. ") : spec.index("## 17. ")] + for clause in ( + "before 1889 or after 2016", + "`n_seed_birth_year_counted_out`", + "`before_1889` and `after_2016`", + "and decides nothing", + "(amends draft-3 line 114)", + ): + assert section.count(clause) == 1, clause + + +#: Mutations of the d1724 record, each of which must fail +#: ``check_d1724_record``: a changed rule, side, code range, precedence, +#: disclosure, scope or authorization, a moved heading, an added line, and +#: hiding by comment. +D1724_MUTATIONS = [ + ("16d", "before 1889 or after 2016.", "before 1889."), + ("16d", "age code is 2\N{EN DASH}125", "age code is 1\N{EN DASH}125"), + ("16d", "and decides nothing.", "and decides whether the pass proceeds."), + ( + "16d", + "a marriage-history or earnings year still wins", + "the seed's year wins", + ), + ( + "16d", + "Seed codes 1 and 999 stay unresolved", + "Seed codes 1 and 999 are counted out", + ), + ("16d", "does not seed it either", "seeds it instead"), + ( + "16d", + "so no counted-out person can belong", + "so a counted-out person can", + ), + ("16d", "(amends draft-3 line 114)", "(amends draft-3 line 126)"), + ( + "16d", + "estimates/career.py` is unchanged.", + "estimates/career.py` changes.", + ), + ( + "16d", + "U2 stays unregistered and unauthorized (\N{SECTION SIGN}20).", + ".", + ), + ( + "16d", + "authorizes one more step-4 pass", + "authorizes two more step-4 passes", + ), + ( + "execution", + "authorizes one more pass, structure", + "authorizes passes, structure", + ), + ( + "outstanding", + "and \N{SECTION SIGN}16d records that ruling.", + "and that is all.", + ), + ("16d", "## 16d. Max's ruling", "### 16d. Max's ruling"), + ( + "16d", + "**What the ruling leaves unchanged.**", + "**What the ruling leaves unchanged.**\n\n- Exploratory counts may " + "be computed.", + ), + ( + "16d", + "**Disclosure.** Each side's count", + "