Skip to content

Activate the NZ graph against pinned Chronicle facts (NZ graph G8) #1183

Open

Description

@MaxGhenis

Part of #343. This is the last package of the NZ graph build. G1–G7 have merged: #1118, #1119, #1120, #1122, #1125, #1130, #1138, #1139, #1159, #1161 and #1181, plus the review follow-ups #1180 and #1182.

Why it is needed

Every NZ ledger reference on main is an authoring placeholder:

  • 8 calibration targets (build/nz/target_references.json);
  • 6 pre-calibration references (precal_references.json);
  • 6 hold-out comparators (holdout_references.json).

None of them has activation_status: active, and compile_ledger_target_references refuses any reference that isn't active (packages/microcosm-build/src/microcosm/build/ledger_targets.py, _require_executable_reference). So no NZ endpoint can run on real data until these references point at pinned Chronicle cells.

Scope

  • Feed pins. Add build/nz/chronicle_feed.json, pinning the Chronicle artifact by commit, SHA-256 values and row counts in the shape of build/uk/chronicle_feed.json. Declare it in build/nz/country_package.json, so the spec-only package test accepts it.
  • References. Replace the placeholders with harvested, cell-pinned references for the approved calibration target set. Only the approved set is activated.
  • Hold-outs. Hold-out references stay out of the calibration set. That covers Accommodation Supplement counts and dollars, Working for Families dollars, and the appropriation comparators. Activate them only as comparators.
  • Thresholds. Write the approved gate thresholds into build/nz/gates.json, and the approved scenario parameters into build/nz/scenarios.json.

Acceptance

  • compile_ledger_target_references resolves every active reference against the pinned artifact.
  • The NZ spec tests and the spec-only package test pass.
  • A local real-data run compiles every target.
  • Add the NZ transport contract and real-Axiom Frame bridge #820 is closed, with each of its acceptance items checked against main and linked to the PR that delivered it, or carried forward here.

Blocked on

