|
17 | 17 | structure, fit `≤2014` by construction and **first-certified by `gate_m6`** (not |
18 | 18 | `gate_1`) — with the annualization rule, the certification framing, and the |
19 | 19 | implementation contract for the engine lane; §4.2 and decision 10 are updated. |
| 20 | +- **Revision 3 (design amendment 2)** adds §2.7.6, closing the engine build's |
| 21 | + round-2 blocker (Sol): §2.7 specified the forward rank draw but not the |
| 22 | + leakage-safe rank↔level map for the 2016/2018 draws (the certified de-index uses |
| 23 | + target-period marginals that do not exist post-`T*`). §2.7.6 pins the rank-to-level |
| 24 | + law (a NAWI-normalized calendar-invariant `≤2014` marginal, re-indexed by a |
| 25 | + `≤2014`-projected wage index — never realized post-`T*` NAWI, which would leak into |
| 26 | + the gated level cells), the re-ranking law (the fit-population CDF, the exact |
| 27 | + inverse), and the four secondary byte-determinism gaps, so the engine lane |
| 28 | + implements with zero design choices. The `gate_m6` lock flip's `design_commit` |
| 29 | + must finalize to this amendment's merge. |
20 | 30 | - **Revision 1** incorporates the adversarial design referee round (PR #170 |
21 | 31 | comment 4953818376, verdict MAJOR REVISION) and the ten-decision adjudication |
22 | 32 | (comment 4953722912, whose conditions bind). The referee cleared the two hard |
@@ -561,6 +571,186 @@ Because each biennial draw uses its own period-`t` per-person stream, no two |
561 | 571 | `(k, t)` earnings draws share entropy, any biennial step is independently |
562 | 572 | reproducible, and the split stream stays disjoint from `5200 + k` (§3.3). |
563 | 573 |
|
| 574 | +### 2.7.6 The rank-to-level and re-ranking laws (design amendment 2, closes Sol's round-2 blocker) |
| 575 | + |
| 576 | +Round 2 pinned the forward conditional-RANK draw and the real per-year seam, but |
| 577 | +the engine build blocked again (Sol, `~/PolicyEngine/sol-worktrees/m6-forward- |
| 578 | +REPORT.md`): §2.7 never specified the **leakage-safe map from a drawn rank to a |
| 579 | +positive earnings LEVEL** for 2016/2018, nor its inverse (recovering the drawn-2016 |
| 580 | +rank at the 2018 step). This subsection pins both, plus the four secondary |
| 581 | +byte-determinism gaps Sol enumerated, so the engine lane implements with **zero** |
| 582 | +design choices. It closes the blocker; the lock flip's `design_commit` pin |
| 583 | +(§2.7.3) must finalize to **this amendment's** merge commit — the `gate_m6` lock is |
| 584 | +holding for it (§2.7.6.6). |
| 585 | + |
| 586 | +**2.7.6.1 The certified de-index, and why it does not transfer forward.** The |
| 587 | +certified chain draws a RANK (`_knn_draw` returns the donor's `u_prev`, not a level |
| 588 | +— `run_gate1_candidate7.py:383-400`) and de-indexes it through a **marginal |
| 589 | +quantile map**: `earnings = marginals[(age_bin, period)].quantile(u_prev)` |
| 590 | +(`candidate7:648-654`), where each `CellMarginal` (`run_gate1_candidate5b.py:283-313`) |
| 591 | +is the weighted `Qhat_pos` quantile of that `(age_bin, period)` cell's **nominal** |
| 592 | +positive earnings, with `p0` the zero share and `rhat` the inverse rank map (no |
| 593 | +NAWI normalization anywhere in `c7`/`c5b`). A `≤2014` fit has no `(age_bin, 2016)` |
| 594 | +or `(age_bin, 2018)` cell, and §2.7.1 forbids carrying a period-specific marginal |
| 595 | +past its last fit cell — so the inherited de-index cannot supply the forward |
| 596 | +level/rank maps, and a `≤2014`-derivable substitute must be pinned. |
| 597 | + |
| 598 | +**2.7.6.2 Rank-to-level law — options and the pinned choice.** Three admissible |
| 599 | +constructions, adjudicated: |
| 600 | + |
| 601 | +- **(a) donor-level bootstrap** — carry the k-NN-selected donor's own realized |
| 602 | + `t+2` level from the `≤2014` forward tuples, wage-index-bridged to the target |
| 603 | + wave. *Rejected.* The certified de-index is a **marginal quantile map**, not a |
| 604 | + donor-level carry: `_knn_draw` returns the donor's rank and the level comes from |
| 605 | + `cell.quantile` (`candidate7:648-654`). Swapping in a donor-level bootstrap |
| 606 | + changes the estimated de-index **mechanism** (an R2 violation — §2.6), and it has |
| 607 | + no clean inverse for the re-rank (§2.7.6.4), which would need the marginal CDF |
| 608 | + anyway. |
| 609 | +- **(b1) carry the 2014 period marginal** — apply `marginals[(age_bin, 2014)]` to |
| 610 | + the 2016/2018 draws. *Rejected.* This is exactly "carrying a period-specific |
| 611 | + marginal past its last fit cell," which §2.7.1 forbids; it also omits 2014→2016 |
| 612 | + wage growth, biasing the gated `earn_p10/p50` levels low. |
| 613 | +- **(b2) NAWI-normalized calendar-invariant marginal, re-indexed — PINNED.** Fit a |
| 614 | + single `CellMarginal` per `age_bin` (the certified `fit_cell_marginals` / |
| 615 | + `_plotting_positions` machinery `candidate5b:283-313` verbatim) on the **pooled |
| 616 | + `≤2014`** positive earnings (one `CellMarginal` per `age_bin` on the `[25,64]` |
| 617 | + grid, §2.7.6.5), each divided by its wave's wage index (NAWI-normalized to a |
| 618 | + common scale). De-index a drawn rank `u`: normalized level `ĝ = |
| 619 | + Qhat_pos_agebin.quantile(u)`, then the nominal target-year level is |
| 620 | + `ĝ × I_proj(target_year)` (§2.7.6.3). This **keeps the certified de-index |
| 621 | + MECHANISM** — it re-fits a **new** `age_bin`, NAWI-normalized object through the |
| 622 | + **same `CellMarginal` machinery** (`fit_cell_marginals` / `_plotting_positions`, |
| 623 | + `candidate5b:283-313`), not the certified `(age_bin, period)` object itself (a |
| 624 | + `CellMarginal` quantile — R2-faithful). It is fully `≤2014`-derivable, is **not** a |
| 625 | + period-specific marginal (calendar-invariant, so §2.7.1-consistent), and has an |
| 626 | + inverse for the re-rank (interior-exact, §2.7.6.4). `p0` (the zero share) and the participation gate are unchanged; a |
| 627 | + drawn zero stays a zero (non-participation). |
| 628 | + |
| 629 | +**2.7.6.3 The wage index `I_proj` — `≤2014`-derivable, pinned; the leakage |
| 630 | +prohibition.** `I_proj(2016)` and `I_proj(2018)` are the wage-index (SSA average- |
| 631 | +wage index / NAWI — the `ss/params.py` series the M2 `taxable_payroll_convention` |
| 632 | +and AIME indexing use) **projected from `≤2014`**, never the realized 2016/2018 |
| 633 | +values. Pinned form: an **OLS log-linear fit of `ln(NAWI_y)` on `y` over the |
| 634 | +trailing decade `y ∈ [2005, 2014]`**, extrapolated to 2016/2018 (equivalently: |
| 635 | +apply the `≤2014` trailing-decade geometric-mean annual wage-growth rate forward |
| 636 | +from `NAWI_2014`). Justified from `≤2014` diagnostics only: a decade window |
| 637 | +averages out biennial sampling noise while reflecting the recent (post-2005) |
| 638 | +wage-growth regime and excluding pre-2005 structural breaks; the two scored steps |
| 639 | +consume only `I_proj(2016)` and `I_proj(2018)`. **NAWI publication lag, disclosed:** |
| 640 | +the SSA average-wage index for a year is published ~2 years later, so `NAWI_2013` |
| 641 | +and `NAWI_2014` are *published* after 2014; but the AWI is a **final, unrevised** |
| 642 | +series (SSA does not restate prior AWI values once set), so a `NAWI_y` with |
| 643 | +`y ≤ 2014` is a fixed function of `≤ 2014` wages — leakage-safe. Only its |
| 644 | +publication *date* is post-`T*`, not its *information*; no realized 2016/2018 wage |
| 645 | +outcome enters `I_proj`. |
| 646 | + |
| 647 | +> **Leakage prohibition (pinned).** Using any **realized** post-`T*` NAWI / wage- |
| 648 | +> index value on the **scored** path is PROHIBITED — it would leak realized wage |
| 649 | +> growth into the gated level cells (`earn_p10`, `earn_p50`, the Δlog-earnings SD, |
| 650 | +> the mobility diagonal). Concretely: the projection frame **carries a realized |
| 651 | +> `nawi` column** (the aging step rolls `nawi_by_year → nawi`, `steps.py:145-156`); |
| 652 | +> the scored forward earnings de-index MUST NOT read it for 2016/2018 — it uses the |
| 653 | +> generator's own `I_proj`. The realized `nawi` is admissible only for the |
| 654 | +> **non-scored** seam / `taxable_max` plumbing and for the **report-only alignment |
| 655 | +> path**. The gated path is the **un-aligned** projection (decision 9, §4.8: "gate |
| 656 | +> the un-aligned drift"; the alignment layer is report-only, the M2 |
| 657 | +> `calibration_disclosure` lesson); realized-index alignment — snapping the |
| 658 | +> projected level onto realized NAWI — may appear only on the report-only path, |
| 659 | +> disclosed per run within the **maximum alignment displacement** (adjudication 9, |
| 660 | +> §4.8). |
| 661 | +
|
| 662 | +**2.7.6.4 Re-ranking law — options and the pinned choice.** Recovering `rank_t` |
| 663 | +from the carried nominal `earnings` level at the next biennial step: |
| 664 | + |
| 665 | +- **within-frame cross-sectional percentile** — rank the level within the current |
| 666 | + projected frame's cross-section. *Rejected.* It references the **synthetic |
| 667 | + projected cohort**, a population the chain was never fit on (an R2 violation), and |
| 668 | + it is not the inverse of §2.7.6.2, so the rank→level→rank round-trip would not |
| 669 | + close. |
| 670 | +- **fit-population marginal CDF — PINNED.** Recover `rank_t = rhat_agebin( ℓ / |
| 671 | + I_proj(wave_of_ℓ) )` — normalize the carried nominal level by its wave's |
| 672 | + `I_proj`, then evaluate the **same `age_bin` `CellMarginal`'s inverse rank map |
| 673 | + `rhat`** used to de-index in §2.7.6.2 (`candidate5b` `CellMarginal.rank`). It is |
| 674 | + the **inverse** of §2.7.6.2 — both are `np.interp` on the one `(wtil, yval)` grid — |
| 675 | + **exact on the strictly-monotone interior**. At the corners it is |
| 676 | + **deterministic but inexact**: `quantile` clamps to `[ymin, ymax]` and `rank` to |
| 677 | + `[0.001, 0.999]` (`RANK_CLAMP_LO/HI`), and a flat-`yval` tie maps back to the first |
| 678 | + `wtil` (`candidate5b:250-259`; the certified code tracks this corner mass as |
| 679 | + `corner_bottom` / `corner_top`). **Consequence for the 2018 re-rank:** `rank(ℓ)` |
| 680 | + is a pure deterministic function of the carried level, so **chain integrity holds** |
| 681 | + (the 2018 draw conditions on a well-defined `rank_t`); the inexactness is only a |
| 682 | + **bounded rank perturbation at the corners** (a tail level clamps into |
| 683 | + `[0.001, 0.999]`; a tie collapses to one grid rank), never a break in |
| 684 | + reproducibility. A carried zero maps to the `p0` / zero-anchor (Q0) regime. |
| 685 | + **Conditioning-consistency (R2):** the |
| 686 | + certified chain's ranks **are** `CellMarginal` CDF positions (it draws `u_prev` |
| 687 | + and de-indexes via `cell.quantile`), so composing as-estimated requires the |
| 688 | + deployed re-rank to reference the **fit-population marginal**, exactly what the |
| 689 | + pinned rule does — not the projected frame's own cross-section. |
| 690 | + |
| 691 | +**2.7.6.5 The four secondary gaps, pinned (byte-determinism).** |
| 692 | + |
| 693 | +- **Memory after 2018 (generated-lag columns).** The frame carries the **two most |
| 694 | + recent generated biennial levels** — `gen_earn_w2` (the `t−2` wave) and |
| 695 | + `gen_earn_w4` (the `t−4` wave) — updated at each biennial step, so every draw has |
| 696 | + its two-step-plus-anchor memory (`rank_t` from `gen_earn_w2`, `rank_{t−2}` from |
| 697 | + `gen_earn_w4`, both via §2.7.6.4; anchor `u_A` from realized-2014). The |
| 698 | + start-of-chain ramp (§2.7.1) fills the early lags from the realized `≤2014` |
| 699 | + columns: `2016` uses realized-2014 (`w−2`) + realized-2012 (`w−4`), `2018` uses |
| 700 | + drawn-2016 + realized-2014, `2020` uses drawn-2018 + drawn-2016. |
| 701 | +- **Generator → substream bridge.** The seam passes an `np.random.Generator`; the |
| 702 | + certified helper wants an integer seed. Pinned: reduce the per-person `EARNINGS` |
| 703 | + generator to a seed with `engine.rng.seed_from_generator(rng)` (the existing |
| 704 | + bridge — `int(generator.integers(0, 2**64-1, dtype=uint64))`, |
| 705 | + `origin/m6-engine:engine/rng.py`) and construct the three certified sub-streams as |
| 706 | + `default_rng(SeedSequence([seed, code]))` for `code ∈ {1: gate, 2: donor-draw, |
| 707 | + 3: re-entry-draw}` (`SUBSTREAM_CODES`, `candidate10:239`), the `_substream` |
| 708 | + construction verbatim. |
| 709 | +- **Frame schema (concrete columns).** The generator reads exactly: |
| 710 | + `person_id`, `age`, `sex`, `u_w` (the §2.7.1 latent-permanent rank), |
| 711 | + `realized_earn_2014` and `realized_earn_2012` (the anchor + `t−4` start-lag), |
| 712 | + `earnings` (current level), and the rolling `gen_earn_w2` / `gen_earn_w4`. The |
| 713 | + **engine `≤2014` refit bundle** (`engine/refit.py`) materializes `u_w` and the |
| 714 | + `realized_earn_*` columns at period 0 (deployment); `_merge_period_columns` |
| 715 | + carries them and the rolling generated-lag columns forward. No post-`T*` column |
| 716 | + (including the realized `nawi`) enters the scored de-index. |
| 717 | +- **Age support — the forward law fits `[25,64]`, extending the transferred gate-1 |
| 718 | + `[25,59]`.** The forward law is a **new** law first-certified by `gate_m6` |
| 719 | + (§2.7.3), so its fit range is a hyperparameter transferred **where transferable**. |
| 720 | + Here the gate-1 transfer `[25,59]` (`refit.py:802-806`, `age_range [25,59]`) |
| 721 | + **conflicts with the pre-registered truth-side surface**: the gated earnings |
| 722 | + cohorts are `prime = 25–44` and `older = 45–64` (`EARN_COHORTS`, |
| 723 | + `scripts/build_m6_holdout_floors.py:120`; the panel has **no** age cap), so |
| 724 | + `earn_*.older` scores ages up to **64**. Clipping `60–64` through the `55–59` |
| 725 | + marginal would understate the retirement-transition zero-inflation and **kill the |
| 726 | + `earn_zero_rate.older` signal**. The `[25,59]` fit range is therefore a |
| 727 | + **non-transferable** hyperparameter: the forward **participation gate**, **donor |
| 728 | + pools**, **memory ramp**, and the calendar-invariant **NAWI-normalized marginal** |
| 729 | + (§2.7.6.2) all fit on **`[25,64]` pooled `≤2014`** — the `≤2014` PSID panel |
| 730 | + contains ages 60–64 and exactly the retirement-transition signal `earn_zero_rate |
| 731 | + .older` scores, so the extension is fully `≤2014`-derivable. The `age_bin` grid |
| 732 | + extends from the certified 7 five-year bins to **8**: `{25–29, 30–34, 35–39, |
| 733 | + 40–44, 45–49, 50–54, 55–59, 60–64}`. The lookup **clips to the nearest fitted bin |
| 734 | + at the new boundary** (`<25 → 25–29`, `>64 → 60–64`); the `<25` and `65+` draws |
| 735 | + are **non-scored** plumbing (feeding only the seam / AIME). **The principle:** the |
| 736 | + law must **cover the surface it is scored on**; narrowing the surface — re-scoping |
| 737 | + or demoting a gated cell — to fit the model's support would be **candidate-informed |
| 738 | + surface design, prohibited** in that direction (the surface is pre-registered |
| 739 | + truth-side; §4.9, decision 10). *Verified:* every gated earnings cell's cohort |
| 740 | + span — `prime 25–44`, `older 45–64` (`EARN_COHORTS`, |
| 741 | + `build_m6_holdout_floors.py:120`) — lies inside `[25,64]`. |
| 742 | + |
| 743 | +**2.7.6.6 Closes the blocker; the lock dependency.** Every under-determined |
| 744 | +primitive Sol flagged is now pinned — the rank→level map (§2.7.6.2), its exact |
| 745 | +inverse (§2.7.6.4), the `≤2014` index basis and its leakage fence (§2.7.6.3), and |
| 746 | +the four byte-determinism gaps (§2.7.6.5) — so the engine lane implements the |
| 747 | +forward generator with **zero** design choices. This **closes Sol's round-2 |
| 748 | +blocker.** Because this amendment changes the design the `gate_m6` block draft |
| 749 | +pins, the **lock flip's `design_commit` must finalize to this amendment's merge |
| 750 | +commit** (not the round-2 `d6abb16`); the block draft's `design_commit_note` |
| 751 | +already records the finalize-at-lock-flip rule, and the `gate_m6` lock is holding |
| 752 | +for this amendment. |
| 753 | + |
564 | 754 | ## 3. RNG discipline — the projection stream registry |
565 | 755 |
|
566 | 756 | ### 3.1 The inherited convention |
@@ -1231,7 +1421,7 @@ lock ceremony. |
1231 | 1421 | ```json m6-design-parameters |
1232 | 1422 | { |
1233 | 1423 | "design_id": "2026-07-12-m6-projection-engine", |
1234 | | - "revision": 2, |
| 1424 | + "revision": 3, |
1235 | 1425 | "referee_round": "PR #170 comment 4953818376 (MAJOR REVISION)", |
1236 | 1426 | "adjudication": "issue #42 comment 4953722912", |
1237 | 1427 | "status": "design_draft", |
@@ -1278,7 +1468,15 @@ lock ceremony. |
1278 | 1468 | "certification": "NOT gate_1-certified (different/forward law); first-certified by gate_m6; no gate_1 certificate transfers", |
1279 | 1469 | "seam_signature": "EarningsGenerator.generate(frame, year, rng) -> np.ndarray (steps.py:164-169); per-year, called by apply_earnings every projection year; stateful THROUGH the frame (drawn earnings column written in-place and carried by the loop's frame reassignment; _merge_period_columns threads the marital/disability/household update-frames), not a pure fn", |
1280 | 1470 | "rng_slots": "per-year person stream context.person_generator(EARNINGS, person_id) = stream(k,t=year,earnings); RNG consumed only at even reference years; even-year spawns candidate-7 SUBSTREAM_CODES {gate:1,donor:2,reentry:3}; odd-year carry-forward deterministic", |
1281 | | - "drawn_prior_rank_threading": "at 2018, drawn-2016 rank recovered inside generate by re-ranking the frame's carried earnings column (0=non-participation); rank_{t-2} + anchor from realized-2014 columns; no generator-held cross-year state" |
| 1471 | + "drawn_prior_rank_threading": "at 2018, drawn-2016 rank recovered inside generate by re-ranking the frame's carried earnings column (0=non-participation); rank_{t-2} + anchor from realized-2014 columns; no generator-held cross-year state", |
| 1472 | + "amendment_2_section": "2.7.6 (closes Sol round-2 blocker: rank<->level map undefined)", |
| 1473 | + "rank_to_level_law": "PINNED (b2): NAWI-normalized calendar-invariant age_bin CellMarginal on pooled <=2014 positive earnings (certified fit_cell_marginals verbatim, key (age_bin,period)->age_bin); de-index rank u -> Qhat_pos_agebin.quantile(u) normalized level -> x I_proj(target_year) -> nominal level. REJECT (a) donor-level bootstrap (departs from certified marginal-quantile de-index candidate7:648-654, R2 violation, no clean inverse); REJECT (b1) carry-2014-period-marginal (§2.7.1-prohibited + biases earn_p10 low)", |
| 1474 | + "wage_index_projection": "I_proj(2016),I_proj(2018) = OLS ln(NAWI)~year over trailing decade [2005,2014] extrapolated (== <=2014 trailing-decade geometric-mean wage growth); ss/params.py NAWI series; NEVER realized post-2014 NAWI", |
| 1475 | + "leakage_prohibition": "realized post-T* NAWI on the SCORED path PROHIBITED (would leak into gated earn_p10/p50/Dlog-SD/mobility); frame's realized nawi column (steps.py:145-156 aging) admissible only for non-scored seam/taxable_max + report-only alignment; gated path = un-aligned projection (decision 9, §4.8; M2 calibration_disclosure)", |
| 1476 | + "re_ranking_law": "PINNED fit-population CDF: rank_t = rhat_agebin(level / I_proj(wave)) -- the inverse of rank_to_level (Qhat_pos/rhat = np.interp on one grid), INTERIOR-EXACT; corner clamps ([ymin,ymax]/[0.001,0.999]) + flat-yval ties are deterministic-but-inexact (candidate5b:250-259, corner_bottom/top); 2018 re-rank is deterministic so chain integrity holds (inexactness = bounded corner rank perturbation); zero -> p0/Q0 regime. REJECT within-frame percentile (references synthetic projected cohort not the PSID fit population, R2 violation, breaks round-trip). R2: certified ranks ARE CellMarginal CDF positions so deployed re-rank must reference the fit-population marginal", |
| 1477 | + "secondary_gaps_pinned": {"memory_after_2018": "two rolling generated-biennial-level cols gen_earn_w2/gen_earn_w4 + realized-<=2014 start-lags", "generator_to_substream": "engine.rng.seed_from_generator(rng)->int then SeedSequence([seed,code]) codes {1:gate,2:donor,3:reentry} (m6-engine rng.py; candidate10:239)", "frame_schema": "person_id,age,sex,u_w,realized_earn_2014,realized_earn_2012,earnings,gen_earn_w2,gen_earn_w4; engine refit.py materializes u_w+realized_earn_* at period 0; _merge_period_columns carries", "age_support": "forward law fits [25,64] (EXTENDS transferred gate-1 [25,59], refit.py:802-806) because gated cohorts prime 25-44 + older 45-64 (EARN_COHORTS build_m6_holdout_floors.py:120, no panel age cap); [25,59] is a NON-transferable hyperparameter (conflicts pre-registered surface; would kill earn_zero_rate.older); 8 age_bins {25-29..60-64}; participation-gate/donor-pools/memory/marginal all fit [25,64] pooled <=2014; clip <25->25-29, >64->60-64; <25 & 65+ non-scored; principle: law must cover the scored surface, narrowing to fit support = candidate-informed surface design PROHIBITED"}, |
| 1478 | + "closes_round2_blocker": true, |
| 1479 | + "lock_flip_design_commit_finalizes_to_this_amendment_merge": true |
1282 | 1480 | }, |
1283 | 1481 | "scoring": { |
1284 | 1482 | "estimator": "mean over K=20 draws, numpy.random.default_rng(5200 + k), scored once |ln(rbar/rate_a)|", |
|
0 commit comments