Skip to content

Commit 81afd0c

Browse files
committed
Merge remote-tracking branch 'origin/master' into m6-lock-flip
2 parents 8758e80 + 4c6a0f6 commit 81afd0c

1 file changed

Lines changed: 200 additions & 2 deletions

File tree

docs/design/m6_projection_engine.md

Lines changed: 200 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@
1717
structure, fit `≤2014` by construction and **first-certified by `gate_m6`** (not
1818
`gate_1`) — with the annualization rule, the certification framing, and the
1919
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.
2030
- **Revision 1** incorporates the adversarial design referee round (PR #170
2131
comment 4953818376, verdict MAJOR REVISION) and the ten-decision adjudication
2232
(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
561571
`(k, t)` earnings draws share entropy, any biennial step is independently
562572
reproducible, and the split stream stays disjoint from `5200 + k` (§3.3).
563573

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+
564754
## 3. RNG discipline — the projection stream registry
565755

566756
### 3.1 The inherited convention
@@ -1231,7 +1421,7 @@ lock ceremony.
12311421
```json m6-design-parameters
12321422
{
12331423
"design_id": "2026-07-12-m6-projection-engine",
1234-
"revision": 2,
1424+
"revision": 3,
12351425
"referee_round": "PR #170 comment 4953818376 (MAJOR REVISION)",
12361426
"adjudication": "issue #42 comment 4953722912",
12371427
"status": "design_draft",
@@ -1278,7 +1468,15 @@ lock ceremony.
12781468
"certification": "NOT gate_1-certified (different/forward law); first-certified by gate_m6; no gate_1 certificate transfers",
12791469
"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",
12801470
"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
12821480
},
12831481
"scoring": {
12841482
"estimator": "mean over K=20 draws, numpy.random.default_rng(5200 + k), scored once |ln(rbar/rate_a)|",

0 commit comments

Comments
 (0)