Activity

  1. MaxGhenis commented on Oct 12, 2026

    @MaxGhenis
    ContributorAuthor

    Progress, 2026-10-12.

    Merged

    In review

    Decided

    • The calibration fit tolerances are ruled: ≤5% per target, ≤2% for demography, 5% for the admin aggregates (decision d1376, 2026-10-12). G8-r writes them into gates.json when it activates those gates.

    Next

    Still missing: a liquid-asset distribution for MC10. Without it, only the asset-test-off bound can run.

  2. MaxGhenis commented on Oct 12, 2026

    @MaxGhenis
    ContributorAuthor

    Progress, 2026-10-12, NZ hub.

    Merged today

    In review (each re-run by the hub at its head; reviews re-dispatching after today's restarts)

    Next

    • G8-r (references; needs B2/B5).
    • G8-d2 (TA population).
    • A liquid-asset source, still not found.
    • G8-w (wiring), then G8-x (real-data run).

    The rulespec-nz waiver exits (d1055 = b) run on the Axiom side: the first reviewed candidate, WEP s75, is waiting on a waiver-refresh re-pin chain. They don't block the G8 graph work.

  3. MaxGhenis commented on Nov 1, 2026

    @MaxGhenis
    ContributorAuthor

    G8-w3 split into three PRs: G8-w3a (#1437, NZ declarations and row activation), G8-w3r (#1436, run-entry money factors) and G8-w3b (next, the graph and the toy run).

    New package G8-y (post-v0 reference activation), starting after G8-x. It owns the closing step for the rows v0 can't support: the WFF tripwires, MBIE P2, P3, Veterans Pension and the Treasury AN 24/01 hold-out. Each needs inputs v0 doesn't have, so naming the package doesn't promise activation. reference_selection.json names each row's prerequisites.

    🤖 Generated with Claude Code

  4. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    ContributorAuthor

    Ready for hub review
    G8-r2c design
    RESULT: READY
    Needs Max: yes

    Main is at a6bcd1ab319302cadd924ab989d380b283854534. Every file:line below refers to that commit unless it names a w4 branch: g8w4c is origin/nzhub/g8w4c-support at 3e72df35 and g8w4d is origin/nzhub/g8w4d-nz-wiring at d81280bb. B/, T/ and F/ are as in the brief, and N/ is B/nz/. Labels: Read means I read the code, Ran means I executed it this session, and Inferred means my reasoning. The embedded card, proposal and review are claims. I couldn't reach the pinned Chronicle feed (it's on R2), and the proxy refused ird.govt.nz (HTTP 403), so this report reproduces no feed value. Every IRD count or amount below comes from the proposal, labelled as a claim.

    What I ran. 400 tests passed and 39 were skipped (the skips need the Chronicle feed) in test_nz_reference_intervals.py, test_reference_intervals.py, test_reference_profiles.py, test_nz_target_references.py, test_nz_money_normalization.py and the frame's test_transport_quantile.py. I also ran four probes against the packaged code. Each is quoted where it's used.

    Needs Max covers one non-blocking item, Q9: whether a Pareto top band fitted to the destination's own counts becomes the cross-country default. NZ's build doesn't wait on it.

    1. State on main

    Piece r2c needs Where State Missing for r2c
    IRD wage-and-salary rows (P1) N/precal_references.json:10 onward; exempt at N/reference_selection.json:3331 onward Read. 235 rows, ird_wage_salary_people_by_band__row_7 to __row_241, family earnings. They are TY2026 facts (period_value 2026), declared for period 2027, as person counts only: measure people and no amount companion. Status is requires_g8r2c_wage_quantile_producer, and the parking reason is that r2c must connect "the reviewed TY2026-to-TY2027 wage bridge, intervals and coverage" to a producer. The bands are 200 × $1,000 up to $200,000, 20 × $5,000 up to $300,000, 14 × $50,000 up to $1 million, then "Over $1 million" (Ran: label parse, no gap or overlap). There's no nil row: the first band is "$1 – $1,000". A producer, a wage interval declaration, activation, and removal of the 235 exemptions
    IRD taxable-income rows N/target_references.json; 474 of the 664 exemptions in N/reference_selection.json Read. 474 rows: 237 ird_taxable_income_people_by_band (measure people) and 237 ird_taxable_income_total_by_band (measure taxable_income_nzd). All are requires_prerequisites, with reference_prerequisites ["reference_profile:aging_index","measurement:ird_taxable_income_ty2027_v1"] (tools/generate_nz_target_references.py:90-99,652-657). A measurement producer. A mechanism that satisfies measurement: (Read: nothing in src does. B/reference_profiles.py:422-453 satisfies only the profile half and leaves reference_prerequisites_remaining set). A producer for the column taxable_income_nzd, which none writes.
    Parked rows by family same files Read. In precal: earnings 235, housing_costs 1 (MBIE, P2/C1), wealth 1 (P3/C2), superannuation 1 (Veterans Pension). In targets: income 474, household_structure 187, housing_tenure 34, superannuation 1 (VP). IRD rows: 235 earnings and 474 income. r2c activates the 235 wage rows as quantile-band facts, and replaces the 474 income rows with 24 coarse rows (item 3).
    P1 wage factors N/reference_aging.json:4810 (central) Read. p1_ty2026_to_ty2027_low/central/high = 1.026690 / 1.028580 / 1.030467, purpose profile_input, source 2026 and target 2027. No profile transform uses them: N/reference_profiles.json carries only the IRD transform at :84851. A consumer
    IRD target-ageing factors N/reference_aging.json:1216; transform at N/reference_profiles.json:84851-85334 Read. ird_ty2024_to_ty2027_* = 1.115056 / 1.117109 / 1.119158. scale_money_and_intervals scales the 474 rows and the national table, under required_measurement_binding: ird_taxable_income_ty2027_v1. It must move to the coarse roster (item 3)
    Donor money factors N/reference_aging.json:9590; N/money_normalization.json:29-40,64-97 Read. donor_cy2024_to_ty2027_* = 1.077090 / 1.079073 / 1.081052, purpose money_normalization_input. The donor money period is declared as calendar 2024 (basis: declared_convention). This settles the card's "NZ money year" item at the calendar-2024 factor. –
    Interval tables (r2b) N/reference_intervals.json:8 (national), :2162 (age); semantics in B/reference_intervals.py:91-109,289-433,679-685 Read. National: 237 bands, positive_band_closure: right, nil_band: present, negative_income_policy: floor_zero, first_lower_cut_cents: 0, with companions on every band. Age: 183 bands and no companions. There is no wage table and no coarse table. Ran: a 12-band table with first_lower_cut_cents: 4000000 and nil_band: absent validates; scaling by 1.117109 gives a first cut of exactly 4,468,436 cents; and classification puts 4,468,436 below the first cut and 4,468,437 in band 1. A wage table and a coarse table (items 2 and 3)
    S5 stage list N/precalibration_stages.json:1-84; validator T/compose.py:1785-1920; guard :2183-2536 Read. Two person stages, nz.precal.main_benefit_rates and nz.precal.benefit_gross_up. Every stage must name an Axiom rules_binding whose rulespec_path matches (:1824-1845). A route for a non-engine producer (item 4)
    Quantile map F/transport.py:271-391; T/population_kernels.py:411-480 (_surface_bands), :483-606 (kernel) Read. No NZ node uses it. employment_income has the money route quantile_mapped and awaits P1 (N/money_normalization.json:99-106). pending_outputs names "P1 earnings …" (N/transport_graph.json:4332). The producer node (item 2)
    Money normalization nz.money.normalize, N/transport_graph.json:423 Read. money.normalize@1 normalizes 7 person and 3 household columns and writes _cents companions, with FX 1.654 and the calendar-2024 donor factors. The VHF sibling holds the factors at 1 (N/money_normalization_hold_fixed.json). –
    Annual transfers nz.benefits.annual_transfers, N/transport_graph.json:1714; T/money_kernels.py:488-600 Read. Four outputs (JS, SPS, SLP, NZS) as *_annual_gross_transfer_cents, with exposure central: 1, full_year_stock_equivalent. The main-benefit gross-ups invert Schedule 1 (the nz.gross_up.* solves). There's no VP output. Nothing reads these four columns yet. A consumer (the stand-in)
    Support classes (w4) g8w4c T/evidence_contract.py:562-644; g8w4c T/evidence_kernels.py:890-1050; g8w4d T/compose.py:1626-1648,1742-1916 Read. The classes are quantile_mapped, normalized, currency, structural, closure_default, derived, pending, copied and uncovered. Only transport.quantile_map@1 maps to quantile_mapped, and the roster is the battery slice plus the problem's measures. person.employment_income is in neither: N/export_contract.json lists the normalized person columns and household.rent, but not employment_income or liquid_financial_assets. Item 2's support evidence
    Stand-in producer – Read. None. Only the column names exist: ird_taxable_income_proxy_cents and ird_taxable_income_proxy_universe_status (tools/generate_nz_target_references.py:98-99). Item 3

    2. The earnings map (MC4, P1)

    Kernel: a new transport.quantile_map@2, with @1 left unchanged. @1 meets every need but one.

    • Read. @1 takes literal band edges and a literal alpha (F/transport.py:75-92). It derives shares from unfiltered person count facts on the precal surface (T/population_kernels.py:433-441) and ranks at the pre-calibration weights (:569).
    • Read. The gap: a literal alpha in params is a hand-copied number that no runtime step checks against the authenticated feed.
    • @2 adds one thing. interpolation = {"method": "pareto", "estimator": {...}} computes alpha from the same surface facts _surface_bands already resolves for the shares, and records the inputs and result in its receipt. Everything else delegates to F.quantile_map, so no new interpolation code exists.
    • Read. source_hash digests a kernel's whole defining module (packages/microcosm-graph/src/microcosm/graph/kernel.py:524-563). So @2 goes in a new module (T/quantile_kernels.py), with the estimator in a new B/tail_estimators.py. Adding the class to population_kernels.py would re-key CREATE @1/@2, boundary, currency and quantile map @1.
    • Read. g8w4c quantile_support reads only interpolation["method"] and the band rows (T/evidence_contract.py:576-600), so w4's support producer accepts @2's params unchanged.

    The wage bridge scales the band edges, not the mapped values.

    • Read. Scaling the values would need a second producer of person.employment_income in one lineage, and composition refuses that (T/compose.py:892-897). Only money.normalize@1 or a quantile map may own a donor money column after CREATE (:192,:913-918).

    • Ran. The two are the same arithmetic. Uniform bands give fL + u(fU − fL) = f(L + u(U − L)). The Pareto top gives fL·exp(offset) with the offset independent of scale. Mapping 2,000 lognormal values with a Pareto top, once on edges × 1.02858 and once on unscaled edges with the outputs × 1.02858, differs by at most 3.9e-16 relative.

    • Where it's applied (Inferred, my proposal): a generated N/earnings_bands.json holds, for each aging_index profile, the effective edges in dollars. Each edge is float(cut_cents × factor / 100), computed exactly in Fraction and rounded once. The document also records the factor id, its receipt SHA-256 and the source interval table's SHA-256. Each map node selects its profile's band list. Composition recomputes every edge from the wage interval table and the selected profile's P1 factor, and refuses any difference. V1 and V2 select low and high.

    • Arithmetic on one band. "$50,001 – $51,000" is (5,000,000, 5,100,000] cents in TY2026 dollars.

      Profile Factor Edges (TY2027 dollars) Value at the band's midpoint rank
      Central 1.028580 (51,429.00, 52,457.58] 51,943.29
      Low 1.026690 (51,334.50, 52,361.19] –
      High 1.030467 (51,523.35, 52,553.817] (a fractional cent, which r2b keeps) –
    • Read. No FX rate or donor factor ever touches wages. Ranks don't change under positive scaling, so the 1.654 rate and the CY2024 factor would cancel. This is why N/money_normalization.json:3 already reserves earnings.

    Nil band, zero and negative wages, and closure.

    • Zero and negative wages.
      • Read. employment_income is unsigned with lower bound 0 (F/concepts.py:737-761,931-942), so a negative wage can't reach the map.
      • Read. Zeros keep their value and stay out of the ranks (F/transport.py:254-268). That fits IRD's table, which has no nil row and counts only people with wages.
      • Inferred. The map doesn't target how many people earn wages: the model's weighted earner count stays donor-shaped. I'd report it against Σ of the 235 counts, not fit it (Q7).
    • Wage interval table. Add ird_wage_salary_national_ty2026 to N/reference_intervals.json, declared and validated with no companions: person, employment_income_cents, right-closed, nil_band: absent, negative_income_policy: refuse, first_lower_cut_cents: 0, open tail over $1,000,000.
      • Read. It can't be scaled through scale_money_and_intervals, because that refuses a table without companions (B/reference_profiles.py:262-266) and the wage rows have no amount companions. So it serves as the single source of cuts for the generator and the composition check above.
      • Read. Its universe column has no producer, so preparation never classifies it.
      • Read. None of the wage labels shares an endpoint, unlike the four pairs in the taxable table.
    • Mismatch 1: the band edge.
      • Read. quantile_map is left-closed at a band edge. A rank equal to a cumulative share boundary goes to the next band at fraction 0 (F/transport.py:193-204) and maps to that band's lower edge. IRD's convention is right-closed.
      • Ran. With bands (0, 1000] at share 0.375 and (1000, 2000] at 0.625, four equal-weight values rank at 0.125, 0.375, 0.625 and 0.875, and the second maps to exactly 1000.00, at the bottom of the upper band. The right-closed convention would class $1,000.00 in "$1 – $1,000". On the real table the same thing happens at, for example, the central edge 51,429.00 between "$49,001 – $50,000" and "$50,001 – $51,000".
    • Mismatch 2: rounding to cents.
      • Inferred. The map writes float dollars, and money.minor_units@1 then rounds them half-even to cents (T/money_kernels.py:389-480). A value within half a cent above a whole-cent cut, such as 51,429.004, rounds onto 5,142,900 cents. The map put it in the upper band; the right-closed table puts it in the lower one.
      • Inferred. Both cases are measure-zero or sub-cent, and they move no target. I'd leave @1/@2's closure alone, and have the post-fit wage diagnostic (G8-x) count mass within 100 cents of every wage cut, as r2b does for income.

    The open top band: a Pareto alpha fitted to NZ's own counts.

    • Estimator grouped_pareto_mle@1, in a new B/tail_estimators.py. Take the declared anchor bands (a_k, b_k] with counts n_k, and the open top above c with count n_T. Write L0 = a_1 and S(x) = (x/L0)^(−α). Then p_k = (a_k/L0)^(−α) − (b_k/L0)^(−α), p_T = (c/L0)^(−α), and ℓ(α) = Σ n_k ln p_k + n_T ln p_T.
    • Solving it. Each term is concave in α, because ln(1 − e^(−αd)) is concave. So ℓ′ has one root.
      • Solve by bisection on [1 + 2^−20, 64], with the bracket and iteration count declared.
      • Round once to 6 decimals, half-even.
      • Refuse α ≤ 1 (an infinite top-band mean) and any bracket without a sign change.
    • Facts and anchors. Central fits the ten $50,000 bands from $500,000 to $1,000,000 (precal rows 231 to 240) plus "Over $1 million" (row 241).
      • Read. These facts are in the pinned feed: each row's selector carries an aggregate_fact_key under the feed pinned in N/chronicle_feed.json.
      • I can't read their values here, so I give no alpha.
    • Receipt. It records the estimator id and version, the anchor, each band's fact key, cuts and count, the bracket, the iterations, the bounds on ℓ′ at the result, α as p/10^6, and the implied top-band mean αc/(α − 1).
    • Sensitivity, declared now and run as a calibration variant (VPT).
      • (a) The same estimator anchored at $300,000, over all 14 $50,000 bands plus the top.
      • (b) The closed-form two-point estimator α₂ = ln(N(>$500k) / n_T) / ln 2.
      • VPT selects (a); (b) goes only in the receipt.
    • Cross-check, as a diagnostic only. The proposal's TY2024 taxable-income top band (claimed: 2,690 people and NZ$5.15097bn) has a mean of $1,914,859, which implies α = mean/(mean − c) = 2.093 (Ran: arithmetic). That's a different concept and year, so it never selects the wage alpha.
    • Why not donor_tail. It would give NZ's top tail a US shape. The hub's position stands.

    Support evidence.

    • What w4 records (Read, on the fixed behaviour: a bare band list feeds both signs):
      • The row class is quantile_mapped, with the owner the map node.
      • expected_max is null, because the Pareto top is open (H39). expected_min is also null, because the negative side now gets the same open top.
      • realized_min is 0, since wages are nonnegative.
    • It passes, if the column is on the roster.
      • Read. inside_quantile_support tests lower ≤ |v| ≤ upper on each band (g8w4c T/evidence_contract.py:630-644).
      • Inferred. With contiguous bands from 0 to an open top, any value ≥ 0 is inside, so records_outside is 0. The check proves only that the producer wrote within its declared range, which here is all of [0, ∞).
    • Gap: the column isn't on the roster.
      • Read. person.employment_income isn't on the support roster, because the export contract and the battery slice omit it. w4 would see it only as a covered source of derived rows, and would never range-check it.
      • r2c should add it to N/export_contract.json and both slices (Q6).
    • Read. T/compose.py:158-187 (SKELETON_KERNELS), :192, :910, :980, and g8w4d T/compose.py:1626-1632 must list @2 beside @1.

    3. The stand-in income measure (d1952 A)

    Measure. Use main's names: binding ird_taxable_income_ty2027_v1, columns ird_taxable_income_proxy_cents and ird_taxable_income_proxy_universe_status. It's a person-level, annual, TY2027-NZD statistical measure, never described as IRD taxable income. A new N/income_measurements.json declares it. A new shared kernel computes it in integer cents: measure.combine@1, in a new T/measure_kernels.py, so no money.* hash moves.

    Components, signs and money basis (all inputs are int64 cents columns that exist on main or that r2c adds):

    Component Column read Sign or treatment Money basis
    Wages employment_income_cents (new money.minor_units@1 node after the map, half-even) + P1 map, TY2026 bands × P1 factor (item 2)
    Non-farm self-employment nonfarm_self_employment_income_cents ± (losses count) FX 1.654, then mixed_income AWE factor (H3)
    Farm self-employment farm_self_employment_income_cents ± same
    Interest interest_income_cents + FX, then capital_income AWE (H3)
    Dividends dividend_income_cents + same
    Rental profit rental_income_cents max(0, r): losses not deducted same
    JS, SPS, SLP *_annual_gross_transfer_cents + Schedule 1 gross-up of after-tax payments, × receipt flag × exposure 1
    NZ Super nz_super_annual_gross_transfer_cents + before-tax weekly × 52 × flag
    • Excluded, and published as gaps: private_pension_income (the taxable-pension share is unknown), realized_capital_gains, Veterans Pension (Read: receipt is unsupported under H4, N/receipt_contract.json:142, and the VP rows stay parked), Youth and Young Parent Payment, ACC, student allowances, PPL, trusts, overseas income, LTC, partnership and Māori-authority allocations, taxable property disposals, and deductions and losses beyond those already netted.
    • Size claims (the proposal's, against the TY2024 total): ACC 1.0653%, student allowances 0.2141%, PPL 0.2645%, youth payments 0.0291%, and VP 0.0489%. The known pieces now total 1.6220%: the card's 1.6% plus VP, which main doesn't produce. Trusts are a separate envelope of about 2.5374% that may overlap. The rest are unknown, not zero.
    • Arithmetic.
      • x = Σ signed components + rental add-back.
      • The universe is included if x > 0 and excluded if x ≤ 0. unresolved applies only where an input is null, and r2b refuses it.
      • The kernel writes x in cents (signed), and ird_taxable_income_proxy_nzd = max(0, x)/100 as the amount measure.
      • Inferred. r2b already floors and reports pre-floor amounts (B/reference_intervals.py:24-32), so the kernel doesn't floor twice.
    • The receipt and its checks (all exact):
      • Σ components = x.
      • add-back = Σ max(0, −r).
      • The universe statuses partition the records.
      • Per-component weighted and unweighted totals, the counts and weighted mass of records with x < 0 and x = 0, and int64 overflow refusal.
    • Weighted to annual NZD. The measure is person-level and annual, and the bands classify persons. Inferred (the review's claim, which I didn't re-read): person targets collapse into household weights in the matrix.

    Coarse bands, counts and amounts. Add a new table ird_taxable_income_standin_ty2024 to N/reference_intervals.json:

    • person, right-closed, nil_band: absent, negative_income_policy: floor_zero, first_lower_cut_cents: 4000000;
    • cuts at $50k, $60k, $70k, $80k, $100k, $125k, $150k, $200k, $300k, $500k and $1m, then an open tail.

    Ran: every coarse band tiles native rows exactly.

    Source band Native rows Count (claim) NZ$bn (claim) Central aged lower cut
    (40k, 50k] row_50–59 322,350 14.53544 44,684.36
    (50k, 60k] row_60–69 335,460 18.45714 55,855.45
    (60k, 70k] row_70–79 333,540 21.70349 67,026.54
    (70k, 80k] row_80–89 283,050 21.14326 78,197.63
    (80k, 100k] row_90–109 386,550 34.47276 89,368.72
    (100k, 125k] row_110–134 284,460 31.59654 111,710.90
    (125k, 150k] row_135–159 154,180 21.01567 139,638.625
    (150k, 200k] row_160–209 154,670 26.45922 167,566.35
    (200k, 300k] row_210–229 72,470 17.13693 223,421.80
    (300k, 500k] row_230–233 26,700 9.93049 335,132.70
    (500k, 1m] row_234–243 9,450 6.23142 558,554.50
    > 1m row_244 2,690 5.15097 1,117,109.00
    • Totals (claim): 2,365,570 people and NZ$227.83333bn.
    • Ran: the central aged amount is NZ$254,514,663,442.97, which reproduces the proposal.
    • Boundaries. Three coarse cuts ($50k, $80k and $300k) land on IRD's shared-endpoint label pairs (rows 59/60, 89/90 and 229/230). r2b gives the endpoint to the lower band, so the boundary diagnostic matters most at those three.
    • 24 references, each with a pinned member list (dimension_values holding that band's member labels, and expected_member_count):
      • Count rows are identity.
      • Amount rows become a one-operand linear_combination with weight = factor.
      • Read. The ledger already sums a multi-member operand (B/ledger_targets.py:3692-3752).
      • Read. The resolver refuses it: scale_money_and_intervals needs a singleton aggregate_fact_key and an identity row (B/reference_profiles.py:645-660). Belgium hit the same wall (tools/generate_be_target_references.py:104-118). Shared hunk A fixes it.
      • Read, minor. That transform writes the weight as a binary64 float (B/reference_profiles.py:630), while r2b keeps cuts as exact rationals. The amount right-hand side carries about 1e-16 relative rounding. That's harmless, but the receipt should say so.
    • Tolerance and family. 5% each (d1376), family income, measure ird_taxable_income_proxy_nzd.
      • Read. Main's amount rows name taxable_income_nzd, which no producer writes. That's a mismatch with what r2c needs.

    Universe and nil band. Membership means "positive stand-in income", not IRD filing.

    • Read. The coarse table declares no nil band. Records at or below $44,684.36 (central) stay visible as below-first-cut in r2b's diagnostics (B/reference_intervals.py:24-32).
    • The 54,800 nil count (claim) stays a displayed comparator. The model's comparable nil count is reported as unavailable.

    Stress test. VSI, a new calibration variant on the raw nz.open branch: the measure without the four transfer addends. A sibling declaration in N/income_measurements.json defines it, with the same flags, payments and entitlement rules, and recomputed universe, bands and weights.

    • Exposure sensitivity (VEX). N/msd_pre_activation_reconciliation.json:1101 makes r2c own the declared annual-transfer exposure sensitivity, so add a second variant, VEX. It changes exposure.central from 1 to a declared value in the annual-transfers node; the kernel accepts exact decimal text (T/money_kernels.py:525-545). Its value is Q5.

    Published gaps and diagnostics. Before matrix construction, record:

    • the omission register above, with sizes and "unknown" where unknown;
    • component totals;
    • weighted and unweighted mass at x < 0 and x = 0;
    • mass below the floor, before and after the fit;
    • the weight change of persons below the floor who share a household with a fitted earner (the review's P2-5);
    • per-band distinct source households and Kish ESS, for counts and amounts separately;
    • r2b's boundary report;
    • the stress and exposure deltas for AS recipients and expenditure.

    Replacing the 474 rows, not adding beside them.

    • Generate the 24 coarse rows into N/target_references.json.
    • Move the 474 native rows to a new non-executable N/income_diagnostic_references.json, drop their exemptions from N/reference_selection.json, and strip companions from the national table, so it becomes declared-and-validated only, like the age table.
    • Point the aging_index transform at the 24 rows and the coarse table.
    • Read: why the move is forced. _validate_companions requires a scaled roster to contain every row of its scope (B/reference_profiles.py:166-199; the scope excludes dimension_values), so the 474 native rows couldn't stay in the same document as unscaled companions. _validate_interval_companions requires every band of a scaled table to name a roster pair (:247-292).
    • Rows aren't exempted. The 24 rows stay requires_prerequisites until composition proves the measurement producer (shared hunk A). Without that proof, compilation refuses (fails closed), rather than an exemption hiding them.
    • N/reference_inventory.json counts and docs/nz-calibration-targets.md:150-180 update with them.

    Places the proposal no longer matches main:

    1. Donor money factor. The proposal used TY2024→TY2027, 1.117109. Main's donor money is declared calendar 2024 with factors 1.077090 / 1.079073 / 1.081052 (N/money_normalization.json:29-40, N/reference_aging.json:9590). 1.117109 survives only as the IRD target-ageing factor.
    2. Wages. The proposal's FX-plus-AWE fallback for wages is superseded: main reserves wages for the quantile map (N/money_normalization.json:99-106).
    3. Net to gross. The proposal's proportional q-ratio is superseded by the Schedule 1 solves (N/precalibration_stages.json:41-82). Annual transfers exist as money.annual_transfers@1.
    4. VP. It was in the proposal and is a gap on main (H4).
    5. Names. ird_taxable_income_proxy_v1 becomes main's ird_taxable_income_ty2027_v1.
    6. The amount measure. It is taxable_income_nzd on main and must become ird_taxable_income_proxy_nzd.
    7. Summed amount rows. "linear_combination with amount weights = factor" doesn't resolve on main (hunk A).
    8. The S2-d allowlist. It's S5 now, and S5 needs an Axiom binding (item 4).
    9. The stress variant. "V5" becomes VSI. VHF already exists in the gates' scenario lists (N/gates.json:222,310).
    10. NZS rate. The proposal flagged the NZS living-alone default, and main still defaults sharing to false (N/axiom_input_closure.json:1049). This now enters the measure: about 8.65% of the denominator is NZS (claim), and the review's scale table puts all-singles-alone at +41% against MSD spending (claim). See Q4.

    4. The route through S5

    Entity, order and the stage list. Everything is on person, on the nz.central population, upstream of nz.calibrate, in this order:

    1. CREATE → nz.open (geography) → nz.central
    2. nz.earnings.map (@2, rewrites person.employment_income), alongside nz.money.normalize
    3. nz.earnings.cents (money.minor_units@1, writes employment_income_cents)
    4. nz.encode.person, which already reads employment_income (N/transport_graph.json:588), so main-benefit income tests see NZ wages
    5. entitlement, super and rates → net cents → the three gross-ups → annual cents and super cents
    6. nz.takeup.assign
    7. nz.benefits.annual_transfers
    8. nz.income.standin (measure.combine@1)
    9. nz.targets.prepare (12 interval outputs, plus the effective interval companion that composition injects, T/compose.py:3092-3105) → compile → problem
    10. nz.calibrate

    Each calibration variant (V1–V4, VHF, VSI, VEX, VPT) repeats steps 2, 3 and 8 with its own profile, from nz.open.

    Is main_benefits_rates re-run after calibration? Yes. main_benefits_rates, a listed S5 binding, runs again in the entitlement extension, in nz.as.bridge.sole_parent_gross_rate and nz.as.bridge.cutout (N/entitlement_graph.json:64,181).

    • Read. The guard treats those nodes as outputs, not inputs (T/compose.py:2269-2272), so this is allowed.
    • Read. No other listed binding runs after calibration.

    How S5's guard treats NZ's person-entity bindings.

    • Read. The guard's checks of benefit-unit semantics apply only to bindings on unit entities (T/compose.py:2192-2195,2396-2470). NZ's only unit entity is family: with CREATE @1, _unit_entities falls back to benefit_unit_rule.json (:1504-1509).

    • Read. For person stages the guard does two things. It requires every derived output to come from a stage node upstream of calibration (:2331-2340). And it refuses any reader of a stage node's column that isn't public (:2344-2357,2482-2483).

    • Inferred. So the stand-in passes whether or not it's listed. S5's schema can't list it anyway, because every stage needs a rules_binding (:1824-1845).

    • Recommendation. Leave the measure off S5, and add nz.precal.nz_super as a person stage:

      • outputs entitled_to_new_zealand_superannuation (purpose classification) and nz_super_weekly_amount_before_tax (purpose calibration_measure);
      • derived output nz_super_weekly_amount_before_tax_cents, with nz.gross_up.super_cents gaining engine_ref and period selections of nz_super, as nz.benefits.net_cents does (N/transport_graph.json:1384).

      That records every engine-derived input of the measure on the declared list, which is what d1952's "shared route" asks, without a guard exemption. The alternative is a non-engine stage kind in S5, which Belgium would have to clear (Q2).

    5. Gates

    Gates and families r2c turns on (no gate kind is new except the last bullet):

    • calibration_per_family_fit (N/gates.json:10): income gets its 24 rows at 5%.
    • national_and_region_admin_aggregates (:51): the 24 rows are national anchors at 5%.
    • target_profile_coverage (:83): the income requirement is met.
    • calibration_reference_inventory (:74): the 24 rows must enter the matrix.
      • Read. T/gate_bindings.py:692-752.
    • donor_support_bounds (:110) gets new roster rows: the stand-in's cents, NZD and universe columns, the 12 indicators, and, if Q6 is adopted, person.employment_income as quantile_mapped.
    • reported_cell_support (:222) and reported_cell_support_target_a (:310): add VSI, VEX and VPT to required_scenarios, as the card's "name the stress test in the scenario-support gates" requires.
    • One new gate kind, measurement_prefit, release-blocking. It holds the d1952 acceptance rules, frozen before any candidate (Q3).
      • It reads the problem's interval diagnostics (T/target_kernels.py:875-900), plus per-band support that hunk D adds.
      • Its proposed parameters come from the proposal and the review: each selected count and amount within 75–125% of its IRD comparator before the fit; refuse if count or amount falls outside 50–200% in 3 or more of the 12 bands; at least 30 distinct source households and source-household Kish ESS ≥ 30 per band, for counts and amounts separately; and complete evidence (native-band report, omission register, VSI/VEX/VPT runs, identity receipts).

    What gates.json must declare:

    • the measurement_prefit entry and its parameters;
    • the three scenario ids in both cell-support lists;
    • the existing income family lists, unchanged.

    The escalation triggers for VSI (a change above 10% in AS recipients or expenditure, or above 5% of the positive population crossing the floor) are review triggers, not gates. Declare them as diagnostic entries.

    What stays red after r2c:

    • donor_support_bounds.
      • Read/Inferred. H34 walks sources per node. nz.encode.person reads household.rent and person.liquid_financial_assets (N/transport_graph.json:588), and both stay pending until C1 and C2. So every derived row through encode stays red, including the stand-in, because its transfer sources run through encode.
      • Inferred. VSI's measure doesn't read transfers, so its row would pass, but the gate audits the central branch (H38).
    • Rent (C1) stays red, and P2 stays parked.
    • Liquid assets (C2/M1). P3 stays parked. d1982's frozen-donor diagnostic run is allowed and never published.
    • macro_realism_nz (:373) stays not applicable.
    • Other parked rows: household structure (187), tenure (34) and VP.
    • Weight gates at risk (Inferred): 24 income constraints raise pressure on weight_effective_sample_size (≥ 4,000) and weight_max_to_median_ratio (≤ 5). G8-x measures this.

    6. The split

    The PRs are stacked on w4d. A to D are shared; E to G are NZ only.

    A. Summed-band money scaling and measurement prerequisites (shared).

    • Files: B/reference_profiles.py (:422-453, :640-668), T/compose.py (measurement proof), test_reference_profiles.py, test_support/microcosm_build/reference_intervals.py.
    • The change:
      • Accept a multi-member identity money row (no aggregate_fact_key, list dimension_values, expected_member_count), and emit one operand {dimension_values, expected_member_count, weight}.
      • Satisfy measurement:<id> only when composition finds exactly one node upstream of calibration that declares measurement_id: <id> and owns the interval table's income and universe columns on its entity. Otherwise the rows stay parked and compilation refuses.
    • Belgium hunks: both. Belgium's statbel_net_taxable_income_proxy_v1 needs both (tools/generate_be_target_references.py:104-118,769-777).
    • Hashes that move: every kernel listing reference_profiles: targets.compile@1, calibration.prepare@1, targets.problem@1, transport.create@1 and @2, transport.quantile_map@1, takeup.assign@1, takeup.gap@1, geography.support_from_facts@1 and gates.battery@1 (T/target_kernels.py:568-581,603-618,967-980; T/population_kernels.py:161,506; T/column_kernels.py:730; T/takeup_kernels.py:628; T/geography_kernels.py:284; T/gate_kernels.py:724). Editing compose.py moves none (H42).
    • Tests:
      • Example: a 10-member pair resolves to one operand with count 10; refusals for a wrong member count, a non-identity row, mismatched member sets, and overlapping members across rows.
      • Hypothesis: for random partitions of synthetic facts and factors p/10^6, the compiled coarse amount equals f·Σ members and the count equals Σ members.
      • Differential: the summed path equals the sum of singleton rows aged by today's path, on a synthetic feed.
      • Mutants for the measurement proof: no producer, a producer after calibration, two producers, the wrong column, the wrong entity.
    • Generated resources and goldens: none, unless the goldens hash kernel identities. The build session checks this.

    B. transport.quantile_map@2 and grouped_pareto_mle@1 (shared).

    • Files: new T/quantile_kernels.py and B/tail_estimators.py; registration; T/compose.py:158-187,192,910,980; g8w4d T/compose.py:1626-1632.
    • Belgium hunks: compose.py's producer sets only. @1 doesn't change.
    • Hashes that move: none. @2 is new.
    • Tests:
      • Example: recover a known α from exact Pareto expected counts; refuse α ≤ 1 and a bracket without a sign change.
      • Hypothesis: for α in [1.2, 5] and random anchors, the estimate recovers α to within a declared tolerance; scaling all counts by k leaves α unchanged; moving mass upward lowers α; ℓ′ changes sign once.
      • Differential: @2 with its estimated α is bitwise equal to @1 with that α as a literal.
      • The w4 support producer accepts @2's params and reports expected_max: null.
    • Generated resources and goldens: none.

    C. measure.combine@1 (shared).

    • Files: new T/measure_kernels.py, registration, T/compose.py SKELETON_KERNELS.
    • Belgium hunks: the registry and skeleton lines only.
    • Hashes that move: none.
    • Tests:
      • Example: losses net, the rental add-back, x ≤ 0 is excluded, overflow is refused, a null input is unresolved.
      • Hypothesis: Σ components + add-back = x exactly; the universe statuses partition the records; the output doesn't depend on component order; the NZD column is max(0, x)/100.
      • Differential: matches an independent Python-integer sum.
    • Generated resources and goldens: none.

    D. Pre-fit support diagnostics and the measurement_prefit gate (shared).

    • Files: B/reference_intervals.py (per-band distinct source_household_id and Kish ESS, for count and amount contributions), T/gate_bindings.py, T/gate_kernels.py, tests.
    • Belgium hunks: both files. The gate's parameters are country data.
    • Hashes that move: calibration.prepare@1 and targets.problem@1 (both list reference_intervals, T/target_kernels.py:614,975), and the battery's binding identity. Land it in the same re-key window as A.
    • Tests:
      • Example: pass and fail at each threshold.
      • Hypothesis: ESS ≤ distinct households; merging clones by source household leaves ESS unchanged.
      • Differential: the diagnostics' weighted band counts equal Σ w·indicator over the prepared columns.
      • Thresholds pinned by a test.
    • Generated resources and goldens: none.

    E. NZ wage map (NZ only; needs B).

    • Files:
      • N/reference_intervals.json (wage table), new N/earnings_bands.json;
      • N/precal_references.json (235 rows active), N/reference_selection.json (235 exemptions dropped), N/money_normalization.json (status text);
      • N/transport_graph.json (the map and cents nodes; pending_outputs), N/scenarios.json, N/export_contract.json (if Q6);
      • tools/generate_nz_target_references.py, tools/build_nz_transport_graph.py, tools/build_nz_entitlement_graph.py (which writes scenarios.json, :639).
    • Belgium hunks: none.
    • Hashes that move: none (node keys only).
    • Tests:
      • test_nz_target_references.py: the wage cuts match the labels; the feed audit skips here.
      • test_nz_transport_declarations.py: the map precedes encode; edges equal cuts × factor for every profile; mutants for a hand-edited edge or a stale factor receipt.
      • test_nz_transport_toy_graph.py: the support row is quantile_mapped, with 0 outside.
      • Hypothesis: the edges stay contiguous and increasing for any positive factor.
      • Differential: the scaled-edge map equals the unscaled map × f, as I ran above.
    • Generated resources and goldens: the resources above, and packages/microcosm-build/tests/golden/nz_country_spec.json.

    F. NZ coarse references (NZ only; needs A).

    • Files:
      • N/target_references.json (24 rows), new N/income_diagnostic_references.json (the 474);
      • N/reference_intervals.json (the coarse table; companions stripped from the national table), N/reference_profiles.json, N/reference_selection.json (474 exemptions dropped);
      • N/reference_inventory.json, N/country_package.json;
      • the generator, and docs/nz-calibration-targets.md.
    • Belgium hunks: none.
    • Hashes that move: none.
    • Tests:
      • Each coarse band equals its listed native rows (rows 50–244), with a feed-gated audit.
      • The central first cut is 4,468,436 cents.
      • No native IRD row sits in an executable document.
      • The rows stay parked without a producer.
      • Hypothesis: the coarse tables tile the positive range for any factor.
      • Differential: each aged coarse amount equals the sum of its 10 to 50 native aged amounts.
    • Generated resources and goldens: the resources above, and the NZ golden.

    G. NZ wiring, variants and gates (NZ only; needs C, D, E and F).

    • Files:
      • new N/income_measurements.json;
      • N/transport_graph.json (nz.income.standin; prepare and problem inputs; super_cents engine_ref), N/calibration_bindings.json, N/precalibration_stages.json (nz.precal.nz_super);
      • N/scenarios.json (VSI, VEX, VPT; knob domains), N/gates.json, N/country_package.json;
      • generators, test_support/microcosm_build/transport_nz_toy.py.
    • Belgium hunks: none.
    • Hashes that move: none.
    • Tests:
      • On the toy graph: the 24 rows reach the matrix, target_profile_coverage passes on income, and measurement_prefit runs.
      • The S5 guard still passes, and a reader of a non-public nz_super column is refused.
      • VSI's measure omits exactly the four transfer columns.
      • Differential: the toy problem's income row value equals the summed coarse reference.
    • Generated resources and goldens: the resources above, and the NZ golden.

    7. Questions for the hub

    1. Satisfying measurement: (B/reference_profiles.py:422-453).
      • (a) Composition proves a single upstream producer (hunk A).
      • (b) A static satisfied_measurements list in the profile declaration.
      • (c) An exemption until G8-x.
      • Recommend (a). It fails closed, and Belgium reuses it.
    2. S5 route for a non-engine producer (T/compose.py:1824-1845).
      • (a) Off S5, plus nz.precal.nz_super as a person stage.
      • (b) A new non-engine stage kind in the S5 schema (a Belgium hunk).
      • (c) Off S5 entirely.
      • Recommend (a). The guard doesn't restrict person bindings (:2192-2195).
    3. Pre-fit acceptance rules (N/gates.json, new entry). Adopt the item 5 values as written, or change them now. They must be frozen before G8-x, because d1952 fixes every threshold before any candidate.
      • Recommend adopting them, with separate count and amount support.
    4. The NZS living-alone default in the measure (N/axiom_input_closure.json:1049).
      • (a) Bind nz_super_shares_residence_with_person_at_least_adult_age from household composition (another adult in the household), in r2c E or G.
      • (b) Keep the default, disclose it, and add the all-sharing rate as a VSI-like variant.
      • Recommend (a). The review's scale check (claim) puts all-singles-alone 41% above MSD's NZS spending.
    5. The VEX exposure value (N/transport_graph.json:1756).
      • (a) 0.5.
      • (b) 0.75.
      • (c) No VEX; treat VSI's exposure of 0 as the bound.
      • Recommend (b), as a declared judgment. Main has no measured exposure (N/msd_pre_activation_reconciliation.json:1101).
    6. Exporting person.employment_income (N/export_contract.json). Add it to the export and both slices, so the support gate range-checks the map.
      • Recommend yes.
    7. The wage-earner count.
      • (a) A diagnostic only: Σ of the 235 counts against the model's weighted earners, before and after the fit.
      • (b) A calibration target.
      • Recommend (a) for v0.
    8. Where the 474 native rows go.
      • (a) A new non-executable income_diagnostic_references.json.
      • (b) Kept only in reference_inventory.json.
      • Recommend (a). It keeps every selector reviewable.
    9. The tail pattern [methodology across countries → Max]. Should a Pareto top band fitted to the destination's own counts (grouped_pareto_mle@1) become the default for any country's open top band, against Belgium's donor_tail?
      • (a) The default everywhere.
      • (b) NZ's declaration only.
      • Recommend (b) now. The hub can rule NZ alone under d1952's declared-convention pattern; making it the default is Max's.

    Left out:

    • rent (C1), liquid assets (C2 and M1), macro_realism and the G8-x run;
    • any new guard beyond measurement_prefit and the composition proofs in A and E;
    • the post-fit wage and taxable diagnostics' report format (G8-x);
    • per-variant weight audits (G8-y, H38).

    Rulings the hub owes

    1. Q1: satisfy measurement: by composition proof (blocks every income target).
    2. Q2: the S5 route, with an nz_super person stage.
    3. Q3: freeze the measurement_prefit thresholds before G8-x.
    4. Q8: where the 474 rows go (decides F's file set).
    5. Q4: the NZS sharing default.
    6. Q6: export employment_income.
    7. Q5: the VEX exposure value.
    8. Q7: the wage-earner count as a diagnostic.
    9. Q9: the tail pattern, forwarded to Max (non-blocking for NZ).

    Generated by Claude Code

  5. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    ContributorAuthor

    G8-r2c: hub rulings on the design (2026-11-02)

    Source: the G8-r2c design, comment 6580994762 above (RESULT: READY). The hub checked it against main e226192ada. Each ruling below is a single reading inside Max's d1952 (option A, 2026-10-24), so the hub makes it under his 2026-10-20 delegation and logs it here.

    The hub checked two of the design's mechanism claims on main e226192ada:

    • S5's schema ties every stage to a rules binding (T/compose.py:1837). So the stand-in, which has no binding, can't sit on the list.
    • nz_super_shares_residence_with_person_at_least_adult_age defaults to false (N/axiom_input_closure.json, entry after :1049). Its own reason says this "overstates NZS for singles who share a home".

    The rest the hub takes from the report's own file:line evidence.

    R2C-1 (Q1). Composition proves measurement:

    Adopt (a). Composition satisfies measurement:<id> only when it finds exactly one node upstream of calibration that declares measurement_id: <id> and writes the interval table's income and universe columns on its entity. Otherwise the rows stay parked and compilation refuses. It fails closed, and Belgium's statbel_net_taxable_income_proxy_v1 reuses it. Shared hunk A.

    R2C-2 (Q2). The S5 route: the stand-in stays off the list, and NZS joins it

    Adopt (a).

    • The stand-in (measure.combine@1) stays a declared transport node, off S5's list. Hunk A's proof makes it the measurement's only producer.
    • New S5 stage nz.precal.nz_super (person), with these outputs and changes:
      • entitled_to_new_zealand_superannuation (purpose classification);
      • nz_super_weekly_amount_before_tax (purpose calibration_measure);
      • the derived output nz_super_weekly_amount_before_tax_cents;
      • nz.gross_up.super_cents gains engine_ref and period selections, as nz.benefits.net_cents has.
    • Reading of d1952's "shared route". The card asks both countries' income producers to enter calibration through the declared pre-calibration list. S5's list governs rules outputs, because every stage needs a rules binding. Under (a), every rules output the stand-in reads sits on that list: main benefits through main_benefits_rates, and NZS through the new stage. The stand-in's own arithmetic is one declared node.
    • Belgium. Belgium's producers are rules outputs and fit S5 as it stands, so (a) asks nothing of Belgium. Option (b), a non-engine stage kind, would add a Belgium hunk for no gain.

    R2C-3 (Q3). The pre-fit rules, frozen now

    The hub freezes every rule below before any candidate run (d1952: "fixed before we look at any candidate … no rule is loosened to pass"). A test pins each number. A change needs a new hub ruling, which the hub logs here before any candidate run, never after one.

    Check Refuses when
    Roster totals The summed count, or the summed amount, over the 12 selected bands falls outside 75–125% of IRD's summed comparator
    Concentrated discrepancy 3 or more of the 12 bands have a count or an amount outside 50–200% of its comparator. A band counts once if either fails
    Support Any band has fewer than 30 distinct source households, or a source-household Kish ESS below 30. The gate checks count contributions and amount contributions separately
    Evidence The run lacks the native-band report, the omission register, the VSI, VEX or VPT runs, or a required identity receipt
    Post-fit Unchanged: d1376's 5% per target, and every existing release gate
    • Why totals, not each band, for the 75–125% rule. The proposal labels the 75–125% rule "aggregate" and the 50–200% rule "concentrated". Read per band, the first would refuse any band outside 25%, and the second rule (which tolerates two bands outside 50–200%) could never fire. Only the totals reading gives each rule work to do.
    • Comparators. Each comparator is a weighted model total at the weights entering nz.calibrate, against the aged IRD reference the matrix uses. Unweighted counts describe support only.
    • VSI escalation triggers. These are diagnostics, not gates: AS recipients or spending moving more than 10%, or more than 5% of the central weighted positive population crossing the floor. When one fires, the hub writes a ruling before any release claim. If the fix is a pattern across countries, it goes to Max.

    R2C-4 (Q8). The 474 native rows

    Adopt (a): a new non-executable N/income_diagnostic_references.json. The 24 coarse rows replace the parked rows in the required target list; they don't sit beside them, as the card's "replace the required target list" asks. A test proves that no native IRD row sits in an executable document.

    R2C-5 (Q4). NZS's living-alone default goes

    Adopt (a), in hunk G. The stand-in sums NZS, so the default now feeds a calibration target, and its own reason says it overstates NZS for singles who share a home.

    • Bind nz_super_shares_residence_with_person_at_least_adult_age from household composition. Read the RuleSpec's definition of the input first, and bind to that definition, including its adult age.
    • If the binding needs a new concept-mapping transform kind, that transform is a shared hunk for Belgium and goes in the hand-off. If it would touch a frozen file, stop for a hub ruling.
    • Record NZS and AS outputs on the toy before and after. The closure entry says the reg 17 superannuitant bridge may read the amount (MC11).

    R2C-6 (Q6). Export person.employment_income

    Yes, in hunk E: add it to the export contract and both slices, so the support gate range-checks the wage map.

    R2C-7 (Q5). VEX exposure 0.75

    Adopt (b) as a declared judgment, disclosed as such. Main has no measured exposure (N/msd_pre_activation_reconciliation.json:1101). VSI already covers exposure 0, so VEX should be a plausible alternative, not a second extreme. If MSD publishes counts of distinct annual recipients alongside the point-in-time stock, their ratio replaces 0.75 by re-declaration (a G8-x item).

    R2C-8 (Q7). Wage-earner count

    A diagnostic only for v0: the sum of the 235 IRD counts against the model's weighted earners, before and after the fit.

    R2C-9 (Q9). The tail: NZ's own band now, and the default question on Belgium's card

    • NZ. NZ declares grouped_pareto_mle@1 for its own open top band. It anchors on rows 231–241 ($500k to $1M plus the top), with sensitivity variant VPT, and it refuses α ≤ 1 and a bracket without a sign change.

    • Belgium wants it too (Belgium's read, 2026-11-02). Belgium took a Pareto tail with no donor-tail mode, and proposes, under d1902 item 24, fitting its alpha with this estimator from RSZ 2026-Q1's top two classes. The closed form gives α = 3.348955.

    • The default question goes to Max on d1902, not on a new card. With two users, whether a fitted Pareto becomes the default for any country's open top band is a cross-country pattern, so it's Max's. The hub joined d1902 with NZ's view:

      • the fitted tail is the default wherever the destination publishes counts in finite bands below the open top;
      • each country declares its anchor bands and a re-anchored sensitivity;
      • a declared alpha only where no counts exist;
      • no donor-tail mode.

      The caveat on that card holds: Belgium's anchor class holds 27% of known-pay workers, so it fits the body more than the tail. NZ's anchors start at $500,000.

    • NZ's build doesn't wait on that ruling. NZ's declaration stands on d1952's declared-convention pattern either way.

    R2C-10. The design's corrections and the measure name

    • Corrections. Adopt §3's corrections wherever the proposal no longer matches main; the design is the build spec, and the proposal isn't.
    • The measure. It is ird_taxable_income_proxy_nzd, and the amount rows stop naming taxable_income_nzd, which no producer writes.
    • Rounding and closure diagnostics. Edge-closure and cent-rounding mismatches stay a G8-x diagnostic.
    • measure.combine@1 declares its floor, scale and output names in its resource, at Belgium's request, so a floor never has two homes. NZ declares floor 0 and scale 100.
    • The binary64 weight. reference_profiles.py:630 writes the factor weight as a binary64 float, while r2b keeps exact rationals. The receipt states the ~1e-16 relative rounding this adds.

    R2C-11. Build order

    • Two cloud build sessions, after the w4 fix round hands off:
      1. Shared hunks A to D. They stack on w4d's fix-round head, and A and D share one re-key window.
      2. NZ hunks E to G. They stack on D.
    • Why wait for the fix round. It edits T/compose.py, T/gate_bindings.py and T/evidence_contract.py, the files A to D touch. Building on its head avoids a second merge-up.
    • The @2 support line. _SUPPORT_PRODUCERS gains transport.quantile_map@2 in hunk B, together with the test that the w4 support producer accepts @2's params.
    • Before A or D lands, read the Fleet ops file and reservation.py status for any hold on shared hashes. A moves targets.compile@1, calibration.prepare@1, targets.problem@1, transport.create@1/@2, transport.quantile_map@1, takeup.assign@1, takeup.gap@1, geography.support_from_facts@1 and gates.battery@1. Other countries run those kernels too, so their stores re-run.
    • Belgium. Belgium clears every shared hunk, A to D, before it lands. It hears the plan now, beside the tail answer, the B3 rulings and H45/H46.
  6. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    ContributorAuthor

    Cloud build session picked up G8-r2c shared hunks A and D.


    Generated by Claude Code

  7. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    ContributorAuthor

    Cloud build session picked up G8-r2c shared hunks B and C.


    Generated by Claude Code

  8. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    ContributorAuthor

    Hub ruling needed
    G8-r2c shared hunk D: the measurement_prefit gate kind needs one line in a frozen file

    Question. Hunk D adds a release-blocking measurement_prefit gate whose thresholds come from the country's gates.json entry. The frozen loader refuses that entry. GateSelectionSpec.from_mapping (packages/microcosm-build/src/microcosm/build/country_spec.py:528, at intake w4d d81280bb) rejects any gate outside ALLOWED_GATE_FUNCTIONS (country_spec.py:115-152), and measurement_prefit isn't on that list. Both gate_kernels.py (GatesManifest.from_mapping, :404 and :866) and the spec loader go through that check, so no country can declare the gate until the name is on the list. The brief lists country_spec.py as frozen.

    Options.

    • (a) Add "measurement_prefit" to ALLOWED_GATE_FUNCTIONS in country_spec.py. That's one line, and it moves no kernel hash: country_spec isn't among the modules any kernel's implementation_hash() digests. I'd check that with the hash table before landing it.
    • (b) Keep D's binding under an existing allowed name. That misnames the gate in every report, and it collides with a name a country may already use.
    • (c) Land D's binding and diagnostics now, and add the name in a later hub-owned change. Until then the binding can't run from any gates.json.

    Recommendation: (a), in hunk D, as the one frozen-file line. Until you rule, I'm building (c): the binding, its registry entry, the per-band support diagnostics and every test that calls the binding directly. A pinned test records that the loader refuses the entry today, and it flips when (a) lands.

    Everything else in A and D goes ahead. The stack keeps building.


    Generated by Claude Code

  9. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    ContributorAuthor

    Hub ruling R2C-12 (answers comment 6582256373)

    Take (a): add "measurement_prefit" to ALLOWED_GATE_FUNCTIONS in country_spec.py, in hunk D. It's the only frozen-file line this round may touch. Once it lands, delete the pinned "the loader refuses the entry" test, and test that a gates.json entry naming the gate loads.

    One correction: the line does move kernel hashes. The hub read these at intake w4d d81280bb50:

    • gates.battery@1 lists country_spec_module in its source_hash (T/gate_kernels.py:765).
    • targets.compile@1 and takeup.compare@1 list it through _COMPILE_MODULES (T/target_kernels.py:568-581, used at :925 and :1073).

    All three also list reference_profiles, so hunk A already moves them. The line therefore adds no kernel to A and D's re-key window. takeup.compare@1 is missing from the design's list of A's moves: put it in your hash table.

    UK's kernels leave country_spec out of their hashes on purpose (uk_runtime/target_compile_modules.py:59, EXCLUDED_DIRECT_IMPORTS), so they don't move. Show that in the hash table rather than taking it from me.

    List the country_spec.py line among D's shared hunks in your hand-off. Belgium's hub clears it with the rest.

  10. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    ContributorAuthor

    Ready for hub review
    G8-r2c shared B and C

    Both PRs are drafts stacked on #1452's intake head d81280bb. #1448 had no **Ready for hub review** / Fix round comment at hand-off, so I didn't merge the fix round in, and the hub merges up later. Each PR body has the full invariant, mutant, hash and per-file test tables. B/ is packages/microcosm-build/src/microcosm/build/, T/ is B/transport/, and the tests live in packages/microcosm-build/tests/engine_free/shared/.

    PRs

    Hunk PR Branch Base Head
    B #1459 nzhub/g8r2c-b-pareto-map nzhub/g8w4d-nz-wiring at d81280bb a401e22fefb1abfee94592e7c639dc606aef0165
    C #1461 nzhub/g8r2c-c-measure nzhub/g8r2c-b-pareto-map c29e4871d9a46560af70574b1d3c8eba58f5ba6e

    B. transport.quantile_map@2 and grouped_pareto_mle@1

    • Commits. c7b7a747 (the change) and a401e22f (the registration-count pin).
    • Estimator, B/tail_estimators.py:1-372.
      • grouped_pareto_score (:311-319) computes ℓ′.
      • grouped_pareto_mle (:322-372) bisects the declared bracket for the declared iterations, rounds once to 6 decimals half-even (_round_half_even, :160-168) and carries α as p/10^6.
      • Named refusals: bracket_without_sign_change (:340) and alpha_not_above_one (:358).
      • The receipt (:109-143) carries the implied top-band mean αc/(α − 1) as an exact fraction.
    • Kernel, T/quantile_kernels.py:1-158. estimated_alpha (:54-112) reads the anchor counts from the surface _surface_bands validates. run (:133-149) delegates to TRANSPORT_QUANTILE_MAP.run with the fitted α as a literal.
    • Invariants and their tests, all in test_transport_quantile_map_v2.py:
      • ℓ′ changes sign once on the bracket: test_property_the_score_changes_sign_once_on_the_bracket.
      • Exact counts recover α within 1e-6, for α in [1.2, 5]: test_property_recovers_alpha_within_the_declared_tolerance. The PR body justifies the tolerance.
      • Scaling the counts leaves α unchanged: test_property_scaling_every_count_leaves_alpha_unchanged.
      • Moving mass upward lowers α: test_property_moving_mass_upward_lowers_alpha.
      • The closed form holds, and Belgium's RSZ counts give α = 3.348955: test_belgium_rsz_counts_give_the_closed_form_alpha and test_property_one_band_and_the_top_match_the_closed_form.
      • @2 is bitwise @1 at the fitted α: test_v2_equals_v1_with_its_estimated_alpha_as_a_literal and test_property_v2_is_bitwise_v1_at_the_fitted_alpha.
      • The anchor counts are the band rows' counts, and changing one changes α: test_anchor_counts_are_the_band_rows_counts.
      • The w4 support producer reports expected_max: null: test_the_support_bounds_kernel_reports_a_null_expected_max_for_v2.
      • Both signs are bounded for a bare list: test_v2_support_bounds_both_signs_for_a_bare_band_list, which is xfail(strict=True, reason="needs the w4 fix round's E1"). The merge-up must remove the mark.
    • Mutants: 13 run, 13 red. For the mutant that undoes each item:
      • drop the half-even rounding: test_belgium_rsz_counts_give_the_closed_form_alpha goes red;
      • drop the α ≤ 1 refusal: test_refuses_an_alpha_at_or_below_one_by_name;
      • drop the sign-change refusal: test_refuses_a_bracket_without_a_sign_change_by_name;
      • hardcode the bracket or the iterations: the recovery tests;
      • read the wrong anchor counts: test_anchor_counts_are_the_band_rows_counts;
      • pass the unrounded α to @1: the differential tests;
      • drop @2 from _SUPPORT_PRODUCERS, SKELETON_KERNELS or the registry: test_v2_is_registered_beside_v1;
      • drop it from the money producer sets: test_composition_accepts_v2_as_the_quantile_mapped_money_producer.

    C. measure.combine@1

    • Commits. ca9c8adf (the change), e7326250 (import order), b905eddd (merge of B, no rebase) and c29e4871 (the registration-count pin).
    • Kernel, T/measure_kernels.py:1-393.
      • validate_measure_declaration (:88-161) reads the entity, components, signs, add-backs, floor, scale and output names from the declaration. The module holds none of them.
      • combine_measure (:177-274) sums in Python integers and refuses int64_overflow (:217-221). A null component leaves the record unresolved, with a null measure and a NaN unit. x ≤ 0 is excluded.
      • run (:295-384) carries measurement_id into the receipt.
    • Invariants and their tests, all in test_transport_measure_kernels.py:
      • Σ components + add-back = x: test_property_components_plus_add_back_equal_the_measure.
      • The statuses partition the records: test_property_the_universe_statuses_partition_the_records.
      • The output doesn't depend on component order: test_property_the_output_does_not_depend_on_component_order.
      • unit = max(floor, x)/scale for any declared floor and scale: test_property_the_unit_is_max_floor_x_over_scale.
      • The measure equals an independent Python-integer sum: test_property_differential_against_an_independent_integer_sum.
    • Mutants: 9 run, 9 red.
      • null read as zero: test_a_null_component_leaves_the_record_unresolved_not_zero;
      • no add-back: test_the_add_back_ignores_an_add_back_components_losses;
      • no overflow refusal: test_an_int64_overflow_is_refused_by_name;
      • x = 0 included: test_records_at_or_below_zero_sit_outside_the_positive_universe;
      • hardcoded floor and scale: test_the_floor_and_scale_come_from_the_declaration;
      • sign ignored: test_a_negative_sign_subtracts;
      • measurement id dropped: test_the_measurement_id_is_carried_not_used;
      • skeleton or registry entry dropped: test_measure_combine_is_registered_in_the_skeleton.

    Shared hunks, at the new heads, for Belgium's clearance

    At B a401e22f:

    • new B/tail_estimators.py:1-372 and T/quantile_kernels.py:1-158;
    • T/compose.py:
      • :182 (SKELETON_KERNELS);
      • :217-218 (_QUANTILE_MAPS, _MONEY_PRODUCERS);
      • :845-848 (docstring);
      • :944-956 (_money_producer_gaps's expected producers and message);
      • :1017 (_check_single_money_normalization's kernel-to-route map);
      • :1634 (_SUPPORT_PRODUCERS);
    • T/registry.py:56 (import), :535 (docstring count), :542 (registration);
    • tests: new test_transport_quantile_map_v2.py, and test_transport_population_kernels.py:143-163 (the registration pin; it pins refs, not hashes).

    At C c29e4871 (adds to B):

    • new T/measure_kernels.py:1-393;
    • T/compose.py:191 (SKELETON_KERNELS);
    • T/registry.py:53 (import), :536 (docstring count), :547 (registration);
    • tests: new test_transport_measure_kernels.py, and test_transport_population_kernels.py:143-164.

    None of these touches the code the fix round owns, and no frozen file changed. test_support/microcosm_build/transport_nz_toy.py is unchanged.

    Hash table

    I hashed every kernel the engine-free transport registry installs, 40 at the base, at d81280bb, B and C. All 40 are byte-identical at all three. The only new rows:

    • transport.quantile_map@2 = 17ec8fcdc5bf535b… (B and C);
    • measure.combine@1 = 8636ac082235b979… (C).

    No kernel digests compose.py or registry.py, so their edits move nothing. @1's hash is unchanged because @2 lives in its own module. The simulate.* rule kernels need an engine mapping, so the table omits them; neither diff touches a module they digest. Both PR bodies list every hash.

    Tests

    Closed list: 53 files, -n 4, no --maxfail. Per-file counts are in the PR bodies.

    Head Passed Skipped Xfailed Failed
    B a401e22f 4091 86 1 0
    C c29e4871 4120 86 1 0
    • At C's first head, test_aggregate_population_registration_installs_exactly_fourteen_kernels failed, because it pins the registry's refs. I updated it on B (fifteen) and then on C (sixteen), and reran the whole closed list at both final heads.
    • Skips, all needing sources this container lacks:
      • the NZ Chronicle feed: test_nz_target_references 38, test_nz_reference_aging 16, test_nz_family_reconciliation 9, test_nz_joint_geography 1, test_nz_reference_intervals 1 and test_nz_msd_pre_activation_reconciliation 1;
      • the joint-geography and ArcGIS bytes: test_nz_joint_geography 11 and test_nz_as_area_crosswalk 3;
      • the Axiom dense engine with a rulespec-nz checkout: test_nz_axiom_input_closure 2;
      • the staged feed: test_nz_transport_declarations 2;
      • the real donor: test_transport_donor 2.
    • Pinned hashes: 24 engine-free modules outside the closed list pin implementation hashes, and all 540 of their tests pass at both heads. I changed no pin. The engine directories need the US and UK engines, which this environment doesn't install, so I didn't run them.
    • Other checks at both heads:
      • ci_test_plan.py verify prints verification=ok;
      • build_nz_transport_graph.py --check, build_nz_entitlement_graph.py --check and build_nz_benefit_gross_up.py --check match. The other NZ builders need a feed or source directory, so these three are the source-free ones;
      • ruff is clean.

    Judgment calls

    • The anchor declaration. @2's interpolation is {"method": "pareto", "estimator": {"id": "grouped_pareto_mle@1", "anchor_references": [...], "bracket": ["1.00000095367431640625", "64"], "iterations": 64}}. The anchors must be the band list's top contiguous suffix, ending at the open top. The bracket is exact decimal text. The iterations are capped at 200.
    • Bare band lists only. @2 refuses signed and component band documents, because one α across two distributions isn't decided.
    • Where the counts come from. _surface_bands returns shares, so @2 calls it for validation and reads the counts from TargetSpec.value on the same decoded surface.
    • Status labels. measure.combine@1 imports r2b's UNIVERSE_STATES from B/reference_intervals.py, so its hash digests that module. Hunk D's edit to reference_intervals.py will move measure.combine@1's hash in D's re-key window.
    • Universe threshold. C fixes the positive universe at x > 0 whatever the declared floor, as the brief says. The floor moves only the unit column.
    • Measure dtype. C writes the measure as Int64, so unresolved records stay null. It accepts int64 only when no record is unresolved.
    • Branch names. I pushed to the brief's nzhub/… branches, not the session's default claude/… branch.

    Left out

    • B's sensitivity variants (VPT's re-anchored fit and the two-point estimator) belong to the NZ session.
    • NZ's declaration resources, under E to G.
    • The merge-up of the w4 fix round, which hadn't handed off.

    Generated by Claude Code

  11. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    ContributorAuthor

    Ready for hub review
    VERDICT: APPROVE_WITH_NITS
    Needs Max: no
    G8-r2c B+C review: B a401e22fefb1abfee94592e7c639dc606aef0165, C c29e4871d9a46560af70574b1d3c8eba58f5ba6e

    Both heads match the brief. I re-fetched them before posting and neither had moved. I reviewed at C's head, detached, with Python 3.13.18 and uv sync --all-packages --locked, and diffed against d81280bb and B. B/, T/ and the test directory are as in the brief, and every file:line below is at C's head unless it says otherwise. Labels: Ran means I executed it this session, Read means I read the code, and Inferred means my reasoning. I changed nothing in the repo. Every mutant file was restored with git checkout --, and git status is clean.

    Summary. I tried to break both kernels and found no wrong output. @2 is @1 at a correctly rounded six-decimal α, read from the one decoded surface. measure.combine@1 matches the design's arithmetic. The misses are in the tests:

    • C: every one of my six mutants survives. Those include the receipt's weighted totals and weighted masses, which design §3 lists.
    • B: half-even rounding is never exercised at a tie.

    Two PR-body claims go further than the design. Both are below.


    1. B: the estimator's mathematics (B/tail_estimators.py)

    1a. ℓ and ℓ′: HOLDS.

    • The model. Condition on X ≥ L₀ = a₁ and write S(x) = (x/L₀)^−α.
      • Each band has p_k = S(a_k) − S(b_k), and the top has p_T = S(c).
      • The bands are contiguous up to c, so Σp_k + p_T = S(a₁) = 1.
      • ℓ(α) = Σ n_k ln p_k + n_T ln p_T.
    • Read. With u_k = ln(a_k/L₀), d_k = ln(b_k/a_k) and t = ln(c/L₀):
      • ln p_k = −αu_k + ln(1 − e^(−αd_k)), and ln p_T = −αt;
      • so ℓ′ = Σ n_k (d_k/(e^(αd_k) − 1) − u_k) − n_T t.
      • grouped_pareto_score (:311-319) computes exactly that: count * (width / expm1(alpha*width) - offset), then -top_count * top_offset, summed with math.fsum. _terms (:294-308) builds u, d and t with math.log on exact Fraction ratios.
      • The signs and logs match.
    • Concavity: proved.
      • The linear terms −αu_k and −αt have zero second derivative.
      • d²/dα² ln(1 − e^(−αd)) = −d²e^(αd)/(e^(αd) − 1)², which is below 0 for d > 0. _anchors (:271) enforces d > 0.
      • So ℓ″ = −Σ n_k d_k² e^(αd_k)/(e^(αd_k) − 1)² ≤ 0. ℓ is concave, and strictly concave when any finite band has n_k > 0.
      • ℓ′ is therefore nonincreasing and changes sign at most once on any bracket.
      • When every finite count is 0, ℓ′ is the constant −n_T·t. That gives no sign change and a named refusal (Ran, 1d).

    1b. Belgium α: HOLDS.

    • Ran. Python decimal at 50 digits (no mpmath installed): ln((1,066,243 + 369,203)/369,203)/ln(300/200) = 3.3489546347441576222607842433469875946621570878728…, which rounds half-even to 3.348955.
    • Ran. The kernel on the same two anchors gives 3348955/10^6. The exact root sits 1.3e-7 from the nearest halfway point, so this one isn't close to a tie.

    1c. Rounding: HOLDS, with a stated exposure.

    • Read. The score is binary64: math.log at :302-308, then math.expm1 and math.fsum at :315-319. Bisection runs in floats (:346-353). Only the final rounding is exact: _round_half_even(Fraction(midpoint)), :160-168, :354.
    • Ran: roots built near a tie (scratchpad/s/rnd.py).
      • Setup: one band plus the top, n_T = 1, and n₁ = 2^(h·ln r) − 1 aimed at h = halfway ± δ, for 399 halfway points × 3 edge pairs × 8 values of δ. That's 9,576 fits, each checked against the exact root's half-even rounding in 60-digit decimal.
      • At δ = ±1e-9 and ±1e-12: 0 mismatches.
      • At δ = ±3e-15: 141 of 2,394 mismatched. For example, the exact root 2.82000050000000284 should round to 2.820001, and the kernel gives 2.820000.
      • So the kernel's rounding is wrong only when the exact root lies within about 3e-15 of a six-decimal halfway point.
    • Ran: plausible inputs.
      • I scanned 2,000,000 integer n₁ with Belgium's n_T and edges.
      • The closest root to a halfway point was 5.7e-13 away (n₁ = 1,872,978). The kernel matched the exact rounding there and on the next two closest.
      • Inferred. For integer counts the chance of a root within about 3e-15 of a tie is about 6e-9 per fit. I found no wrong α on a plausible input, so I propose no rewrite.
    • Precedent: the repo already accepts libm-dependent output.
      • @1's Pareto inverse uses np.log1p, np.expm1, np.log and np.exp (packages/microcosm-frame/src/microcosm/frame/transport.py:153-168).
      • @1 still declares the default Numeric.BITWISE (T/population_kernels.py:492-495; the default is at microcosm-graph/.../kernel.py:188).
      • @2 reuses @1's capabilities object (T/quantile_kernels.py:119). So @2's α carries the same cross-platform exposure @1's mapped values already carry.

    1d. Degenerate inputs: HOLDS. Ran scratchpad/s/degen.py. Each case is either refused by name or handled:

    Input Result
    n_top = 0, two bands with counts Fits α = 12.570705, a genuine interior MLE. Through @2 the kernel then refuses, because @1 says "Band shares must be finite and positive" (Ran, item 2). The PR body doesn't mention this case.
    Every anchor count 0 except the top bracket_without_sign_change (ℓ′ = −10.94 at both ends)
    One anchor band with count 0 (of two) Fits (6.574475). Through @2, @1 refuses the zero share.
    The only finite band at 0 bracket_without_sign_change
    U < L, or U = L "band 'a' must have upper above lower"
    A gap, or lower = 0 "must be contiguous and increasing" / "band lower must be finite and positive"
    A NaN or bool count, or a finite top Refused by name
    Bracket low = 1, or 0.5 Accepted by the parser (it requires only 0 < low < high, :230). The fit then runs, and a root ≤ 1 hits alpha_not_above_one.
    low = 0, low = high, low > high "bracket must satisfy 0 < low < high"
    0 and 201 iterations, True, 64.0 "iterations must be an integer from 1 to 200". 200 is accepted.
    Bracket text "1e3", " 2", "-1", "+2", "2.", "01.5", "1.5 ", "inf", "NaN", "٣", or a JSON number "must be nonnegative decimal text" (_DECIMAL_TEXT, :56, ASCII-only)
    • Nit: alpha_not_above_one can't fire under the declared bracket.
      • The design's low end is 1 + 2^−20 ≈ 1.00000095, which is above 1.0000005. So any α bisected inside it rounds to at least 1.000001.
      • Heavy-tail data with a true root ≤ 1 therefore surfaces as bracket_without_sign_change. Ran: counts (1, 1000) gave ℓ′ = −94.4 at the low end.
      • R2C-9's requirement still holds, since both cases are refused. The test reaches alpha_not_above_one only with a bracket starting at 1/2 (test file :149-157).

    2. B: where the counts come from, and which bands anchor (T/quantile_kernels.py)

    2a. Belgium 2, one source: HOLDS.

    • Read. estimated_alpha decodes the artifact once (:80), runs _surface_bands(document, surface, unit=unit) on that object (:88), and reads the counts from surface.registry.specs on the same object (:95-103).
      • _surface_bands builds its dict the same way (T/population_kernels.py:418).
      • The delegated @1 decodes the same artifact.payload bytes a second time to get shares (T/population_kernels.py:554-558).
    • Ran scratchpad/b2.py, trying to make the decodes or sources disagree:
      • With a spy, estimated_alpha decodes exactly once, and the object _surface_bands validates is that decoded object.
      • Two decodes of one payload give identical (name, value) lists.
      • Spec names are unique, and every value is a Python float, so _anchors' type check sees what _surface_bands' float() sees.
      • The receipt's counts are the band rows' surface counts.
      • Inferred. A disagreement would need decode_target_surface to be nondeterministic on fixed bytes. The kernel has no other count input, so I couldn't build one.
    • Mutant B-M2 (Ran). Deleting @2's own _surface_bands call (:88) leaves all of B's tests green, because @1 re-validates the same payload before any output is written. So it's safe, but no test pins @2's own validation. Style only; not in fixes owed.

    2b. The top-suffix rule: HOLDS. Ran scratchpad/suffix.py through the kernel on the test's toy surface:

    Anchors Result
    A gap ([toy_wage_1, top]) "anchor_references must be the top contiguous bands…"
    Stops short of the open top Same refusal
    A duplicate "…each once" (parser)
    A reference outside the band list "top contiguous bands"
    A reordered list "top contiguous bands"
    More anchors than bands "top contiguous bands" (:90-94)
    Band edges non-contiguous inside the anchors "anchor bands must be contiguous"
    The band list's last band is finite "fits an open top band"
    A reversed band list "fits an open top band"

    2c. Signed and component band documents: HOLDS. Ran. {positive, negative} and {components: […]} are both refused: "fits one alpha to a bare band list; signed or component bands are refused" (:72-76).

    3. B: @2 against @1

    3a. The differential: HOLDS, for the right reason.

    • Ran. test_v2_equals_v1_with_its_estimated_alpha_as_a_literal and test_property_v2_is_bitwise_v1_at_the_fitted_alpha pass.
    • Read: why the bytes match.
      • @2 writes canonical_json({"method": "pareto", "alpha": fit.alpha_float}) (:109-111), where alpha_float = numerator / denominator, a correctly rounded int/int (tail_estimators.py:95-97).
      • canonical_json uses json.dumps, which gives the shortest round-trip repr (microcosm-graph/.../canonical.py:43-58).
      • @1 reads the value back through canonical_document_param and json.loads (graph_inputs.py:122-141), then float(interpolation["alpha"]) (frame/transport.py:89).
      • A literal declaration "alpha": 3.348955 is float("3.348955"), the same correctly rounded double as 3348955/10⁶. The test's literal is float(receipt["alpha_decimal"]), so both sides are the same double, not two floats that merely happen to agree.
    • Ran (mutant B-T3). Passing the unrounded midpoint turns both differential tests red.

    3b. The receipt by hand: HOLDS. Ran on Belgium's anchors:

    • alpha = {numerator: 3348955, denominator: 1000000}.
    • top_band_mean = 66979100/156597. By hand, 3.348955·300/2.348955 = 1004686500/2348955, which reduces by 15 to 66979100/156597, ≈ 427.716367. That equals top_band_mean_decimal.
    • score_at_final_bracket = [5.8e-11, 0.0], which bounds the root as the design asks.

    4. B: composition (T/compose.py)

    4a. git grep -n 'quantile_map@1' outside the tests at B: HOLDS. It gives 10 hits, all non-test and none in .md or JSON:

    Hit What it is
    T/compose.py:181 SKELETON_KERNELS, with @2 beside it at :182
    T/compose.py:217 _QUANTILE_MAPS, which holds both
    T/compose.py:845 Docstring naming both
    T/compose.py:951 Refusal message naming both
    T/compose.py:1016 Route map, with @2 at :1017
    T/compose.py:1633 _SUPPORT_PRODUCERS, with @2 at :1634
    T/population_kernels.py:484 and :491 @1's own definition
    T/quantile_kernels.py:5 Docstring
    T/quantile_kernels.py:66 @2's refusal "a literal alpha belongs to transport.quantile_map@1", which should name @1 alone

    Every other hit lists @1 alongside @2. No hit should accept @2 and fails to.

    4b. A toy graph whose quantile-mapped column comes from @2: HOLDS.

    • Composition and the support roster (Ran, scratchpad/roster.py):
      • I took the NZ spec and added a person.employment_income map node with full params: the test's bands, and either @1 with α 2.5 or @2 with the estimator. Then I ran compose._support_roster.
      • Both give 420 roster rows, 307 of which have employment_income as a source, and those 307 rows are byte-identical.
      • The source class is quantile_mapped under both, against pending with no map (:1842-1846).
    • The support producer (Ran in B's tests): test_the_support_bounds_kernel_reports_a_null_expected_max_for_v2 runs w4's donor-ranges and support-bounds kernels on @2's params and gets expected_max: null and records_outside: 0.
      • Read. quantile_support keys on interpolation["method"] (T/evidence_contract.py:576-585), which is pareto for @2.
    • The money-producer checks (Ran, scratchpad/money.py): I called _check_single_money_normalization with the real NZ money_normalization resource on a minimal graph.
      • @1 rewriting person.employment_income: accepted.
      • @2 doing the same: accepted.
      • transport.currency@1 doing the same: refused, "declared quantile_mapped but transport.currency@1 node 'x' produces it".
      • B's test_composition_accepts_v2_as_the_quantile_mapped_money_producer covers _money_producer_gaps the same way.

    4c. Belgium 3: HOLDS.

    • @pytest.mark.xfail(strict=True, reason="needs the w4 fix round's E1") sits on test_v2_support_bounds_both_signs_for_a_bare_band_list (test file :784).
    • git diff d81280bb c29e4871 | grep -n 'xfail\|skip' finds that mark and nothing else, so it's the only xfail or skip either PR adds.
    • Ran. It reports XFAIL at B and at C.

    5. C: measure.combine@1 (T/measure_kernels.py)

    5a. Declaration validation: HOLDS. Ran scratchpad/cdecl.py on 34 malformed declarations. Every one is refused by a message naming the field:

    • each of the 6 fields missing, and an extra field ("declares exactly …", :92-103);
    • a duplicate component ("must be distinct", :124-125);
    • sign 0, 2, −2, True, 1.0 or "1" (:107-122);
    • scale 0, −100, 100.0, True or "100" (:138-139);
    • floor 0.0, 0.5, True or "0", and 2⁶³ or −2⁶³−1 (:136-141);
    • an output that collides with an input, an output that collides with another output, and an extra output role (:142-153);
    • an add-back that isn't a component, and a duplicated add-back (:126-135).

    A negative integer floor (−5) is accepted, which the spec doesn't forbid.

    5b. Arithmetic: HOLDS. The universe threshold is a build-session reading, not specified.

    • Python integers (Read). _component_values converts every cell with int(value) (:164-174). The signed terms, the add-backs and the total are Python int (:203-216). The unit is max(floor, x) / scale as int/int (:359-362).
    • Where the int64 refusal checks (Read). It checks x, once per record (:217-221), not each partial sum.
      • Every partial sum is an unbounded Python int, so none can overflow.
      • Only x is written, so checking x is the right place. The difference doesn't matter.
      • Inferred. The receipt's sum_measure and unweighted_signed_total are Python ints that can exceed int64 even when every x fits. They are receipt JSON, not columns.
    • The three statuses (Ran in C's tests and by Read, :209-212, :225, :359-362).
      • A null component gives unresolved, a <NA> measure and a NaN unit.
      • x ≤ 0 gives excluded.
      • x > 0 gives included whatever the floor. Ran: with floor 2000, x = 1000 is included and its unit is 2.0.
    • Do the design or R2C-10 specify "included at x > 0 whatever the floor"? No.
      • Design §3 says "included if x > 0" and "ird_taxable_income_proxy_nzd = max(0, x)/100". It has no floor field at all.
      • R2C-10 adds the declared floor and scale at Belgium's request ("NZ declares floor 0 and scale 100") and says nothing about the universe.
      • For NZ (floor 0) the two readings coincide. For a nonzero floor, "x > 0 regardless" is the build session's judgment, and PR G8-r2c C: measure.combine@1 sums declared signed cent components #1461 calls it what "the brief and design §3 say". That claim goes further than either text. It's a hub ruling for Belgium, not a code defect, so it's not a Max item.

    5c. Add-backs: HOLDS.

    • The design (§3) defines the rental treatment as "max(0, r): losses not deducted", x = Σ signed components + rental add-back, and "add-back = Σ max(0, −r)".
    • Read. The kernel computes backs = max(0, -value) on the signed add-back component (:213-215) and adds it (:216). So r + max(0, −r) = max(0, r).
    • Ran. Rent −3,000 with wages 10,000 gives 10,000, and add-back total 3,001 over [−3000, 3000, −1].
    • Read, for a −1-signed add-back. The kernel takes max(0, c), so the component can only lower x to zero, never raise it. That's consistent with "counts only its gains".

    5d. measurement_id: HOLDS. Here is the contract hunk A must read.

    • It's the node parameter key "measurement_id", optional (:297-302).
    • It must be a non-empty str, checked by string_param (:309-313; T/graph_inputs.py:85-91).
    • It sits at the top level of the node's params, not inside the measure declaration, so measure_sha256 doesn't cover it.
    • It's copied verbatim to receipt["measurement_id"], and is None when absent (:379-383).
    • The hub should check that A reads node.params["measurement_id"] as a string on the node whose outputs are the interval table's income and universe columns.

    5e. Money classification of a _cents output: HOLDS. It's accepted, and correctly left outside the money checks. Ran scratchpad/money.py, with the NZ money_normalization resource present:

    What measure.combine@1 writes Result
    ird_taxable_income_proxy_cents, _nzd, _universe_status Accepted
    interest_income_cents, a normalize companion Refused: "is a money.normalize@1 companion, but measure.combine@1 node 'x' produces it" (T/compose.py:1009-1026)
    A donor money concept (interest_income, nonfarm_…, farm_…) Refused: "declared normalized but measure.combine@1 … produces it" (:1088-1094)
    employment_income_cents Accepted, because employment_income is quantile_mapped, not normalized, so its _cents name isn't a companion. The design reserves that column for money.minor_units@1, and _check_money_bridge_owners (:969-989) would refuse a second owner in lineage.
    • Why the stand-in is accepted (Read). money_concept_columns() holds only Unit.BASE_CURRENCY concepts (B/money_normalization.py:340-346), and ird_taxable_income_proxy_cents and _nzd aren't among them.
      • The money checks iterate only those concepts and the companion set, so the stand-in is outside both. It isn't misclassified.
      • In the support roster it is derived, with its components as sources (:1859-1886).
      • In the export contract it isn't scratch, because only _MONEY_BRIDGES and reporting.* outputs are (:1384).

    5f. The UNIVERSE_STATES import: HOLDS.

    • Read. implementation_hash digests reference_intervals_module (:287-293). Ran: the hash is 8636ac082235b979….
    • One coupling goes beyond the hash: INCLUDED, EXCLUDED, UNRESOLVED = UNIVERSE_STATES unpacks by position (:70; the tuple is at B/reference_intervals.py:79). If D reordered the tuple, the labels would swap silently.
      • C's tests compare literal "included", "excluded" and "unresolved" strings (for example, test file :159-174 and :189-209), so a reorder goes red.
    • Ran. There's no import cycle: the registry imports cleanly, and every test passes.
    • Beyond moving the hash when D edits the module, I found no problem.

    6. The stack

    6a. Hashes: HOLDS. Ran scratchpad/hashes.py.

    • It registers every kernel the engine-free registry installs: population (with quantile and measure), atomic geography, person location, target, calibrate.ordered_adam@1, gate, terminal, take-up, reporting and evidence (with an empty engine mapping). That's 40 kernels at d81280bb, 41 at B and 42 at C.
    • diff base → B adds only transport.quantile_map@2 17ec8fcdc5bf535b…. diff B → C adds only measure.combine@1 8636ac082235b979….
    • No existing hash moves. All 42 16-hex prefixes match G8-r2c B: transport.quantile_map@2 with a fitted grouped Pareto top band #1459's table row for row.
    • The simulate.* engine kernels aren't covered, as in the hand-off.

    6b. Tests: HOLDS. Ran with -n 4 at each head.

    • The set: the population-kernels, graph, NZ money-normalization, annual-transfers, composed-graph, gross-up-selectors, money-kernels, evidence-kernels and gate-bindings test files, plus each PR's new file where present.
    • git grep for _money_producer_gaps, _check_single_money_normalization, _SUPPORT_PRODUCERS, SKELETON_KERNELS and _MONEY_PRODUCERS found the composition files.
    • I added the evidence and gate-bindings files because they consume _SUPPORT_PRODUCERS' classes.
    Head Result
    d81280bb 965 passed
    B a401e22f 1001 passed, 1 xfailed (the E1 test)
    C c29e4871 1030 passed, 1 xfailed

    test_transport_graph.py's float-literal and country-name guards over T/*.py pass at all three.

    6c. Mutants. B: the hand-off's mutants HOLD. C: NEEDS CHANGE (tests). Each mutant ran against its PR's test file at C's head.

    # Mutant Result
    B-T1 Drop the α ≤ 1 refusal (theirs) Red: test_refuses_an_alpha_at_or_below_one_by_name
    B-T2 Drop the top-suffix check (theirs) Red: test_v2_refusals[…top…] ×2
    B-T3 Pass the unrounded midpoint to @1 (theirs) Red: both differential tests
    B-M1 Half-even → half-up at a tie (tail_estimators.py:166) All green
    B-M2 Delete @2's _surface_bands call (quantile_kernels.py:88) All green. Harmless, since @1 re-validates (2a).
    B-M3 Drop the anchor contiguity check Red: test_refuses_malformed_anchors[rows3-contiguous]
    B-M4 Receipt mean uses the lowest anchor as c Red: test_the_receipt_records_the_fit
    B-M5 Allow 0 iterations Red: test_the_declaration_is_parsed_exactly
    C-T1 Drop the int64 refusal (theirs) Red: test_an_int64_overflow_is_refused_by_name
    C-T2 Count x = 0 as included (theirs) Red: 3 tests
    C-T3 Drop the add-back (theirs) Red: 6 tests
    C-N1 Receipt weighted_signed_total ignores weights (:245) All green
    C-N2 below_zero.weighted sums the x = 0 set (:264) All green
    C-N3 Allow a rewrite output (:339) All green
    C-N4 Drop the whole-row slice check (:315-316) All green
    C-N5 Drop the declared-outputs equality check (:331-335) All green
    C-N6 Drop the floor's int64 range check (:140-141) All green
    • On B-M1. A tie is reachable in principle: 1.0078125 is a binary64 value lying exactly halfway between 1.007812 and 1.007813. The Belgium test only separates rounding from truncation.

    6d. PR bodies: two claims overreach. I found nothing else the code contradicts.

    Every file:line range I spot-checked in both bodies is accurate at the heads, as are the test counts for the files I ran and the hash table.


    Fixes owed

    1. G8-r2c C: measure.combine@1 sums declared signed cent components #1461: the receipt's weighted fields are untested. Mutants C-N1 and C-N2 stay green.
      • Where: T/measure_kernels.py:244-247 and :264-265.
      • Failure: design §3 lists "per-component weighted … totals" and the "weighted mass of records with x < 0 and x = 0" among the receipt's checks. A regression that drops the weights, or swaps the masses, passes every test and ships a wrong receipt.
      • Smallest fix: in one example test, assert weighted_signed_total, below_zero.weighted and at_zero.weighted against hand sums over the frame's linspace(1, 2, n) weights.
    2. G8-r2c C: measure.combine@1 sums declared signed cent components #1461: four node-shape refusals are untested. Mutants C-N3 to C-N6 stay green.
      • Where: :315-316 (whole-row slices), :331-335 (outputs equal the declaration), :339 (no rewrite) and :140-141 (floor fits int64).
      • Failure: deleting any of them passes CI. Without the whole-row check, a filtered slice would compute a measure over a subset with no refusal.
      • Smallest fix: add four cases to test_node_shape_refusals and test_malformed_declarations_are_refused.
    3. G8-r2c C: measure.combine@1 sums declared signed cent components #1461: the PR body states the universe threshold as specified.
      • Failure: Belgium may read "x > 0 whatever the floor" as the design's rule when it's a reading (5b).
      • Smallest fix: reword it as a build-session judgment and ask for a hub ruling on a nonzero floor. NZ isn't affected.
    4. G8-r2c B: transport.quantile_map@2 with a fitted grouped Pareto top band #1459: half-even is untested at a tie. Mutant B-M1 stays green.
      • Where: B/tail_estimators.py:160-168.
      • Smallest fix: a direct unit test of _round_half_even on Fractions (for example 1.0078125, which should give 1.007812, and 1.0078135, which should give 1.007814).
    5. G8-r2c B: transport.quantile_map@2 with a fitted grouped Pareto top band #1459 (nit): alpha_not_above_one can't fire under the declared bracket [1 + 2^−20, 64].
      • Failure: heavy-tail data is refused as bracket_without_sign_change (1d). It is still refused, so R2C-9 holds, but the receipt names the wrong reason.
      • Smallest fix: one sentence in the PR body and the module docstring.
    6. G8-r2c B: transport.quantile_map@2 with a fitted grouped Pareto top band #1459 (style): the scaling invariant in the PR body should say it holds within one unit of the 6th decimal, which is a relaxation of the design's "unchanged".

    Left out

    • I didn't run the 53-file closed list, the 24 pinned-hash modules or the engine directories. I ran the composition-related set in 6b only.
    • I ran no NZ builders' --check and no ruff.
    • I didn't review hunks A or D, the w4 fix round, or NZ's E to G.
    • I didn't re-run the hand-off's remaining 10 B and 6 C mutants.
    • I didn't build the full NZ toy run (make_nz_toy_fixture). 4b and 5e use the real NZ spec, with a minimal graph for the money check.
    • The scripts named above are in my session scratchpad, not in the repo.

    Generated by Claude Code

  12. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    ContributorAuthor

    Hub rulings R2C-13 and R2C-14 (from the B and C review, comment 6585875953)

    R2C-13. Where a node declares its measurement. A node declares measurement_id in its own params, as a non-empty string under that key. C's measure.combine@1 already takes it there (the review's 5d). A's proof reads params["measurement_id"] on the node that writes the interval table's income and universe columns, and refuses any other place. The A and D continuation brief carries this ruling.

    R2C-14. C's universe ignores the floor. measure.combine@1 marks a record included at x > 0, excluded at x ≤ 0 and unresolved on any null component, whatever floor the declaration names. The floor bounds only the unit column, max(floor, x)/scale.

    • Design §3 sets the x > 0 rule. R2C-10 moved the floor into the declaration and left the universe alone.
    • NZ declares floor 0, so the two readings agree for NZ. Belgium's stand-in comes from its rules, not from C.
    • A country that needs zero or loss records inside the universe, as a nil band does, asks for a declared universe field. That takes a new ruling.
    • G8-r2c C: measure.combine@1 sums declared signed cent components #1461's body states this rule as R2C-14, not as the design's text.
  13. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    ContributorAuthor

    Cloud build session picked up G8-r2c shared hunks A and D, continuing from the pushed heads after the earlier session stopped at an account limit.


    Generated by Claude Code

  14. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    ContributorAuthor

    Cloud build session picked up the holdout separation guard (B3 claim 8).


    Generated by Claude Code

  15. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    ContributorAuthor

    Cloud build session picked up the G8-r2c B and C merge-up.


    Generated by Claude Code

  16. 70 remaining items

  17. MaxGhenis commented on Nov 3, 2026

    @MaxGhenis
    ContributorAuthor

    Ready for hub review
    VERDICT: APPROVE_WITH_NITS
    Needs Max: no
    F1 null household pointers and holdout rows review

    Brief 210 v2, cloud review session, 19:19Z to 20:25Z. Ran: gh api …/pulls/1481 printed open 9b49ef303bb4f0a3188f871236591ade3b363a07, and the earlier-report check printed nothing. I set up the head, main (3995d92d) and M (origin/main 31c89390 merged with the head, clean) on Python 3.13.18 with uv sync --all-packages --locked. I pushed nothing, and every worktree I mutated reads git status clean. The interim is comment 6614221923. Its "9 of the 13 forms" should read 10, as item 4 below gives.

    1. Scope

    • Ran. git diff --stat lists only the five allowed files: compose.py +32/−6, concept_mapping.py +13/−4, concept_reference.py +14/−9, and the two test files. These match the body's "17 lines" and "38 lines".
    • Read. Each source fix needs every hunk it has:
      • F/concept_mapping.py:2096-2098, :2121-2125 and :2127-2136 are the three fix 1 lines, plus comments and docstrings.
      • In T/compose.py, _is_holdout (:1010-1016) is a shared predicate that both guards use. seen moves up (:1035) because fix 4's dedupe needs it, and the new gap block is :1039-1049. The stage guard filter is :3015-3021, and the .get('entity', '?') is at :3134.
      • Ran. The text-entity filter on holdout_columns is needed: at main a list entity raised TypeError: unhashable type: 'list'.
    • I found no line that no fix needs.

    2. Fix 1 and I1

    Read. _household_rows has three readers at the head:

    • household_amount_for_persons (:2125) gives row -1 the appended NaN.
    • reference_rows (:2136) gives row -1 the appended -1.
    • _apply's AllocateToReferencePerson (:2221-2223) compares the reference row with the person's own row. -1 never equals a row, so the person gets 0.
    • _role_flags (:2274-2282): the REFERENCE_PERSON test is reference == rows, so it is False at -1. The partner role reads np.where(reference >= 0, …, -1), so the person is never flagged as the reference person's partner, married or not.
    • _coresident_adult_flags (:2301-2325), _child_counts and HAS_PARTNER/NO_PARTNER don't read _household_rows, and F1 leaves them as Add a person-level coresident_adult transform kind #1477 left them. A null pointer shares a household with nobody. An unknown id groups with the other persons who carry that same id.
    • The group path (_Units, :2443-2451, :2485-2495) still converts pointers with to_numpy(dtype=np.int64) and refuses unknown households, as the spec asks.

    Ran. I used a probe frame: P1 in household 10, P2 and P4 in household 20, and P3 detached, with partners P2↔P3. Each line gives the head first, then main.

    • pd.NA pointer, Int64 and UInt64. Head: rows [0,1,-1,1], reference rows [0,1,-1,1], amount NaN for P3, rent_paid [100,900,0,0], and no partner flag. Main: reference rows [0,1,1,1] and amount 900 for P3. P3 read household 20's row.
    • Unknown pointer (99). Head gives the same as above. Main gives the same misread as above.
    • Object column with None, with int64 or object household ids. Head gives -1, NaN and 0. Main gives reference row 1 and amount 900.
    • Household table with a null id. Ids [10,20,NA] (Int64, UInt64 and object None). Head: P3 gets -1, NaN, rent_paid 0 and reference_person False. Main: P3 found the null-id row, with rent 500 and reference_person True.
    • Empty household table, int64 and Int64. Head: every person gets -1 and NaN, with rent_paid all 0. Main raised IndexError: index -1 is out of bounds for axis 0 with size 0.
    • Empty person table. Both versions give empty columns and raise nothing.
    • Pointer to a duplicated household id. Both versions raise pandas' InvalidIndexError: Reindexing only valid with uniquely valued Index objects from get_indexer (:2093-2095). They raise even when no person points at the duplicated id. F1 doesn't change this.

    3. Fix 2

    • Read. The reference (concept_reference.py:57-78, :121-124) gives a person with no household household_of = None. reference_of then gives None, allocation gives 0, and every reference-person and partner role gives False.
    • Read. _detached (test_concept_mapping.py:164-182) draws null pointers (st.none()) and unknown ones (st.integers(0, 2**40) outside the known ids) for a random subset of persons. The column is Int64 when any pointer is null and int64 otherwise. It never draws UInt64 or a null id in the household table.
    • Read. test_encode_matches_a_row_by_row_reference runs for all four packaged mappings (PROPERTY, 100 examples). Ran. These mappings read household rows: policyengine-us (3 AllocateToReferencePerson, plus the reference-person, partner and unmarried-partner roles) and axiom-be (1 AllocateToReferencePerson). axiom-nz and policyengine-uk read none.
    • Inferred. The reference and encode can't agree vacuously. Detached rows compare as 0.0 and False, not NaN. assert_encode_matches_reference compares floats with np.isclose without equal_nan, so NaN against NaN would fail the test, not pass it.

    4. Fixes 3 and 4, and I5

    Ran. For each form I called _holdout_separation_gaps directly, _precalibration_stages with a stage output on the measure, and validate_transport_activation (head / main):

    form activation's guard stage guard validate_transport_activation
    list or dict measure 1 gap / skipped silently skips / TypeError 1 gap / no gap
    missing measure 1 gap / silent skips / KeyError 1 gap / no gap
    empty-string measure 1 gap / silent skips / skips 1 gap / no gap
    text measure, missing entity holds out / same refuses, (?, …) / KeyError —
    text measure, int entity holds out / same refuses / refuses —
    text measure, list entity holds out / same refuses / TypeError —
    row not a mapping (str, None, list) 1 gap naming (?) / silent skips / TypeError TypeError from the row check at both
    list name with a list measure 1 gap / silent skips / TypeError TypeError: unhashable type: 'list' at both (compose.py:984)
    int name, missing measure 1 gap (7) / silent skips / KeyError 1 gap / no gap
    dict name, text measure holds out / same refuses / refuses TypeError at both (:984)
    • At the head, neither guard function raises on any form. At main the stage guard raised on 10 of the 13 forms.
    • Both guards hold out the same measures. A row with a text measure and a non-text entity still blocks the stage output on its measure, and leaves holdout_columns alone (:3017-3021).
    • A probe row on that measure in target_references draws a leak gap that names the entity as ? or 7.
    • The same document passed twice gives one gap. A second view that holds the same malformed row at another index gives two gaps, one per index (Fixes owed 1).
    • validate_transport_activation raises TypeError before the guard on a non-mapping row and on a list or dict name, and it did so at main too (Fixes owed 2).

    5. I4 across versions

    Ran. I ran encode for all four packaged mappings at main and at the head, in separate processes, on the same pickled inputs:

    • 600 concept_frames() frames, with a share and a rate drawn per frame;
    • 300 test_group_encode.unit_frames() cases through that module's _encode (encode_groups);
    • 300 of the frames with Int64 ids, half of them with an extra household row whose id is null.

    Every person household id named a household. assert_frame_equal(check_exact=True) found 0 differences in values, dtypes or deferred bindings, across 174,900 columns. No input raised at either version.

    6. The properties

    Read.

    • _strays (test_concept_mapping.py:2297-2333) draws:
      • null and unknown pointers;
      • Int64 and UInt64;
      • sometimes a household row with a null id, whose reference is a detached person.
    • It never draws an empty household table (concept_frames has min_households=1), an empty person table or an object column. Only the unit test test_empty_household_table_leaves_everyone_without_one covers the empty household table, and only for Int64.
    • I5's _holdout_rows draws list, dict, None, int, empty and missing measures and entities, plus non-mapping rows. It never draws a non-text name or a holdout document in two views.

    Ran. I raised max_examples to 1000 with a fresh database. I1, I2 and I3 passed 1000 each, with 133, 83 and 109 invalid. I5 passed 1000, with 64 invalid. The run took 150 s.

    7. The changed old test

    • Read. test_activation_reports_a_malformed_holdout_row covers the dropped holdout_references case. It covers the list and dict measures that the old test drew, and asserts the new gap both from the guard and through validate_transport_activation.
    • Read. I found no other removed or weakened assertion. The other removed lines are docstrings, an import line that the head extends, and the reference's old lookups.

    8. Mutants

    Each run used the whole test file named, with -n 4, and I restored the tree after each one.

    Ran, the hub's mutants:

    • M4b (drop the seen check at :1047-1049): only test_guard_names_a_malformed_holdout_row_once_across_views goes red, out of 51 tests.
    • M1a: 3 red, the 2 null household id cases and I1, as the body says.
    • M1b: 6 red, the reference row cases ×4, the empty household table and I1, as the body says.
    • M3b: 2 red, the stage guard's no entity case and I5, as the body says.

    Ran, my own mutants:

    • R1 (_is_holdout accepts an empty measure): only I5 goes red.
    • R2 (dedupe malformed-row gaps by (resource, name), not by gap text): only I5 goes red.
    • R3 (holdout_measures drops rows with a non-text entity): the stage guard's no entity case and I5 go red.
    • I5 was the weakest point I found, so I reran R1 and R2 on that test alone with seeds 1 to 5 and a fresh database each time. All 10 runs went red.

    9. Kernel hashes

    • Ran. make_composed_fixture installs 45 kernels. Eight move, with the same 12-character prefixes as the body's table. A second run at main repeated every hash.
    • Ran. I put each changed source file from the head onto main alone. concept_mapping.py alone moves all 8, and compose.py and concept_reference.py alone move none. I didn't hash the AxiomEngine kernels.

    10. Tests at the head and at M

    Ran. -n 4 -rs, with NZ_CHRONICLE_ARTIFACT unset. The head and M gave identical results:

    run head M
    frame trio 359 passed 359 passed
    test_transport_holdout_separation.py 51 passed 51 passed
    composed -k "holdout or precalibration" 252 passed 252 passed
    NZ pair 48 passed, 4 skipped 48 passed, 4 skipped

    The skips:

    • test_nz_axiom_input_closure.py:535 and :610 need axiom_rules_engine with its dense extension and a rulespec-nz checkout.
    • test_nz_transport_declarations.py:281 ×2 reports "Pinned feed not staged", because the feed is unset. The hub ran those two with the feed.

    No test result differs between the head and M.

    11. The body and the hand-off

    • Read. Every file:line I checked matches the head:
      • F/concepts.py:1403 and F/transport.py:593/:876;
      • concept_mapping.py:2096-2098, :2121-2125 and :2127-2136;
      • compose.py:1010-1016, :1019-1049, :3015-3021 and :3134, with :1027 on main;
      • concept_reference.py:57-78 and :121-124.
    • Ran. The counts check out:
      • 50 holdout tests at 85efd107 plus the new test make 51 at the head, and 359 + 50 + 252 + 50 = 711 matches the hand-off.
      • The mutant counts for M1a, M1b and M3b match, as does the hash table.
      • The head merges cleanly with 4a36edcf (on main) and with 20507ad8.
    • Not confirmed. The body says activation's guard "reports each malformed row once, even when the document arrives in two views". That holds only for identical views (Fixes owed 1). I5's "neither raises (activation reports gaps instead)" holds for the guard functions, but not for validate_transport_activation (Fixes owed 2).
    • I didn't rerun the hub's packaged counts, M1, M1c, M2, M3, M3a, M3c, M4, ruff or ci_test_plan.py verify.

    Left out

    • The hub's other mutants (M1, M1c, M2, M3, M3a, M3c, M4).
    • The packaged-input counts, ruff and ci_test_plan.py verify.
    • AxiomEngine kernel hashes.
    • encode_groups with a null or unknown pointer (Read only: _Units refuses it at :2443-2451).
    • The differential reference test at raised max_examples.

    Fixes owed

    None blocks the merge. Two body nits:

    1. T/compose.py:1039-1049. The PR body says the guard "reports each malformed row once, even when the document arrives in two views". Ran. That holds when the views are identical. A second view that holds the row at another index gives two gaps, [1] and [0]. Such a view needs reference_profiles to name holdout_references as its reference resource, and no packaged spec does that (Inferred). Smallest fix: write "even when the same document arrives in two views" in the body and the squash message.
    2. PR body I5, "neither raises (activation reports gaps instead)". validate_transport_activation raises TypeError on a non-mapping holdout row (its row check), and on a list or dict name (compose.py:984, _reference_activation_gaps). Both behave the same at main, and F1's test pins the first. Smallest fix: write "neither guard raises" in the body.

    Generated by Claude Code

  18. MaxGhenis commented on Nov 3, 2026

    @MaxGhenis
    ContributorAuthor

    Interim: H2 NZS co-residence binding review

    Head 1b56b916. M is the head merged with G's fix head f43e8fbb, then with origin/main 31c89390: main has moved past 4a36edcf, so I merged it into M as well. Both merges were clean. F is M merged with F1 9b49ef30, also clean. The pinned feed built, and both digests match. Dependency sync took about 45 minutes of the time box.

    Findings so far:

    • Scope (Ran). 15 files change. git show 891cb9f1 | git patch-id --stable prints e3f8d1c790817d1c3e6677e68fd1f0a564f68af2. No commit touches F/concept_mapping.py, any T/*.py, or another shared src or test-support file beyond the two prepared hunks. Shared test lines change only at test_transport_evidence_kernels.py:810 and :2734-2735.
    • Law (Read). nz_super_living_alone_adult_age is 18 (core.yaml:65-79), and core.yaml names it only at its definition. The input's name, "at least adult age", supports >=. Only nz_super_living_alone (core.yaml:550-572) reads the input, and only the single branch of nz_super_ordinary_weekly_rate_before_tax (:664-670) reads that rule. The closure defaults nz_super_principal_residence_is_qualifying_accommodation to true, so every single superannuitant reaches the rate. The rates 595.57 and 647.37 and the clause 1(b) citation match core.yaml:80-111. core.yaml at 6fe181fc and at 8dc2507d hashes to d8ea0d15….
    • Mechanism (Ran, at the head and at F). Each case gives the same result at both trees. A person alone reads F. A person living with an adult of exactly 18 reads T. A person whose only co-resident is 17 reads F. A co-resident whose age is null doesn't count. A person whose household id is null reads F. Two persons whose ids name no household row group with each other and read T. A household table that holds an id twice makes _Context.__init__ raise InvalidIndexError. F1 changes _household_rows, household_amount_for_persons and reference_rows, and _coresident_adult_flags reads none of them. So F1 doesn't reach the binding, though the hand-off gives a different reason ("not through Frame").
    • Toy (Ran). I read household composition directly from the frame. On the toy's nz.create frame, it agrees with the binding for all 120 persons. 118 persons read true, and the two who read false are 10006 (20) and 10009 (21). All 20 rows aged 65 or over match the hand-off.
    • Pins (Ran). At the head, git grep '791c1cd4\|06ce7ca0' finds nothing, and the closure's SHA-256 is d2694200…. The closure partition moves as claimed: nz_super goes from 2 encoded and 11 defaulted to 3 and 10, and the whole closure from 93 defaulted to 92. The only entries that change are the sharing input and the exempt reason.

    Still running: the 42-file runs at the head and at M, the builders' --check, and the mutants.


    Generated by Claude Code

  19. MaxGhenis commented on Nov 3, 2026

    @MaxGhenis
    ContributorAuthor

    Ready for hub review
    VERDICT: APPROVE_WITH_NITS
    Needs Max: no
    G8-r2c G toy review with both fix rounds

    All three heads match the brief: toy 85d55af5, fix f43e8fbb and G 64ef20af, per git ls-remote at 19:18Z, and #1183 is open. M is main 31c89390 with the head merged in. It merged with no conflict as 9752cf31 and was never pushed. Main has moved since 4a36edcf, so M differs from the head. I built the feed from Chronicle 8f4cfd57 under Python 3.13, and both digests match the pin (03f5ea0f…, 1ea2d1ee…). Every run set NZ_CHRONICLE_ARTIFACT unless a line says "without the feed". File:line is at f43e8fbb. Labels: Ran means I ran it, Read means I read the code, Inferred means my reasoning. Every mutant was restored, and git status was clean after each one.

    No finding blocks the landing. The four design tests, the hub's one-writer test and both fix rounds hold, and every mutant the authors reported reproduces. The nits are five places where a test is weaker than it reads. Three of my mutants survive in the toy file, and one survives fix 3's feed-free test.

    1. Scope: HOLDS

    • Ran. git diff 64ef20af 85d55af --stat changes 3 files. 9ae0914c (toy session) changes test_support/microcosm_build/transport_nz_toy.py (+343) and test_nz_transport_toy_graph.py. 4ae5810e (toy session) changes test_nz_transport_toy_graph.py (+21). 85d55af (hub) changes test_nz_transport_declarations.py (+53/−3). No src/, tools/ or golden file changes.

    • Ran. The first-parent log over 85d55af..f43e8fbb:

      • f43e8fbb Keep NZ's interval outputs out of the small-frame column tests: test_calibration_columns.py only.
      • 20507ad8 Expect G's nz_super stage in the holdout stage-guard test (R2C-26): test_transport_holdout_separation.py only.
      • d1d72353 Merge main (4a36edc) into the E-G fix round. Its parents are 044440a7 and 4a36edcf, and its tree 52aafc74 equals git merge-tree --write-tree 044440a7 4a36edcf, so it has no hand resolution.
      • 044440a7 Name the stand-in in the declared-scenario scaling test: test_transport_composed_graph.py only.
      • 0506c9d6 Test each wage cut against its own committed label: test_nz_transport_declarations.py only.
      • 06760993 Test each coarse row's labels against its committed native rows: test_nz_target_references.py only.

      Each commit matches its row in E-G fix round: feed-free label tests, the stand-in rename and R2C-26 #1486's body.

    2. The toy mirrors G: HOLDS, with disclosed differences

    Packaged counterparts are at 64ef20af. Read and Ran, by printing both sides.

    • Measure. Toy: toy_standin_declaration (transport_nz_toy.py:638). Packaged: N/income_measurements.json measures.central. Both have floor 0, scale 100, the outputs measure/unit/universe_status, and rental_income_cents as the only add-back, with sign +1 everywhere. The difference: the toy sums 7 components (wages, farm, interest, rental and three toy transfers), and the packaged measure sums 10. The toy has no non-farm self-employment, dividends or NZS column. That's harmless to the gates, but see nit 4.
    • Stand-in node. nz.income.standin (:725), against N/transport_graph.json:1845. Both use measure.combine@1 on nz.central, the same measure/measure_sha256 selections (json/value encodings), the same measurement_id param, the same three outputs and dtypes (Int64/float64/string), and an nz.earnings.cents money.minor_units@1 node with identical params (:642 packaged). The input is the 7 toy columns, not the 10.
    • Coarse table (:652): person, nil_band: absent, floor_zero, first cut 15,000 above zero, and 3 bands, each with both companions. Packaged: the same policy, first cut 4,000,000, 12 bands.
    • Summed rows (:693): count rows carry value_operation: sum with no operands, amount rows are identity with no aggregate_fact_key, and each row has an expected_member_count and a list selector from TOY_STANDIN_MEMBERS. That's the same shape as packaged …people_by_band__b01 (sum, 10) and …total_by_band__b01 (identity, 10), per R2C-17.
    • Prepare and problem. Prepare reads the cents and universe columns and writes the indicators plus toy_people. The problem slices the indicators, cents, universe, the NZD unit (R2C-25) and household.source_household_id, which matches nz.targets.prepare/nz.targets.problem. The toy's prepare id is nz.calibration.prepare, a naming difference only.
    • Gates. The toy prefit takes R2C3_PREFIT_THRESHOLDS (test_support/microcosm_build/reference_intervals.py:389-395, equal to N/gates.json measurement_prefit) with support 3/2 and required_evidence equal to the packaged six. Its criticality is diagnostic where the packaged gate's is release_blocking, which Test G's income stand-in on NZ's toy graph #1483's body discloses. Coverage requires ["income","toy"], against the packaged six families.
    • Can the toy pass where the packaged graph would fail? Inferred: not through the wiring. Diagnostic criticality and lower support thresholds are the only relaxations, and both are disclosed. But the toy's thresholds and declaration aren't pinned to the packaged ones by any test (nits 3 and 4).

    3. The four tests

    Toy file at the head: Ran, 31 passed, 0 skipped.

    • (1) Rows reach the matrix (test_nz_transport_toy_graph.py:786): HOLDS. Each of the 6 rows appears once in target_metadata with a nonzero matrix row, family income and its measure, and A's proof names nz.income.standin for nz.calibrate.

    • (2) Coverage (:825): passes, but it is half the check. Ran: the gate's details list exactly the 6 income rows under income and the 4 demography rows under toy (targets_checked: 10). Ran, with the 6 rows relabelled to family toy and the gate made diagnostic so the run completes: the gate fails with income: target profile has 0 match(es), needs 1 for calibration family 'income'. With the gate still release-blocking, the same mutant makes export.prepare@1 refuse, so the fixture errors. The test never asserts the failing case, nor which rows matched (nit 1).

    • (3) Pre-fit (:839): HOLDS, and the gate really evaluates. Ran:

      • Roster count: model 115.385, comparator 115, ratio 1.00334. Roster amount: model 70,849.85, comparator 70,730, ratio 1.00169.
      • b0: count 53.85/54 (0.997151), 20 households, ESS 18.34; amount 16,654.24/16,610 (1.00266), ESS 17.26.
      • b1: count 38.97/39 (0.999343), 15 households, ESS 13.88; amount 1.0025.
      • b2: count 22.56/22 (1.02564), 9 households, ESS 8.07; amount 33,794.66/33,770, ESS 6.96.
      • discrepant_bands: []. The failures are exactly the six evidence lines in packaged order.

      The test pins neither the ratio bounds nor the support numbers (nit 3).

    • (4) Differential (:880): HOLDS. For all 6 rows it rebuilds each member fact from the pinned feed, checks the selector equals the declared members, and compares count = Σ members and amount = 11/10 · Σ members at rel 1e-12. I found no vacuous path: the nine member facts are distinct, and the test asserts the feed's member facts equal TOY_STANDIN_FACTS.

    4. The property: WEAK at zero and the edges

    Read (:949): each of the 7 components is integers(-150_000, 150_000), with 1–12 records per example. Nothing targets 0 or the aged cuts 16,500, 44,000 and 66,000. Ran, the same strategy at 5,000 examples (about 30,600 records): 13,392 negative, 17,260 positive, x = 0 once (the shrunk all-zero case), and no record on or within one cent of any band edge. Ran: the test itself at max_examples=3000 passes in 86 s. Ran, mutant R1 (B/reference_intervals.py:_position, right closure counted inclusively): all 31 toy tests stay green. 22 shared tests catch it (test_reference_intervals.py, test_nz_reference_intervals.py). Nit 2.

    5. Changed counts: HOLD

    • Read and Ran. :487: n_targets = 4 demography cells + 2 × 3 bands = 10. The coverage gate's targets_checked: 10 agrees.
    • :240 now reads the effective view (4 + 6 active). In the raw resource the 6 rows are requires_prerequisites (toy_standin_references sets the prerequisites), so the body's reason holds.

    6. Mutants

    Each ran in place against the toy file and was restored.

    • Hand-off's, Ran:
      • members_swapped: red, the differential :880.
      • support_threshold_10: red, prefit :839.
      • table_first_cut_0: red, the property :949.
    • Mine, all Ran:
      • R1, closure flip in _position: every toy test stays green, and the shared interval tests catch it (§4).
      • R2, the toy prefit's roster_total_bounds and band_bounds set to [0, 100] and maximum_discrepant_bands: 99: all 31 toy tests stay green.
      • R3, the toy declaration's add_backs: []: all 31 toy tests stay green.
      • My coverage mutant (§3 item 2) goes red only through a fixture error, as a blocking gate. As a diagnostic gate, test (2) goes red on status.

    7. Tests at the head and at M

    Ran, feed set, -n 4.

    • At the head (inside the item 12 run): toy 31, declarations 62, target references 61, all passed with 0 skipped.
    • At M 9752cf31: the same three files gave 154 passed, 0 skipped.
    • No result differs. The branches add no new test_nz_*.py file. The 2026-11-04 clock-pin failures don't apply, because the runs finished before 21:05Z on 2026-11-03.

    8. The bodies: HOLD

    Read and Ran.

    • Test G's income stand-in on NZ's toy graph #1483's file:line list is pinned at 4ae5810e, and every reference checks: :611, :618, :632, :635, :649, :690, :722, :846, :873, :879, :909, and tests :240, :487, :786, :825, :839, :880, :949, :1010. At the head the toy file sits 3 lines lower after :89, because main's Refuse calibration rows that measure a declared holdout #1472 arrives through d1d72353, which is fine.
    • T/compose.py:610/:716/:4372 hold at 64ef20af (:4442 at the head). T/target_kernels.py:541-551 covers _compiled_problem and its refusal at both trees.
    • E-G fix round: feed-free label tests, the stand-in rename and R2C-26 #1486's references hold: B/calibration_columns.py:106-107, T/target_kernels.py:679, test_calibration_columns.py:38, :47-60, :101-107, holdout :277 at 4a36edcf, 257 + 12 = 269 entries, the 106 tests of test_calibration_columns.py, and 1388 = 41 + 62 + 61 + 4 + 16 + 1203 + 1.
    • Ran: test_spec_engine_reemission.py passes 8/8 here, which supports the body's claim that the hub's one failure came from its venv.
    • Not reproduced: the hand-off's counts at 4ae5810e (I ran the head instead) and the Belgium patch-ids (Left out).

    9. The hub's test (85d55af)

    • Observation: HOLDS (Read). _check_measurement_proofs has one call, T/compose.py:4442, on skeleton before the extension loop. measurement_proofs (:610) reads only transport_graph.json rows (:633-635). Nothing re-runs either check after the extensions append nz.v.<V>.calibrate.

    • Definitions (Read). The writer clause is identical to _check_measurement_proofs (:744-759): an output or an EXPAND cell from _precalibration_expansions, over compiled ancestry. The declarer is equivalent (params.get(measurement_id) == binding). The test differs in three ways:

      • (a) It expects the producer by name (<branch>.income.standin), where the code compares against the skeleton proof's producer, and it never sees variants.
      • (b) Its roots are only calibrate.* kernels, where the code also adds the calibration endpoint. Both give nine roots here.
      • (c) It walks the composed graph, not the skeleton.

      The declared-stage measurement_proofs also counts an EXPAND with unresolvable cells as a writer of every column (_declared_writer, :596). The compiled check and the test use resolved cells instead.

    • Nine nodes: HOLD (Ran). N/scenarios.json names nz.v.{V1,V2,V3,V4,VEX,VHF,VPT,VSI}.calibrate, each with its own .income.standin, plus nz.calibrate.

    • Cache: HOLDS (Read). Only :879 and :901 call _nz_composed, and both only read. _spec() deep-copies _pristine_spec() (:42-43).

    • Mutants (Ran):

      • The hub's vsi_standin_removed: 1 failed (the declarer assertion, nz.v.VSI.synthetic_measurement against nz.v.VSI.income.standin), and the compile test passes.
      • Mine, aimed at the writer clause: G7's builder recipe in VEX, but the rewrite copy keeps only the universe_status output (rewrite, no measurement_id). The builder's --check agrees, composition accepts it, the compile test passes, and the hub test fails with ('nz.v.VEX.calibrate', 'person.ird_taxable_income_proxy_universe_status'), naming nz.v.VEX.review_rewrite.

    10. Fix round 1

    • Fix 3: closes 211's item as specified, with a gap.

      • Ran without the feed, the b05 drop: 1 failed (test_each_coarse_row_selects_its_native_rows_committed_labels), 1237 passed, 42 skipped, which matches the body.
      • My mutant, moving row_59 from b01 to b02 in both rows' labels and metadata.native_rows (counts 9/11): 1238 passed, 42 skipped without the feed. The tiling still reads 50…244 in order. With the feed, test_each_coarse_band_lists_exactly_its_native_rows_labels and test_generation_reproduces_committed_resources_with_exact_feed_identity go red. Nit 5.
    • Fix 4: HOLDS.

      • Ran: row_107 upper_cent +100, then build_nz_transport_graph.py, which rewrote earnings_bands.json, and --check printed "match". Without the feed: 1 failed (test_each_wage_cut_follows_from_its_committed_label, "row_107"), 63 passed, 2 skipped.
      • Mine, a new shared endpoint (row_120 "$113,001 - $114,000" → "$113,000 - $114,000", builder regenerated, --check agrees): the same test alone goes red, Left contains one more item: 'row_120'.
    • Fix 2 against G7: HOLDS (Ran). I built G7 in tools/build_nz_entitlement_graph.py after the money-normalize loop:

      • nz.v.VSI.review is a FILTER with base nz.v.VSI, after the stand-in;
      • nz.v.VSI.review_rewrite copies the stand-in, with every output rewrite: true and no measurement_id;
      • prepare, compile and problem move onto review, and calibrate keeps base nz.v.VSI.

      The builder's --check printed "match". Over test_nz_transport_declarations.py + test_transport_entitlement_graph.py: 1 failed, 128 passed. Only the hub test fails, with ('nz.v.VSI.calibrate', 'person.ird_taxable_income_proxy_cents'), Left contains one more item: 'nz.v.VSI.review_rewrite'. test_nz_packaged_graph_compiles_with_only_pins_and_money_producers passes, and no other test changes result.

    • R2C-26: HOLDS (Ran). The diff from 4a36edcf changes only :277, adding "nz_super". The file passes at the head: 41 passed.

    • R2C-27: HOLDS (Read). The recompute lives in validate_earnings_bands (tools/build_nz_transport_graph.py:384, called from --check at :1453) and in tests. bands_sha256 selects earnings_bands at path: [] with encoding: sha256, which is the whole file (N/transport_graph.json:619-623). T/population_kernels.py:11-14 says bands_sha256 "is checked for format only: it moves the node key and authenticates nothing". The brief's :13-15 is one line off, which is harmless.

    • Rename: HOLDS (Ran). git diff 64ef20af 044440a7 -- test_transport_composed_graph.py changes only the function name. The other differences against 4a36edcf are G's Belgium-cleared lines.

    11. Fix round 2: HOLDS

    • The change (Read and Ran). _bindings() filters on "interval" not in entry, which drops exactly the 12 interval entries. At the head, N/calibration_bindings.json has 269 entries, 12 of them interval: ird_taxable_income_standin_band_b01_indicator … _b12_indicator. test_all_active_calibration_bindings_enter_the_small_frame_matrix still asserts 109 (:268).
    • The 109 rows (Ran): I resolved the effective view the test resolves. 109 rows are active, none reads an interval output in its measure or filter, and their measures are households, people and the four receives_* columns. The 24 income rows stay requires_prerequisites (R2C-18). The test fixes the fixture and hides no fault in G: the prepare kernel passes intervals (T/target_kernels.py:679-681).
    • Skip mutant (Ran). B/calibration_columns.py:106-107 returns {} instead of raising: 2 failed, 104 passed. test_packaged_interval_outputs_need_the_effective_interval_document goes red, as required, and the existing fixture test test_malformed_interval_outputs_refuse[no_document] goes red too.
    • My _bindings mutant (Ran). It also drops population_region_01_age_0_14_indicator: 4 failed (test_stale_indicators_are_recomputed_after_inputs_change, …partition_age_and_canonical_tenure…, test_all_active_calibration_bindings_enter_the_small_frame_matrix, test_nonnegative_integer_ages_have_exactly_one_declared_band).
    • CI probe: G8-r2c stack's full engine-free failure list (don't merge) #1489's logs (Ran, both fetched). Jobs 125880289264 (3.13) and 125880289103 (3.14) each read 20 failed, 24863 passed, 286 skipped, 3 xfailed.
      • 17 in test_calibration_columns.py: 9 raise Interval outputs need the effective interval document., 7 fail with "Regex pattern did not match" (6 test_household_roster_refuses_invalid_membership[*] and test_prepared_columns_cannot_overwrite_a_validated_roster_count), and 1 reads 'refused' == 'prepared' (…generated_nz_tables[None]). f43e8fbb fixes all 17: the file passes 106/106 at the head.
      • 3 in test_ci_integration_job.py. CI probe: G8-r2c stack's full engine-free failure list (don't merge) #1489's head a0512be9 (parent 20507ad8) causes them: it sets if: false on engine-us, engine-uk and integration-uk, and drops --maxfail=1 from tools/run_engine_free_tests.sh. E-G fix round: feed-free label tests, the stand-in rename and R2C-26 #1486 doesn't touch .github, and the file passes 9/9 at the head.

    12. The closed list and CI

    • Ran at the head, feed set, -n 4, over 64 files: the 58 files of G8-r2c E-G: NZ's wage map, coarse income rows, stand-in measure, variants and pre-fit gate #1478's table (frame/ from packages/microcosm-frame, plus uk/test_transport_gate_bindings.py) and the 6 extras. Result: 4866 passed, 50 skipped, 0 failed in 32 m 30 s.
    • Skips, each for a source this container lacks:
      • test_be_target_references.py 32: BE_CHRONICLE_ARTIFACT unset.
      • test_nz_joint_geography.py 11: NZ_AS_AREA_SOURCES/NZ_JOINT_GEOGRAPHY_SOURCES unset.
      • test_nz_as_area_crosswalk.py 3: pinned ArcGIS bytes.
      • test_nz_axiom_input_closure.py 2: Axiom dense engine and the rulespec-nz checkout.
      • frame/test_transport_donor.py 2: pinned real donor not cached.
    • ci_test_plan.py verify prints verification=ok, and ruff check passes on the five changed files.
    • CI at f43e8fbb (Ran, check-runs): select-countries, wheels, lint, integration-uk, engine-uk (3.13), engine-uk (3.14), engine-us (3.13), engine-us (3.14), engine-free (3.13) and engine-free (3.14) all report success. Every job had completed.

    Left out

    Fixes owed

    None block the landing. These are nits, most useful first:

    1. Fix 3's feed-free test doesn't tie members to the band's cuts (test_nz_target_references.py:1350-1387). Moving row_59 from b01 to b02 consistently in labels and native_rows passes every feed-free test, and only the feed audit catches it. Smallest fix: in that test, also assert that each band's first member label starts one dollar above the band's lower cut and its last member label ends at its upper_cent, read from ird_taxable_income_standin_ty2024.
    2. The toy prefit test doesn't pin R2C-3's numbers (test_nz_transport_toy_graph.py:842-877 at the head). Mutant R2 (bounds [0, 100], 99 discrepant bands) leaves it green. Smallest fix: assert that the toy entry's roster_total_bounds, band_bounds and maximum_discrepant_bands equal N/gates.json measurement_prefit's, as the test already does for required_evidence.
    3. The property never reaches zero or a band edge (:952-1010). In 5,000 examples it drew no record on any of the three aged edges, and closure mutant R1 leaves all 31 toy tests green. Smallest fix: add @examples with one component set to 0 and to 16,500, 44,000 and 66,000 (each ±1), all other components 0.
    4. No test ties the toy declaration to the packaged one (transport_nz_toy.py:638-650). Mutant R3 (no rental add-back) leaves every toy test green. Smallest fix: assert that the toy declaration's floor, scale, entity and add_backs equal N/income_measurements.json measures.central's.
    5. The coverage test checks only the passing case (:828-839). Smallest fix: assert details["matches_by_requirement"]["income"] names exactly the 6 toy rows, so a family relabel turns the test itself red instead of erroring the fixture.

    Generated by Claude Code

  20. MaxGhenis commented on Nov 3, 2026

    @MaxGhenis
    ContributorAuthor

    Cloud review session picked up the review of #1501 (G's five test fixes).


    Generated by Claude Code

  21. MaxGhenis commented on Nov 3, 2026

    @MaxGhenis
    ContributorAuthor

    Ready for hub review
    VERDICT: APPROVE_WITH_NITS
    Needs Max: no
    H2 NZS co-residence binding review

    I reviewed head 1b56b916daa627715e131cfc22679a1d1bae5dbe on Python 3.13.18. Before I started, pulls/1485 printed open with this head, and nzhub/g8r2c-g-fix1 printed f43e8fbb…. Origin/main had moved to 31c89390, so I built M as the head merged with f43e8fbb, then with 31c89390. F is M merged with F1 (9b49ef30). All three merges were clean, and I pushed none of them. The pinned feed built: consumer_facts.jsonl hashes to 03f5ea0f… and manifest.json to 1ea2d1ee…, both as pinned, so I set NZ_CHRONICLE_ARTIFACT for every run. Dependency sync took about 45 minutes of the time box, and one parallel run hit the container's memory limit (see item 8). The interim is comment 6614519818.

    1. Scope

    • Ran. git diff --stat df9fce45 1b56b916 lists 15 files. By commit:
      • 891cb9f1 changes concept_reference.py and build_nz_transport_graph.py:208.
      • 9cc37a58 changes both mappings, the closure, export_replay_bindings.json, transport_graph.json, reporting_classifiers.json, the method doc, both coverage outputs, the country-spec golden, test_nz_spec_package.py, test_transport_evidence_kernels.py:2734-2735 and build_nz_transport_graph.py:1271.
      • b78c8ad8 changes test_nz_transport_toy_graph.py.
      • 1725611c changes test_transport_evidence_kernels.py:810.
      • 1b56b916 changes the two mappings' note, the closure's exempt reason, the disclosure, the method doc, the golden, export_replay_bindings.json and test_nz_spec_package.py.
    • Ran. git show 891cb9f1 | git patch-id --stable prints e3f8d1c790817d1c3e6677e68fd1f0a564f68af2.
    • Read. No commit touches F/concept_mapping.py, any T/*.py, or another shared src or test-support file. The only shared test lines that change are test_transport_evidence_kernels.py:810 and :2734-2735.
    • Read. tools/build_nz_transport_graph.py:1271 drops "and the NZS sharing binding (R2C-5)" from pending_outputs. That follows from the binding, because the pending item is the binding this PR adds. The generator writes the same line to N/transport_graph.json:4981, and --check passes.

    2. The law

    • Read. rulespec-nz core.yaml hashes to d8ea0d15… at 6fe181fc, at 8dc2507d and at today's head 710453b3, and N/axiom_input_closure.json:28 pins the same hash.
    • Read. nz_super_living_alone_adult_age is 18 from 2026-04-01, sourced to s 4A(3) and (4) (core.yaml:65-79). The file names it only at its definition, so min_age: 18 copies it by hand, as the note says.
    • Read. The input's name, "shares residence with person at least adult age", supports an inclusive >=. rulespec's core.test.yaml:24 pins the parameter at 18 and doesn't test the boundary.
    • Read. Only nz_super_living_alone (core.yaml:550-572) reads the input. Only the single branch of nz_super_ordinary_weekly_rate_before_tax (:664-670) reads that rule. special_rates.yaml:115 reads its own nz_super_living_alone input, and NZ's closure holds no special_rates entry, so the binding doesn't reach it.
    • Read. The closure defaults nz_super_principal_residence_is_qualifying_accommodation to true, so every single person reaches the rate's living-alone test, and the binding decides it.
    • Read. The binding's note (nz.json:414-428) holds:
      • the formula quote matches core.yaml:567-571;
      • the partner gate: :567 requires nz_super_single and :664 branches on it first;
      • the exempt-adults carve-out, which :571 makes;
      • the proxy statement for household and residence;
      • "a person with no household id reads false", which item 3 confirms.
    • Read. The closure entries:
      • :1051-1052 is encoded by concept_binding, with no value.
      • :946 cites Schedule 1 Part 1 clause 1(b) for nz_super_single_not_living_alone_weekly_rate_before_tax, as core.yaml:96-111 sources it.
      • The amounts match: 595.57 at :111 and 647.37 at :95.
      • The reg 17 claim holds. N/as_rate_bridge.json:462-472 adds nz_super_weekly_amount_before_tax with coefficient 1 to a single_superannuitant unit.
      • The reason says the rule "reads this input only when [sharing] is true". That holds logically, because the formula's not A or B lets B matter only when A is true.
    • Read. The disclosure (N/reporting_classifiers.json:212-213) and the method doc (docs/nz-reporting-classifiers-method.md:66-71) agree with the closure. Item 7 checks that the disclosure's two evidence pointers resolve.

    3. The mechanism

    Read. ConceptsEncodeKernel.run (T/column_kernels.py:396-416) calls mapping.encode (:403). _apply dispatches CoresidentAdult to _coresident_adult_flags (F/concept_mapping.py:2231-2232, :2293-2318). That function factorizes the person table's own household ids with use_na_sentinel=True (:2304-2306). _defaults (:212-232) then calls InputClosure.defaults (F/input_closure.py:520-526), which keeps only DEFAULTED entries, so no default overwrites the bound column.

    Ran. I ran ConceptMapping.encode with the packaged frame mapping on frames I built:

    case head F
    alone (70) F F
    with an adult of exactly 18 T (the 18-year-old: T) same
    with a 17-year-old only F F
    co-resident's age null F F
    own household id null (other person 40, id 100) F; the 40-year-old also F same
    two persons, both ids null F, F F, F
    id names no household row: a pair T, T (grouped by id) same
    id names no household row: alone F F
    household table holds id 100 twice raises InvalidIndexError in _Context.__init__ same

    The head and F agree in every case. F1 changes _Context._household_rows, household_amount_for_persons and reference_rows, and _coresident_adult_flags reads none of them. So the hand-off's conclusion holds: F1's change doesn't reach the small-frame test. Its reason doesn't hold. The test does go through _Context, the class F1 edits, via mapping.encode; "not through Frame" isn't why. (nit 3)

    4. The toy table

    • Ran. I built the toy with make_nz_toy_fixture and run_entitlement_graph, took populations["nz.create"], and read household composition directly: another row with the same person_household_id and age >= 18.
      • The frame has 120 persons and 40 households, with no null, orphan or duplicated household ids.
      • The direct read and the binding agree for all 120 persons. 118 read true. The two that read false are 10006 (20) and 10009 (21).
    • Ran. Every one of the 20 rows aged 65 or over matches the hand-off's table: the household, the age, the partner and their age, the 16-year-old, and the after value of true.
    • Read. The small-frame test's rows follow from its literal table (test_nz_transport_toy_graph.py:1148-1159). The direct rule gives the same ten values that :1165-1176 pins, and the test passes in both runs.

    5. Closure and pins

    • Ran. At the head, git grep -n '791c1cd4\|06ce7ca0' prints nothing. The closure hashes to d2694200…, which export_replay_bindings.json:3 pins, and the golden's fingerprint is 9d0b57a1….
    • Ran. Each builder's --check passes with exit 0: build_nz_transport_graph.py, build_nz_benefit_gross_up.py and build_nz_entitlement_graph.py. So does refresh_concept_coverage.py --engine axiom-nz --check. git status stays clean.

    6. Moved counts

    • Ran. From the closure at df9fce45 and at the head:
      • The whole closure goes from 93 defaulted and 50 encoded to 92 and 51, over 143 entries.
      • nz_super's partition goes from 2 encoded and 11 defaulted to 3 and 10.
      • Only two entries differ: the sharing input (defaulted to encoded) and the exempt input (its reason text only).
    • Inferred. closure_default moves from 93 to 92 and derived from 322 to 323, because one closure default becomes an encoder output.
    • Ran. At M, all 14 TestClosurePartition tests and all 19 TestSupportRoster tests pass. The roster check includes 158 roots.
    • Ran. Coverage goes from 89 to 90 covered and from 579 to 578 uncovered. The golden's only content change adds the sharing input under fact:person.age and removes it from the uncovered list.
    • Inferred. Every move follows from this one input.

    7. The tests

    • Read. test_h2_sharing_binding_turns_the_toy_superannuitants_default_into_true (:1110-1138) is the weakest test.
      • Every toy superannuitant has a partner aged 65 or over and a 16-year-old.
      • So it tells "binding present" from "binding absent", but it can't see any min_age from 17 through 65.
      • The partner gate also means no rate reads these values.
    • Read. The small-frame test (:1141-1176) holds a single null-id person, so it can't tell "a null id shares with nobody" from "null ids group together".
    • Read. The renamed disclosure test checks the class, encoded_by, the missing value, the exempt default and two phrases. Making it pass vacuously would mean keeping both phrases while changing the meaning.

    Mutants. I ran each in a separate worktree at the head against the two H2 tests, the builder-parity test, the two disclosure tests, test_axiom_concept_mapping.py, test_country_spec.py, TestClosurePartition and TestSupportRoster (201 tests, all green before each mutant). I restored the tree after each one, and git status came back clean.

    1. Internal review's mutant: min_age 19 in both mappings (Ran). 2 red: test_h2_sharing_binding_on_the_household_shapes_the_toy_lacks and test_loaded_spec_matches_the_golden_file_byte_for_byte[nz]. The toy test stays green.
    2. Internal review's mutant: delete the binding from F/adapters/axiom_concept_mappings/nz.json alone (Ran). 5 red: test_packaged_graph_resources_match_their_builders[build_nz_transport_graph.py] and [build_nz_entitlement_graph.py], two TestClosurePartition tests (…partitions_as_the_design_table and …check_equals_input_closure_check_on_the_reduced_surface), and test_committed_coverage_report_matches_the_surface[nz]. The two new H2 tests stay green, as the hand-off says.
    3. Mine, A: min_age 65 in both mappings, aimed at the toy test (Ran). 2 red, the small-frame test and the country-spec golden. The toy test stays green, so only the small-frame test pins the age.
    4. Mine, B: use_na_sentinel=False in _coresident_adult_flags, aimed at the small-frame test's null-id row (Ran). All 201 tests stay green. H1's TestCoresidentAdult::test_a_null_household_id_shares_a_household_with_nobody and ::test_i7_null_household_ids_leave_the_others_exact go red. So H1's tests carry this, and H2's tests don't.

    8. Tests at the head and at M

    I ran the hand-off's 42 files with --dist loadfile and the feed set. The first attempt ran the head and M in parallel at -n 4 each, and the container's memory limit killed workers (dmesg shows oom-kill). The head's run aborted with an xdist INTERNALERROR, and I discarded it. M finished with one crashed worker. I reran the head alone at -n 3.

    • M (Ran): 3818 passed, 1 failed, 1 error (the crashed worker), 16 skipped.
      • G's fix rounds fix all 18 inherited tests at M: test_calibration_columns.py passes 106 and test_transport_holdout_separation.py passes 41.
      • The error is test_spec_source_citations.py::test_every_country_package_is_scanned, from the crashed worker. Run alone at M, it passes.
      • The failure is TestImplementationHashes::test_the_batterys_binding_identity_adds_only_the_leaf_and_its_imports (test_transport_evidence_kernels.py:534, 222 against 197 + 23). It fails the same way at origin/main 31c89390 alone, and passes at M without that main merge (2d35820c). Main's move since 4a36edcf causes it, not H2. I don't count it against H2, but H2 lands on a main where this test is red.
      • The clock pin doesn't apply: every run finished before 2026-11-04T00:00Z.
    • Head (Ran): 3798 passed, 18 failed, 16 skipped (3832 collected, the hand-off's count). The 18 failures are exactly the inherited set: 17 in test_calibration_columns.py and test_transport_holdout_separation.py::test_packaged_precalibration_stages_pass_the_stage_guard[nz].
    • The head against M, per test (Ran, from JUnit):
      • The 18 inherited tests fail at the head and pass at M, because G's fix rounds fix them.
      • test_the_batterys_binding_identity_adds_only_the_leaf_and_its_imports passes at the head and fails at M. The cause is main 31c89390, as above.
      • Four tests exist only at M, and all pass: test_packaged_interval_outputs_need_the_effective_interval_document, test_each_coarse_row_selects_its_native_rows_committed_labels, test_each_wage_cut_follows_from_its_committed_label and test_declared_scenarios_scale_the_standin_cuts_with_their_own_factor.
      • One test exists only at the head and passes: test_declared_scenarios_scale_national_cuts_with_their_own_factor.
      • Inferred: G's fix rounds add these four tests and rename the last one; I didn't trace the commits.
      • No H2 test differs between the head and M.
    • Skips at M (16, Ran):
      • test_nz_as_area_crosswalk.py:1085, :1096 and :1105: "Pinned ArcGIS bytes are distributed separately…"
      • test_nz_joint_geography.py:592, :602, :635 ×7, :654 and :671: "Set NZ_AS_AREA_SOURCES and NZ_JOINT_GEOGRAPHY_SOURCES…"
      • test_nz_axiom_input_closure.py:535 and :610: "needs axiom_rules_engine…"
    • Feed-gated tests: with the feed set, the 73 tests the hand-off skipped ran and passed at M. That includes test_nz_target_references.py (61), test_nz_reference_aging.py (22) and test_nz_family_reconciliation.py (16).

    9. The body and the hand-off

    • Read. Every file:line I checked holds at the head:
      • nz.json and N/concept_mapping.json:414-428;
      • the closure at :946 and :1051-1052;
      • the disclosure at :212-213, and the doc at :66-71;
      • the builder at :208 and :1271, and transport_graph.json:4981;
      • the golden at :3, :46, :53, :59, :83 and :94;
      • test_nz_spec_package.py:3238-3261 and :3422-3447, and test_transport_evidence_kernels.py:810 and :2734-2735;
      • T/column_kernels.py:212-232, :231 and :403, F/input_closure.py:520-526, and F/concept_mapping.py:2232 and :2305;
      • as_rate_bridge.json:462-472 and precalibration_stages.json:84-96;
      • core.yaml:65-79, :80-111, :567 and :664.
    • Read. The body gives core.yaml:550-573, but the rule ends at :572. (trivial)
    • Read. The hand-off's claim about F1 holds for the wrong reason (item 3). (nit 3)
    • Read. The hand-off's run1 covered 30 of the 42 files on a pre-9cc37a58 tree, not on the head. My head and M runs cover all 42.
    • Ran. The counts (93→92, 322→323, 2/11→3/10, 89→90, 579→578), the hashes and the before-and-after table all check out.

    Left out

    • I didn't trace which G commits add or rename the five tests that differ between the head and M.
    • I ran the mutants against a 201-test subset, not all 42 files.
    • I didn't run a real Axiom engine (two closure tests skip without one), and I didn't test the household-as-residence proxy on data.

    Fixes owed

    None.

    Nits (none blocks H2):

    1. packages/microcosm-build/tests/engine_free/shared/test_nz_transport_toy_graph.py:1110-1138: the toy test passes with min_age 65 (mutant A). Only the small-frame test pins the adult age. The docstring already says what the toy covers, so this needs no change unless the hub wants the toy to carry the age.
    2. test_nz_transport_toy_graph.py:1152: the small-frame test has one null-id person, so mutant B survives H2's tests. H1's TestCoresidentAdult catches it. The smallest fix gives person 10 a second null-id co-resident aged 18 or over and still expects False.
    3. The hand-off and the PR body ("not through Frame, so F1's frame change doesn't reach it") give the wrong reason. F1 edits _Context, which the test reaches. The conclusion holds because _coresident_adult_flags reads the person table's household ids directly (F/concept_mapping.py:2304-2306). The fix is a wording change in the body.
    4. For the hub, outside H2: origin/main 31c89390 fails test_transport_evidence_kernels.py:534 (222 against 220). H2 inherits it once it merges current main.

    Generated by Claude Code

  22. MaxGhenis commented on Nov 3, 2026

    @MaxGhenis
    ContributorAuthor

    Ready for hub review
    VERDICT: CLEAR_WITH_NITS
    Needs Max: no
    NZ clearance run on #1488

    A cloud session ran NZ's clearance checks on #1488. Base B0 is 20507ad8, head H is 3196273c, and the session started at 19:22 UTC. I built the feed under Python 3.13 from Chronicle 8f4cfd57. Ran: consumer_facts.jsonl = 03f5ea0fe60e…6b18 and manifest.json = 1ea2d1eeb122…e053, so both match the pins, and every run set NZ_CHRONICLE_ARTIFACT. T/ = packages/microcosm-build/src/microcosm/build/transport/; tests sit in packages/microcosm-build/tests/engine_free/shared/.

    The hunk does what #1488 claims on NZ's graphs. NZ's packaged graph composes, NZ's tests pass, and no kernel hash moves. Through NZ's builder, the extension pass refuses G7 (both placements), M2 and M4 at H, and composition accepts each of them at B0. One body claim fails: M1 never reaches the extension pass, because NZ's own test helper inserts a second declarer, and compile then refuses it at B0 and at H alike.

    1. Scope

    • Ran. git diff 20507ad8 3196273c --stat lists 4 files: changelog.d/transport-extension-measurement-proofs.fixed.md (bdc75bd5), T/compose.py (bdc75bd5), test_transport_measurement_proof.py (bdc75bd5 and 3196273c) and test_nz_transport_toy_graph.py (3196273c only). 3196273c touches only the two test files. I found no other file.
    • Ran. grep -n 'nz\.' test_transport_measurement_proof.py finds nothing. Lines 11–12 and 931 name NZ only in prose, with no node id.

    2. NZ's tests at H and at B0

    • Ran. I ran the six files with -n 4 and the feed set. H: 719 passed, 0 skipped, 0 failed. B0: 703 passed, 0 skipped, 0 failed. Neither tree skipped a test.
    • Ran. Per file at H / B0: declarations 62/62, entitlement graph 67/67, toy graph 37/31, measurement proof 43/33, composed graph 469/469, holdout separation 41/41. This confirms Belgium's 43 and 37 at H, and 62 + 67 = 129 matches the body's NZ count.
    • Ran. Only the 16 tests that Prove extension calibration nodes' measurement producers in compose #1488 adds differ: 10 in the shared file and 6 in NZ's toy file (test_composition_proves_each_extension_calibration_producer and the 5 …the_declaration_never_sees cases). Each exists only at H and passes there. No test that exists in both trees changes result.

    3. The packaged graph's proofs (H)

    • Ran. _nz_composition_spec_and_config() followed by compose_transport_graph composes. The graph has 9 calibration nodes: nz.calibrate and nz.v.{V1,V2,V3,V4,VEX,VHF,VPT,VSI}.calibrate. The declared proof names only nz.calibrate. Its proof columns are person.ird_taxable_income_proxy_cents and person.ird_taxable_income_proxy_universe_status.
    • Ran. In each node's compiled closure, the one ancestor that declares ird_taxable_income_ty2027_v1 is also the only writer of both columns:
    Node Declarer and sole writer of both columns
    nz.calibrate nz.income.standin (also the declared producer)
    nz.v.<V>.calibrate, for each of the 8 variants nz.v.<V>.income.standin

    4. NZ's mutants (through tools/build_nz_entitlement_graph.py)

    For each mutant I patched the builder, ran it, and ran --check (which printed "NZ entitlement resources match." every time). Then I composed the graph and ran test_nz_transport_declarations.py and test_transport_entitlement_graph.py with the feed set. The mutants ran in separate worktrees at H and B0. After each one I restored the builder, regenerated the resources, and saw --check match and git status print nothing. The builder is identical at B0 and H, and g7.py's anchor matched unchanged (Ran).

    Mutant B0: composition / failing tests H: composition / failing tests Check that refuses at H
    G7, NZ's placement composes; 1 fails (test_every_calibration_node_has_its_own_standin_as_the_only_writer: writers ['nz.v.VSI.income.standin', 'nz.v.VSI.review_rewrite']) refuses; 4 fail extension pass
    G7, Belgium's placement (nz.v.VSI.calibrate base → nz.v.VSI.review, which I saw in the generated scenarios.json) composes; 1 fails (the same NZ test) refuses; 4 fail extension pass
    M1 (VSI stand-in drops measurement_id) refuses; 7 fail refuses; 7 fail, the same 7 compile's version ownership (another composition check), at both trees
    M2 (VEX benefits.annual_transfers also declares) composes; 1 fails (the NZ test: declarers ['nz.v.VEX.benefits.annual_transfers', 'nz.v.VEX.income.standin']) refuses; 4 fail extension pass
    M4 (VEX universe column moves to benefits.annual_transfers) composes (the universe column's only writer is nz.v.VEX.benefits.annual_transfers); 2 fail (test_every_calibration_branch_has_one_standin_between_transfers_and_prepare, plus test_every_calibration_node_has_its_own_standin_as_the_only_writer: writers of the universe column ['nz.v.VEX.benefits.annual_transfers']) refuses; 5 fail extension pass

    The first refusals at H, in full (Ran):

    • G7, both placements: Measurement 'ird_taxable_income_ty2027_v1': the composed graph proves no producer for extension calibration node 'nz.v.VSI.calibrate'; measurement 'ird_taxable_income_ty2027_v1': calibration node 'nz.v.VSI.calibrate' has writers ['nz.v.VSI.income.standin', 'nz.v.VSI.review_rewrite'] of person.ird_taxable_income_proxy_cents upstream; only its producer 'nz.v.VSI.income.standin' may write it.
    • M2: Measurement 'ird_taxable_income_ty2027_v1': the composed graph proves no producer for extension calibration node 'nz.v.VEX.calibrate'; 2 nodes declaring measurement_id 'ird_taxable_income_ty2027_v1' sit upstream of calibration node 'nz.v.VEX.calibrate' (declared by ['nz.income.standin', …, 'nz.v.VEX.benefits.annual_transfers', 'nz.v.VEX.income.standin', …]); exactly one must.
    • M4: Measurement 'ird_taxable_income_ty2027_v1': the composed graph proves no producer for extension calibration node 'nz.v.VEX.calibrate'; its one declarer 'nz.v.VEX.income.standin' does not write ['person.ird_taxable_income_proxy_universe_status'].
    • M1, at B0 and at H: person.ird_taxable_income_proxy_cents in version 'nz.v.VSI' is owned by both 'nz.v.VSI.income.standin' and 'nz.v.VSI.synthetic_measurement'. (a GraphError from compile_graph)

    The tests that fail at H:

    • G7 and M2: test_nz_terminal_filter_keeps_the_skeletons_predecessors_and_ancestry, test_the_s5_guard_refuses_an_undeclared_nz_super_derived_output, test_nz_packaged_graph_compiles_with_only_pins_and_money_producers and test_every_calibration_node_has_its_own_standin_as_the_only_writer. All four fail on the refusal above.
    • M4: those four, plus test_every_calibration_branch_has_one_standin_between_transfers_and_prepare, which fails on its own column assertion.
    • M1: those four on the GraphError, plus test_every_calibration_branch_has_one_standin_between_transfers_and_prepare (KeyError: 'measurement_id') and both test_nz_variant_holdout_selection_refuses_before_calibration cases (their regex meets the GraphError).

    Why M1 behaves this way (Read): add_measurement (test_nz_transport_declarations.py:731-757) checks each calibration scenario. When no row in a branch declares the measurement, it inserts <branch>.synthetic_measurement, which declares the measurement and writes all three stand-in columns. Under M1, VSI's stand-in still writes those columns, so compile_graph refuses two owners at T/compose.py:4574 before _check_measurement_proofs (T/compose.py:4577-4579) ever runs. NZ's packaged graph can't exercise the zero-declarer branch through _nz_composition_spec_and_config. The toy's M1 (test_nz_transport_toy_graph.py:1055) does exercise it.

    5. The artifact edge

    • Ran. At H, I patched _check_measurement_proofs (T/compose.py:718) to drop the predecessors that each node gets only through artifact_inputs. I worked these out by compiling the graph again with every node's artifact_inputs emptied. Under that patch, test_transport_measurement_proof.py and test_nz_transport_toy_graph.py gave 79 passed and 1 failed. The one failure is …the_declaration_never_sees[_v3_rewrite_behind_the_problem-…]. This confirms Belgium's claim.
    • Ran. NZ's G7 (NZ's placement), built through the builder and run against the same patch, composes. So on the packaged graph, only the artifact edge from nz.v.VSI.targets.problem lets composition refuse NZ's placement. I restored the files, and git status came back clean.

    6. Kernels

    • Ran. make_composed_fixture installs 45 kernels, and every implementation_hash is identical at B0 and H. Under AxiomEngine, I built the registry with test_transport_registry.py's _fixture and the same monkeypatches that test_native_engines_use_pinned_refs_before_relation_compilation uses. There, simulate.counterfactual@1, simulate.rules_by_ref@1 and simulate.solve_zero@1 are identical at both trees. This confirms Belgium's claim. The real Axiom wheel isn't installed here, so the AxiomEngine hashes come from that monkeypatched registry.

    7. The body

    • Confirmed (Ran):
      • the toy composes, and its declared proof doesn't name nz.v.V3.calibrate;
      • the toy's five V3 mutants each refuse in the extension pass, naming nz.v.V3.calibrate;
      • H's two test files run on B0's compose.py fail exactly 9 shared tests and NZ's 5 mutant cases (14 failed, 66 passed);
      • the no-artifact-edges hand mutant reddens only the _v3_rewrite_behind_the_problem case;
      • NZ's two files give 129 passed with no skips;
      • the 43 and 37 counts;
      • the 45 + 3 kernel hashes;
      • G7 in both placements inside VSI composes at 20507ad8 and refuses at H, naming the stand-in and the rewrite. NZ's script names the rewrite nz.v.VSI.review_rewrite, where the body says review.rewrite.
    • Confirmed (Read): the toy's V1, V3 and VHF scenarios are calibration-tier (test_support/microcosm_build/transport_nz_toy.py:1168-1185).
    • Confirmed (Ran): the M2 and M4 table rows, both refusal reasons included. At 20507ad, M2 and M4 both compose.
    • Refuted (Ran): the table row "M1: VSI's stand-in drops measurement_id | composes | refuses: 0 declarers upstream of nz.v.VSI.calibrate". The body says it composed through _nz_composition_spec_and_config. Through that path, M1 doesn't compose at 20507ad8, and at H the refusal is compile_graph's ownership error, not 0 declarers (see item 4).
    • Stale text (Read): NZ's docstring at test_nz_transport_declarations.py:894-896 still says "Composition checks the measurement proof on the skeleton alone … it never sees nz.v.<V>.calibrate". Prove extension calibration nodes' measurement producers in compose #1488 makes that false.
    • Not checked: the VEX G7 rows, which NZ's builder mutant doesn't build.

    8. F1's US worker identity (#1481; doesn't affect the verdict)

    • Ran. I made worktrees at 3995d92d, at 9b49ef30, and at 3995d92d with only packages/microcosm-frame/src/microcosm/frame/concept_mapping.py from 9b49ef30, and ran uv sync --all-packages --locked in each. Each ran its own .venv/bin/python -I with no PYTHONPATH, and microcosm.__path__ listed only that worktree's package directories.
    • Ran. SHA-256 per worktree: base 94429354…75ab, head 7b3282ef…db27, mix 9448a2cd…77ce. These differ only because audit_aliases (argv_template_0, sys_executable, sys_prefix) embeds each worktree's path.
    • Ran. For a byte-for-byte check, I ran all three states in one worktree path, one after another: base 241f9140…dae1e, head bafa81d2…1d55, mix bafa81d2…1d55. The mix output is byte-identical to 9b49ef30's.
    • Ran. From 3995d92d to 9b49ef30, these fields change: semantic_identity.transitive_environment_code_sha256 (27ab80e7… → 8d1d4aef…), semantic_identity.worker_module.transitive_imports_sha256 (10191fc4… → 23701a92…) and semantic_identity_sha256 (48c4fdcd… → 59e7064c…). This confirms Belgium's claim that concept_mapping.py accounts for the whole move.

    Left out

    • I didn't check Prove extension calibration nodes' measurement producers in compose #1488's Hypothesis property or its 4,680-chain sweep (shared, not NZ), its 27-file reach run, or its US identity claim at bdc75bd5.
    • I didn't run the VEX G7 rows.
    • The refusal text repeats the measurement (Measurement '…': …; measurement '…': …). B0's skeleton path does the same, so I didn't count it as a finding.

    Fixes owed

    1. Prove extension calibration nodes' measurement producers in compose #1488 body, "NZ's packaged graph" table, M1 row. Through _nz_composition_spec_and_config, M1 doesn't compose at 20507ad8. At H, compile_graph refuses it with … owned by both 'nz.v.VSI.income.standin' and 'nz.v.VSI.synthetic_measurement', not "0 declarers", because add_measurement (test_nz_transport_declarations.py:731-757) inserts a declarer. Smallest fix: Belgium's hub corrects the row, or says how it built M1 so that the shim didn't fire.
    2. test_nz_transport_declarations.py:894-896. The docstring says that composition checks the proof on the skeleton alone and never sees nz.v.<V>.calibrate, which is false at H. Smallest fix: the NZ hub (or Prove extension calibration nodes' measurement producers in compose #1488, which already edits NZ's toy file) rewrites that sentence to say that the extension pass now runs the check too.
    3. test_nz_transport_declarations.py:731-757 (NZ hub). add_measurement says "until G declares one", but every branch now declares its stand-in, so the shim's only effect is to hide a zero-declarer branch from the extension pass (M1 above). Smallest fix: the NZ hub deletes add_measurement and its two calls (lines 759 and 763).

    Generated by Claude Code

  23. MaxGhenis commented on Nov 3, 2026

    @MaxGhenis
    ContributorAuthor

    Ready for hub review
    VERDICT: REQUEST_CHANGES
    Needs Max: no
    Review of #1501, G's five test fixes

    Head reviewed: 92ff931bf57a63a1394f7b3b6eddb80f0410349c (parent f43e8fbb, G's head). The PR's REST state reads open. I ran Python 3.13.18 after uv sync --all-packages --locked, with NZ_CHRONICLE_ARTIFACT unset, -p no:cacheprovider, and --basetemp in my scratchpad. Paths below are relative to packages/microcosm-build/tests/engine_free/shared/ unless they say otherwise.

    One blocker: the new nit-5 assertion fails at the head (test_nz_transport_toy_graph.py:842). The gate reports each match with a period suffix (toy_income_standin_amount__b0@2002), and the test compares the matches with bare row names. CI would go red on this file. The other four fixes hold, and each one turns red on its mutant.

    1. Scope

    • Ran: git diff f43e8fbb 92ff931b --stat changes only test_nz_target_references.py (+46) and test_nz_transport_toy_graph.py (+56/−1). I found no other files.

    2. Each change matches the PR body

    • Ran: The shared-endpoint rows match. In B/nz/income_diagnostic_references.json, the only rows 50–244 whose lower label equals the previous row's upper are row_60 ($50,000 - $51,000), row_90 ($80,000 - $81,000) and row_230 ($300,000 - $350,000). The test pins them at test_nz_target_references.py:1354 and checks them at :1433.
    • Ran: In B/nz/reference_intervals.json, the table ird_taxable_income_standin_ty2024 gives first_lower_cut_cents 4,000,000. Its finite upper_cents run from 5,000,000 to 100,000,000, and b12 is open, which matches the tail branch at :1419-1424.
    • Ran/Read: The aged cuts are 16,500, 44,000 and 66,000. These are Fraction(11, 10) × (15,000, 40,000, 60,000) from test_support/microcosm_build/transport_nz_toy.py:610-611. The cuts are exact integers, so int(cut) at test_nz_transport_toy_graph.py:999-1003 loses nothing. M3's falsifying example below prints all twelve records.
    • Read: The toy's first component is employment_income_cents (transport_nz_toy.py:600). Its add-backs list only rental_income_cents (:645). combine_measure adds back only declared add-back columns (B/transport/measure_kernels.py:213-216) and applies no floor. The floor applies only to the unit output (:357-360). So a wage-only record's measure equals its wage, and the edge example reaches the cuts as intended.
    • Read: Items 2, 3 and 4 match their diffs (test_nz_transport_toy_graph.py:870-881, :999-1013, :1041-1061).
    • Ran (body inaccuracy, minor): Item 5 says that, before this PR, "a row that left the income family went unnoticed while the gate passed." Under M5, the existing test_each_toy_coarse_row_enters_the_problem_matrix_once_with_support already goes red at test_nz_transport_toy_graph.py:813 (assert 'toy' == 'income'). Review 213's "erroring the fixture" doesn't hold either: the fixture builds, and the gate passes.

    3. Both files at the head

    • Ran: test_nz_target_references.py: 20 passed, 0 failed, 41 skipped. Every skip gives the reason "NZ_CHRONICLE_ARTIFACT is unset; public NZ feed audit unavailable" (lines 356, 464, 584, 836, 847, 866, 901, 918, 961, 989, 1041, 1071, 1100, 1138, 1159, 1179, 1229 and 1312).
    • Ran: test_nz_transport_toy_graph.py: 31 passed, 1 failed, 0 skipped. test_target_profile_coverage_passes_on_the_toy_with_income_required fails at :842:
      AssertionError: assert ['toy_income_...unt__b2@2002'] == ['toy_income_...in_count__b2']
      At index 0 diff: 'toy_income_standin_amount__b0@2002' != 'toy_income_standin_amount__b0'
      
      The gate itself passes (:836). The PR body says the hub didn't run this file, which explains the miss.

    4. Mutants

    I ran each mutant in place and restored it with git checkout -- <file>. git status --short came back empty after each one.

    • M1 (Ran): I moved row_59 from b01 to b02 for people and totals, in the labels and in metadata.native_rows. Only test_each_coarse_row_selects_its_native_rows_committed_labels goes red, at test_nz_target_references.py:1431 (assert 4900000 == 5000000, b01). All other tests stay green. Caveat: my first attempt left expected_member_count at 10, and that alone turned eight other tests red through a ValueError. Review 213's mutant passes those tests, so I also moved the counts (b01 to 9, b02 to 11). The figures above come from that run.
    • M2 (Ran): I set the bounds to [0, 100] and maximum_discrepant_bands to 99. test_measurement_prefit_fails_the_toy_only_on_its_unwired_evidence goes red at :879 (roster_total_bounds: assert [0, 100] == [0.75, 1.25]). The coverage test stays red at :842, as it is at the head.
    • M3 (Ran): I changed _cuts_at_or_below(..., inclusive=closure == "left") to inclusive=True (B/reference_intervals.py:701). The property test goes red at :1031 (assert np.int64(1) == 0) on the explicit example:
      Failing explicit example: check(records=[(-1,0,0,0,0,0,0), (0,…), (1,…), (16499,…), (16500,…), (16501,…),
        (43999,…), (44000,…), (44001,…), (65999,…), (66000,…), (66001,…)])
      
      In the toy file, the coverage test stays red at :842, as it is at the head. I also ran M3 against three other interval files and test_nz_target_references.py:
      • test_nz_reference_intervals.py goes red in 8 tests: test_property_the_scaled_coarse_table_tiles_the_positive_range, and test_shared_endpoint_labels_resolve_to_the_ordered_cuts for 7 cases (age 20,000, 50,000 and 80,000; national 20,000, 50,000, 80,000 and 300,000).
      • test_reference_intervals.py goes red in 13 tests: test_a_table_starting_above_zero_reports_records_below_its_first_cut [False-right] and [True-right], test_each_exact_cut_and_one_cent_either_side[right-expected0], test_first_positive_band_and_open_tail_under_both_closures[right-below-b1-b2], test_floored_negative_mass_reports_signed_amounts_by_actual_destination (2 cases), test_fractional_cent_aged_cuts_stay_exact_and_are_never_rounded, test_household_entity_table_is_weighted_through_memberships, test_left_closed_nil_zero_first_cut_diagnostics_keep_zero_in_nil, and four property tests.
      • test_reference_interval_support.py and test_nz_target_references.py stay green.
    • M4 (Ran): I set "add_backs": []. test_the_toy_standin_measure_keeps_the_packaged_central_rules goes red at :1058 (add_backs: assert [] == ['rental_income_cents']). The coverage test stays red at :842, as it is at the head.
    • M5 (Ran): I moved toy_income_standin_amount__b2 to family toy in toy_standin_references. The coverage test goes red at :842, past :836, so the gate still passes. It also failed before the mutant, though, so at the head the assertion can't tell M5 apart from the suffix bug. test_each_toy_coarse_row_enters_the_problem_matrix_once_with_support also goes red at :813.
    • Ran (fix check, reverted): I made the comparison strip the suffix ([name.partition("@")[0] for name in …["income"]] == sorted(…)). The coverage test then passes at the head and goes red on M5 at :842 (Right contains one more item: 'toy_income_standin_count__b2'). I restored the file afterwards, and git status is clean.

    5. Lint

    • Ran: uv run --no-sync ruff check prints "All checks passed!" on both files. ruff format --check prints "2 files already formatted".

    Left out

    • I ran no feed audits, because NZ_CHRONICLE_ARTIFACT is unset. I ran no package-wide suite, and I ran M1, M2, M4 and M5 against the named file only.
    • I found no CI check runs on 92ff931b through REST, so I can't confirm what CI reports.
    • I ran no hypothesis seeds beyond the default run.

    Fixes owed

    1. The nit-5 assertion fails at the head (test_nz_transport_toy_graph.py:842). The gate's matches_by_requirement["income"] names rows with a @2002 period suffix, and the test compares them with bare row names, so the toy file goes red. Smallest fix: compare [name.partition("@")[0] for name in row["details"]["matches_by_requirement"]["income"]], or build the expected names with the @2002 suffix. I ran the first form: it passes at the head and catches M5.
    2. The PR body overstates nit 5's gap (Tighten five G tests that checked less than their names claim #1501 body, "What changes" item 5). test_each_toy_coarse_row_enters_the_problem_matrix_once_with_support (test_nz_transport_toy_graph.py:813) already catches a family relabel. Smallest fix: reword item 5 to say that the new assertion pins the gate's own match list.

    Generated by Claude Code

  24. MaxGhenis commented on Nov 3, 2026

    @MaxGhenis
    ContributorAuthor

    Cloud review session picked up the delta review of #1501 and #1485.


    Generated by Claude Code

  25. MaxGhenis commented on Nov 3, 2026

    @MaxGhenis
    ContributorAuthor

    Ready for hub review
    VERDICT: #1501 APPROVE; #1485 APPROVE_WITH_NITS
    Needs Max: no
    Delta review of #1501 and #1485

    Heads reviewed: #1501 at 8edd52a3b62a0ccfbaa4f2655b0f289a28a70bd6 (parent 92ff931b) and #1485 at d82a2ba5aa4c2a00a38e52a4e84dab64b85bc418 (parent 1b56b916). Before I started, the REST API printed open and the expected head for both PRs. I ran Python 3.13.18 after uv sync --all-packages --locked, with NZ_CHRONICLE_ARTIFACT unset, -p no:cacheprovider, --basetemp in my scratchpad, and uv run --no-sync. T below means packages/microcosm-build/tests/engine_free/shared/test_nz_transport_toy_graph.py.

    Both commits do what their reviews asked. Each new assertion passes at its head and goes red on the mutant it targets. #1485's body has one small gap: its small-frame table doesn't list person 11.

    A1. #1501 scope

    • Ran: git diff 92ff931b 8edd52a3 --stat changes only T (+4/−2). The hunk sits inside test_target_profile_coverage_passes_on_the_toy_with_income_required. It extends the comment (T:837-840) and rewrites the assertion (T:843-848) to compare sorted(name.partition("@")[0] for name in matched) with the six stand-in row names. Nothing else changes.

    A2. #1501: the file at the head

    • Ran: T gives 32 passed, 0 failed, 0 skipped in 151 s. Review 221's failure at :842 is gone.
    • Ran: ruff check prints "All checks passed!" and ruff format --check prints "1 file already formatted".

    A3. #1501: M5

    • Ran: In test_support/microcosm_build/transport_nz_toy.py:705, I set family="toy" for toy_income_standin_amount__b2 only. The coverage test goes red at T:844, so the gate-status assertion at T:836 passed first:
      >       assert sorted(name.partition("@")[0] for name in matched) == sorted(
      E       AssertionError: assert ['toy_income_...in_count__b2'] == ['toy_income_...in_count__b2']
      E         At index 2 diff: 'toy_income_standin_count__b0' != 'toy_income_standin_amount__b2'
      E         Right contains one more item: 'toy_income_standin_count__b2'
      T:844: AssertionError
      
    • Ran: I restored the file with git checkout -- test_support/microcosm_build/transport_nz_toy.py, and git status --short printed nothing.
    • Ran: At the head, the test passes and goes red only under M5, so the assertion now separates M5 from a clean tree. That gap is what review 221 raised.

    A4. #1501: the comment's claim

    • Read: The target_profile_coverage binding is _target_profile_coverage (packages/microcosm-build/src/microcosm/build/transport/gate_bindings.py:789-823). It takes names = tuple(problem.problem.names) (:803), builds one {"name": str(name), "family": …} target per row (:807-810), and passes them to target_profile_coverage_gate (:819). So the gate receives the problem's row names.
    • Read: _compiled_problem (…/transport/target_kernels.py:552-554) builds expected = tuple(f"{spec.name}@{spec.period}" …) and raises unless problem.names == expected. So every row name reads name@period.
    • Inferred: The comment holds. Splitting on the first @ recovers the reference name, because the registry appends exactly one @{period} and the stand-in names contain no @. The @2002 matches that review 221 printed agree.

    B1. #1485 scope

    • Ran: git diff 1b56b916 d82a2ba5 --stat changes only T (+11/−6). Every hunk sits inside test_h2_sharing_binding_on_the_household_shapes_the_toy_lacks (T:1141-1181):
      • the docstring now names persons 10 and 11;
      • person_id runs 1–11;
      • person 11 has a null person_household_id and age 30;
      • before expects range(1, 12) all False (T:1168);
      • after adds 11: False (T:1180).
    • Read: Person 11 is an adult (30 ≥ 18), as review 215's nit 2 asked.

    B2. #1485: the file at the head

    • Ran: T gives 33 passed, 0 failed, 0 skipped in 138 s.
    • Ran: ruff check and ruff format --check both pass.

    B3. #1485: mutant B

    • Ran: I set use_na_sentinel=False in _coresident_adult_flags's factorize call (packages/microcosm-frame/src/microcosm/frame/concept_mapping.py:2305). The small-frame test goes red at T:1169. The before assertion at T:1168 passed first:
      >       assert after == {
      E         Differing items:
      E         {10: True} != {10: False}
      E         {11: True} != {11: False}
      T:1169: AssertionError
      
      Persons 10 and 11 read True after H2, as the brief predicted. The mutant groups their null ids into one household, and each has an adult co-resident.
    • Ran: I restored the file with git checkout --, and git status --short printed nothing.

    B4. #1485: the body

    • Read: The "Review:" paragraph matches review 215 on these points:

      • It cites brief 215, 1b56b916 and comment 6616135497.
      • It gives APPROVE_WITH_NITS, Needs Max: no, and no fixes owed. Review 215's "Fixes owed" reads "None".
      • It says the hub took nits 2 and 3, which match review 215's nit 2 (a second null-id adult) and nit 3 (the F1 reason).
      • It doesn't take nit 1, which review 215 says "needs no change".
    • Ran: For nit 4, pulls/1495 reads closed, merged at 2026-11-03T21:22:52Z. Its title is "Count the two modules Publish calibration diagnostics as schema 9, each hierarchy label declared once #1342 added to the transport bindings' identity…". That supports the body's "Publish calibration diagnostics as schema 9, each hierarchy label declared once #1342 … Count the two modules #1342 added to the transport bindings' identity, and merge main's US re-pin (#1496) #1495 fixed it on main at 21:22Z 11/03".

    • Read: The corrected reason holds. _coresident_adult_flags (concept_mapping.py:2293-2318) factorizes context.person[_PERSON_HOUSEHOLD_ID] (:2304-2306) and reads context.values("fact:person.age") (:2307). It never touches _household_rows, household_amount_for_persons or reference_rows.

    • Ran: git diff 3995d92d 9b49ef30 (F1's head on Give a person with no household no household's row, and read malformed holdout rows alike in both guards #1481, against its merge base) changes concept_mapping.py in exactly three places:

      • _Context.__init__ now sets _household_rows to −1 for null ids (:2096-2098 at F1);
      • household_amount_for_persons gains an appended-NaN read;
      • reference_rows gains an appended −1 read.

      These are the three members the body names. F1's base blob for the file (13790700) is H2's blob at d82a2ba5.

    • Read (nit): The body's small-frame table, under "The toy, before and after", stops at person 10. The next sentence says "the small-frame test pins every row above". The paragraph above that sentence mentions person 11, but the table has no row for it.

    Left out

    • I ran no package-wide suite and no feed-gated tests, because NZ_CHRONICLE_ARTIFACT is unset. I ran each mutant against its named test only, not against the whole file.
    • I didn't merge F1 into H2 and re-run the small-frame test. B4's F1 claim rests on F1's diff, plus review 215's run with F1 merged.
    • I didn't check CI runs on either head, and I didn't re-review Tighten five G tests that checked less than their names claim #1501's body (review 221's nit 2).

    Fixes owed

    None.

    Nit (doesn't block): #1485 body, under "The toy, before and after": add a row | 11 | none | 30 | none | false | false | to the small-frame table, so the table covers every row that T:1169-1181 pins.


    Generated by Claude Code

  26. MaxGhenis commented on Nov 4, 2026

    @MaxGhenis
    ContributorAuthor

    Cloud review session picked up the NZ QES September 2026 re-declaration review.


    Generated by Claude Code

  27. MaxGhenis commented on Nov 4, 2026

    @MaxGhenis
    ContributorAuthor

    Interim: NZ QES September 2026 re-declaration review

    Head reviewed: b44229cd4b8076854196440cd86b0790699c8f42 (state open, checked through the REST API).

    • Feed (Ran). I built Chronicle 89178aaf's NZ feed under Python 3.13. consumer_facts.jsonl has 12,370 rows and SHA-256 8580143f…35fb5, and manifest.json has ea0cc7ef…24a6. Both match nz/chronicle_feed.json.

    • Feed identity (Ran). reference_aging.json (21 identity blocks), reference_inventory.json and msd_pre_activation_reconciliation.json each carry the same seven fields as chronicle_feed.json. The golden nz_country_spec.json carries no identity block. It pins every resource's hash, and all 54 hashes match the files on disk. The generator --check passes for generate_nz_reference_aging, generate_nz_target_references, generate_nz_msd_pre_activation_reconciliation and build_nz_transport_graph, each exiting 0.

    • Operands (Ran). I filtered on stats_nz/SBSZ9A, the five dimensions and quarter. Each of the 13 operand quarters resolves to exactly one fact. Each fact's value, key and line number equal its _OPERANDS row, and each source_sha256 follows the vintage ruling: 25053dcb for 2023-Q2 to 2024-Q1, 1af2e7e3 for 2024-Q2, and d6fb9a94 from 2024-Q3 on.

    • Factors (Ran). I recomputed all ten factors with Decimal at 60 digits. All ten equal _EXPECTED, reference_aging.json and the PR table. The nearest rounding boundary sits 0.094e-6 from p1_high's unrounded value.

    • Window (Ran). require_current_factors refuses 2026-11-03, accepts 2026-11-04 and 2027-02-02, and refuses 2027-02-03.

    • Mutants (Ran). Each mutant turned test_nz_reference_aging.py red:

      • generator _EXPECTED p1_central +1e-6: "expected factor mismatch … re-declaration is required"
      • 2026-Q3 operand +1 cent: "Reviewed QES operand changed for 2026-Q3"
      • test _EXPECTED +1e-6: test_root_enclosure… fails

      I restored every file afterwards, and the tree is clean.

    • Schema v5 (Ran/Read). The pinned schema SHA-256 acd33a09… equals Chronicle's consumer_fact.v5.schema.json. NZ accepts rows through the exact pin (ledger_artifact.py:286-295), not through the epoch table.

    The test runs and the diff against the old pin are still in progress.


    Generated by Claude Code

  28. MaxGhenis commented on Nov 4, 2026

    @MaxGhenis
    ContributorAuthor

    Ready for hub review
    VERDICT: APPROVE_WITH_NITS
    Needs Max: no
    NZ QES September 2026 re-declaration review

    Head reviewed: b44229cd4b8076854196440cd86b0790699c8f42. pulls/1507 returned open and this SHA. I ran everything under Python 3.13.18 with NZ_CHRONICLE_ARTIFACT=$HOME/nz-feed/artifact. The working tree was clean at the end.

    Factors

    I recomputed the factors from my own artifact's values with decimal at 60 digits, using N = (Jun26 + Sep26·(1+q+q²))/4, q = (1+g)^(1/4) and half-even rounding to six digits. I didn't use the generator. Distance is the gap between the unrounded value and its nearest rounding boundary, in millionths.

    Factor Mine (unrounded) Mine _EXPECTED / reference_aging.json / PR table Distance
    ird_ty2024_to_ty2027_low 1.1174603575593… 1117460 1117460 ✓ 0.142
    ird_ty2024_to_ty2027_central 1.1184911997114… 1118491 1118491 ✓ 0.300
    ird_ty2024_to_ty2027_high 1.1195191202727… 1119519 1119519 ✓ 0.380
    p1_ty2026_to_ty2027_low 1.0289037958993… 1028904 1028904 ✓ 0.296
    p1_ty2026_to_ty2027_central 1.0298529458141… 1029853 1029853 ✓ 0.446
    p1_ty2026_to_ty2027_high 1.0307994056686… 1030799 1030799 ✓ 0.094
    donor_cy2024_to_ty2027_low 1.0794126197384… 1079413 1079413 ✓ 0.120
    donor_cy2024_to_ty2027_central 1.0804083633640… 1080408 1080408 ✓ 0.137
    donor_cy2024_to_ty2027_high 1.0814012848743… 1081401 1081401 ✓ 0.215
    observed_ty2026_over_ty2024 1.0860688453215… 1086069 1086069 ✓ 0.345

    p1_high sits closest to a boundary, at 0.094e-6. Even that is several orders of magnitude above any arithmetic error at 60 digits.

    1. Feed identity

    Ran (~/chronicle at 89178aaf): I built the feed under Python 3.13. consumer_facts.jsonl has 12,370 rows and SHA-256 8580143f…35fb5, and manifest.json has ea0cc7ef…24a6. Both match on the first build, so the 3.14 rerun wasn't needed.

    Ran (nz/chronicle_feed.json:6-15): the seven identity fields match my build.

    • reference_aging.json carries 21 identity blocks (top level plus each declaration and receipt). reference_inventory.json and msd_pre_activation_reconciliation.json carry one each. All 23 equal the pin.
    • The golden nz_country_spec.json carries no identity block. It pins resource hashes instead, and all 54 match the files on disk, including chronicle_feed.json (598f4f50…).

    Ran: each --check exits 0 against my artifact:

    • tools.generate_nz_reference_aging
    • tools.generate_nz_target_references
    • tools.generate_nz_msd_pre_activation_reconciliation
    • tools.build_nz_transport_graph ("NZ transport graph resources match the generator.")

    Ran (nz/earnings_bands.json): each profile's operational_factor equals its P1 entry in reference_aging.json. build_nz_transport_graph --check and test_nz_earnings_bands.py confirm the receipt hashes:

    • low 18bb7590…
    • central 492c93a1…
    • high 7d4e0a99…

    2. Operands

    Ran (tools/generate_nz_reference_aging.py:30-44): my filter used source_name=stats_nz, source_measure_id=SBSZ9A, the five dimensions and period.type=quarter. Each operand quarter matches exactly one fact, and each fact's value, key and line number equal its _OPERANDS row. The source_sha256 values follow the vintage ruling:

    • 2023-Q2 to 2024-Q1 (lines 12028–12034): 25053dcb
    • 2024-Q2 (12036): 1af2e7e3
    • 2024-Q3 to 2026-Q3 (12038–12054): d6fb9a94…26f3

    2025-Q1 (12042, 1637.20) also matches one fact; no factor uses it. 2026-Q3 holds 1714.86 at line 12054.

    Read (tools/generate_nz_reference_aging.py:85-91): _qes_hash implements the same rule.

    3. Factors

    Ran: see the table above. All ten agree across my recomputation, _EXPECTED, reference_aging.json, the PR table and the changelog fragment.

    Ran: the derived pins also agree:

    • docs/nz-calibration-targets.md:167-169: 40,000 × 1.118491 = 4,473,964 cents, and 125,000 × 1.118491 = 13,981,137.5 cents.
    • test_nz_reference_intervals.py:345: 50,000 × 1.118491 = 5,592,455 cents.
    • test_nz_transport_declarations.py:1137: 50,000 × 1.029853 = 51,492.65 and 51,000 × 1.029853 = 52,522.503.

    4. Window

    Ran (reference_factors.py:747-766 on the packaged reference_aging.json): require_current_factors refuses 2026-11-03, accepts 2026-11-04 and 2027-02-02, and refuses 2027-02-03. Each refusal names all ten factors.

    Read (transport/cli.py:708-710): _today uses the UTC date.

    Read (test_nz_reference_aging.py:117-133): the test pins the same four dates.

    Inferred: Stats NZ publishes at 10:45 NZDT on 2027-02-03, which is 21:45 UTC on 2027-02-02. Run entry therefore still accepts the factors for about two hours after the December quarter is public. The ruling chose this, and the previous window (2026-11-03) had the same gap. I didn't verify the release time, and I'm not flagging this as a fix.

    5. Re-anchoring and rounding tests

    Read (test_nz_reference_aging.py:262-307): test_december_observation_reanchors_and_requires_reviewed_redeclaration fails for the reason its name claims.

    • If the resolver ignored a newly observed 2026-Q4, the first pytest.raises("expected factor mismatch.*re-declaration") would not fire.
    • The second half checks three things: the slot statuses (observed, observed, observed, projected), the numerator bounds (Jun + Sep + Dec·(1 + q))/4, and a 2027-Q1 projection anchored on the synthetic December key.

    Read/Ran (test_nz_reference_aging.py:225-237): test_p1_rounds_once_and_the_truncated_numerator_refuses refuses the truncated numerator 1029852 for the right reason. It does not exercise the "rounds once" half of its name.

    • I computed p1_central under three intermediate-rounding schemes: N rounded to cents, the projected quarters rounded to cents, and q rounded to six digits. All three still give 1029853.
    • Only truncation differs, so a resolver that rounded intermediates would pass this test.
    • test_all_nine_factors… catches intermediate rounding for seven of the nine projected factors, so the suite still covers it. p1_low would cover both halves: truncation gives 1028903 and N rounded to cents gives 1028901. Nit 1 below.

    Ran (mutants on test_nz_reference_aging.py, each restored with git checkout, tree clean afterwards):

    • Generator _EXPECTED["p1_ty2026_to_ty2027_central"] changed to 1029854: red. The generator raises ValueError: Declared expected factor mismatch: computed {'numerator': 1029853, …}; re-declaration is required., which fails test_generated_aging_resource_reproduces_pinned_feed_and_all_receipts.
    • Generator 2026-Q3 operand changed to "1714.87": red. The generator raises Reviewed QES operand changed for 2026-Q3, and test_each_reviewed_operand_resolves_once_with_exact_lexical_evidence asserts '1714.86' == '1714.87'.
    • Test-file _EXPECTED changed to 1029854: red (test_root_enclosure_certifies_no_rounded_quarters_and_irrational_root).

    6. Tests

    Ran each file on its own with -p no:cacheprovider, --basetemp in the scratchpad and the feed exported:

    File Pass Fail Skip
    test_ledger_artifact.py 55 0 0
    test_nz_family_reconciliation.py 16 0 0
    test_nz_joint_geography.py 22 0 11
    test_nz_msd_pre_activation_reconciliation.py 5 0 0
    test_nz_reference_aging.py 22 0 0
    test_nz_reference_intervals.py 16 0 0
    test_nz_target_references.py 61 0 0
    test_nz_transport_declarations.py 62 0 0
    test_nz_money_normalization.py 56 0 0
    test_nz_earnings_bands.py 4 0 0
    test_transport_composed_graph.py (the three clock-pin tests) 3 0 0
    test_country_spec.py -k nz 3 0 0

    All 11 skips have the same reason: "Set NZ_AS_AREA_SOURCES and NZ_JOINT_GEOGRAPHY_SOURCES for pinned-source replay". They come from test_nz_joint_geography.py lines 592, 602, 635 (×7), 654 and 671. No skip names NZ_CHRONICLE_ARTIFACT. The counts match the PR body's table.

    7. Scope

    Ran (git diff origin/main...HEAD --name-only; merge base e2918f4f = origin/main):

    • changelog.d/1507-nz-qes-september-2026-redeclaration.changed.md (new fragment)
    • docs/nz-calibration-targets.md
    • packages/microcosm-build/src/microcosm/build/nz/chronicle_feed.json
    • packages/microcosm-build/src/microcosm/build/nz/earnings_bands.json
    • packages/microcosm-build/src/microcosm/build/nz/msd_pre_activation_reconciliation.json
    • packages/microcosm-build/src/microcosm/build/nz/reference_aging.json
    • packages/microcosm-build/src/microcosm/build/nz/reference_inventory.json
    • packages/microcosm-build/tests/engine_free/shared/test_nz_money_normalization.py
    • packages/microcosm-build/tests/engine_free/shared/test_nz_reference_aging.py
    • packages/microcosm-build/tests/engine_free/shared/test_nz_reference_intervals.py
    • packages/microcosm-build/tests/engine_free/shared/test_nz_target_references.py
    • packages/microcosm-build/tests/engine_free/shared/test_nz_transport_declarations.py
    • packages/microcosm-build/tests/engine_free/shared/test_transport_composed_graph.py: only the three _today pins move, 2026-10-24 to 2026-11-05 (lines 2804, 2815, 3536).
    • packages/microcosm-build/tests/golden/nz_country_spec.json
    • tools/generate_nz_reference_aging.py

    Every path falls inside the allowed set. The PR changes no runtime code: the only src/ changes are NZ JSON resources.

    8. Derived pins

    Ran (git grep -w over the nine old factors in integer and decimal form, 4,468,436, 5,585,545, 13,963,862.5, 51,429.0 and 52,457.58). Every hit stays put:

    • test_money_normalization.py:178,572-587,876-879 and test_reference_intervals.py:38,369-384,534-538,642,807 are shared tests that use the old factors as synthetic sample inputs and read no NZ resource. They should stay.
    • test_nz_reference_intervals.py:344 and test_nz_transport_declarations.py:1109 are historical comments ("design 80 gave …"). They should stay.

    No doc, resource or NZ test still pins a retired value.

    Ran: I searched the repository for the 22 old receipt and declaration hashes from main's reference_aging.json, and none appear anywhere. A 23rd old hash, the v4 schema digest d953306d…, survives only in the BE and UK pins and test_uk_chronicle_feed.py, which are those countries' own feeds and correct.

    9. Schema v5

    Read (ledger_artifact.py:464-470): the loader carries each row's schema_version and never gates on it.

    Read (ledger_artifact.py:257-295): validate_chronicle_feed_pin accepts the artifact only when its observed consumer_fact_schema_versions equal the pin exactly, so NZ accepts v5 through the reviewed pin.

    Read (chronicle_epoch.py:216-219, 328-330, 547-549): ACCEPTED_CONSUMER_FACT_SCHEMA_VERSIONS declares v1–v4 and not v5. is_accepted_consumer_fact_schema_version and consumer_fact_schema_epoch have no caller outside chronicle_epoch.py and its tests. No NZ code path accepts or refuses a fact by its row schema_version. Whether the epoch table should declare v5 is outside NZ's scope. See Nit 2.

    Ran: the pin's consumer_fact_schema_sha256 (acd33a09…6f970) equals the SHA-256 of Chronicle 89178aaf's policyengine_chronicle/schemas/consumer_fact.v5.schema.json and docs/schemas/consumer_fact.v5.schema.json. It also equals the manifest's own declaration.

    Read/Ran (groupby_value_label):

    • No NZ resource, NZ generator or NZ test reads it.
    • Shared ledger_targets.py reads it at three places. Two of them strip whitespace (:1524, :1596/:1650). _ledger_metadata (:5905) copies it raw.
    • None of the 16 relabelled facts' keys or their record set (stats_nz.hnw_2024.table5.01.estimates) appears in any NZ resource, tools/*nz* file or test_nz_*.py, so the change reaches nothing NZ resolves.

    Ran (old-pin diff): I built Chronicle 8f4cfd57's NZ feed. It reproduces the old pin: 12,368 rows, 03f5ea0f…/1ea2d1ee…. Diffed by aggregate_fact_key:

    • 12,352 facts are shared. 12,336 differ only in schema_version. 16 differ in schema_version plus layout.groupby_value_label ('One-person household ' becomes 'One-person household'). Those 16 are stats_nz household net worth 2023 facts: median, mean, total and household_count, four of each.
    • 16 facts appear only in the old feed, all stats_nz from 1af2e7e3 (June 2026).
    • 18 facts appear only in the new feed, all stats_nz from d6fb9a94 (September 2026).

    This matches the hub's account exactly.

    Left out

    • I didn't independently check Stats NZ's December 2026 release date (2027-02-03) or PREFU 2026 Table 4.1's growth rates. I took both from the spec.
    • I didn't check the hub's peak-memory figures.
    • I didn't rerun under Python 3.14, because every digest matched under 3.13.
    • I didn't run the broader engine-free suite outside the listed files.

    Fixes owed

    None blocking. Two nits, the first most relevant:

    1. Nit (packages/microcosm-build/tests/engine_free/shared/test_nz_reference_aging.py:225-237): the test's name claims "rounds once", but p1_ty2026_to_ty2027_central can't tell single rounding from intermediate rounding. N rounded to cents, the projected quarters rounded to cents and q rounded to six digits all still give 1029853, so the test only refuses truncation. Smallest fix: switch to p1_ty2026_to_ty2027_low and set its numerator to the truncated 1028903 (N rounded to cents gives 1028901). Alternatively, rename the test to drop "rounds once". test_all_nine_factors… already covers intermediate rounding for seven of the nine factors, so nothing is at risk now.
    2. Note, outside this PR's scope (packages/microcosm-build/src/microcosm/build/chronicle_epoch.py:206-219): the epoch table doesn't declare chronicle.consumer_fact.v5, though Chronicle has emitted it since chronicle#377. Nothing NZ runs consults the table, so this PR owes nothing. The module's owner can add the row whenever the table should match Chronicle's again.

    Generated by Claude Code

  29. MaxGhenis commented on Nov 10, 2026

    @MaxGhenis
    ContributorAuthor

    Hub ruling R2C-29: real producers for measurement_prefit's six evidence aliases (G8-x1, PR #1555)

    R2C-18 says to wire an alias only where a graph node really produces that artifact. This ruling names those nodes. It changes no R2C-3 number and adds no threshold. The gate still checks presence by alias, so each producer must refuse, or carry the run's real content, on its own.

    Alias Producer Population
    scenario_vsi, scenario_vex, scenario_vpt nz.v.{VSI,VEX,VPT}.gap, existing takeup.gap@1, through a new gate_evidence key in the entitlement graph each variant's branch
    identity_receipts nz.evidence.income_identity (transport.income_identity@1) nz.terminal
    omission_register nz.evidence.omission_register (transport.omission_register@1) nz.terminal
    native_band_report nz.evidence.native_bands (transport.native_band_report@1) nz.terminal
    1. The three income producers run after the fit. R2C-1's proof counts every node upstream of a calibration that declares the measurement, and a structural node's ancestry takes in every member of its base. A reader at nz.central would join nz.calibrate's proof and its key. At nz.terminal, their params and kernel sources stay out of that key.
    2. The readers name the measurement under measurement. Only the measure.combine@1 stand-ins declare measurement_id.
    3. identity_receipts checks four identities on every record of the central measure: component_sum, unit_rule, universe_partition and int64_range, in Python integers, over the declaration measure.combine@1's validator parses. A failure raises and names the record, so the artifact exists only when every identity holds. The add-back totals follow from component_sum, so the artifact reports them and checks no identity of their own.
    4. omission_register binds the declared items verbatim, sizes as declared, beside the included totals at pre-fit weights. Those weights come from the calibration problem, each person at their household's, and the register digests them as the native report does.
    5. native_band_report sets the model, at pre-fit and post-fit weights, beside each native row. The native rows must tile the coarse bands, by cuts and by facts, or the kernel refuses. It stays diagnostic (R2C-4).

    The hub drafted this ruling as R2C-28 on 11/09 and renumbered it, because comment 6601354890 already used that number. #1555 implements it and landed as 5509c92 on 2026-11-10; #1563 tracks three follow-ups from its reviews.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions